diff --git a/.circleci/config.yml b/.circleci/config.yml index 6ab1db9b4a8..4eec9b5885d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -158,6 +158,8 @@ jobs: CHOCOLATEY_CONFIRM_ALL: "true" - run: name: Install Dependencies + environment: + UV_HTTP_TIMEOUT: "300" command: | $installer = Join-Path $env:TEMP "uv-install.ps1" Invoke-WebRequest -Uri https://astral.sh/uv/0.10.9/install.ps1 -OutFile $installer diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 8ee3a1ed0cd..2d4e85630dc 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -215,8 +215,10 @@ jobs: tests/proxy_unit_tests/test_models_fallback_endpoint.py tests/proxy_unit_tests/test_google_endpoint_routing.py tests/proxy_unit_tests/test_google_gemini_proxy_request.py + tests/proxy_unit_tests/test_gemini_agents_endpoints.py tests/proxy_unit_tests/test_get_favicon.py tests/proxy_unit_tests/test_get_image.py + tests/proxy_unit_tests/test_reducto_ocr_route.py tests/proxy_unit_tests/test_ui_path_detection.py tests/proxy_unit_tests/test_prompt_test_endpoint.py tests/proxy_unit_tests/test_check_batch_cost.py diff --git a/.github/workflows/test-unit-proxy-mgmt-behavior.yml b/.github/workflows/test-unit-proxy-mgmt-behavior.yml new file mode 100644 index 00000000000..e73997323a4 --- /dev/null +++ b/.github/workflows/test-unit-proxy-mgmt-behavior.yml @@ -0,0 +1,34 @@ +name: "Unit Tests: Proxy Management-Endpoint Behavior Pinning" + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_branch + - "litellm_**" + +permissions: + contents: read + id-token: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + proxy-mgmt-behavior: + uses: ./.github/workflows/_test-unit-services-base.yml + with: + test-path: tests/proxy_behavior + # workers=0 (no xdist): the world seed is a single shared Postgres + # state — two xdist workers both call seed_world() and race on the + # ``behavior-pin-budget`` row, producing UniqueViolation + cascading + # missing-membership FK failures. The whole suite is ~7s sequentially, + # so the cost of disabling parallelism here is negligible. + workers: 0 + reruns: 0 + enable-postgres: true + artifact-name: proxy-mgmt-behavior + timeout-minutes: 15 diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 4de4a55981d..2729babb6d6 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -24,7 +24,8 @@ RUN for i in 1 2 3; do \ curl \ openssl \ libsndfile \ - nodejs && break || sleep 5; \ + nodejs \ + npm && break || sleep 5; \ done ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index ee27cc3585e..0654f17ec68 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.72" +version = "0.4.73" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.72" +version = "0.4.73" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm/__init__.py b/litellm/__init__.py index c868ae55b4f..3365abe3256 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -225,6 +225,10 @@ use_chat_completions_url_for_anthropic_messages: bool = bool( route_all_chat_openai_to_responses: bool = ( os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true" ) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge +use_legacy_interactions_schema: bool = ( + os.getenv("LITELLM_USE_LEGACY_INTERACTIONS_SCHEMA", "false").lower() == "true" +) # When True, sends Api-Revision: 2026-05-07 to Google so responses use the legacy `outputs` +# schema instead of the new `steps` schema. Remove this flag after June 8, 2026. retry = True ### AUTH ### api_key: Optional[str] = None @@ -409,6 +413,12 @@ internal_user_budget_duration: Optional[str] = None tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None max_end_user_budget: Optional[float] = None max_end_user_budget_id: Optional[str] = None +# When True, end-user IDs extracted from requests are validated against +# LiteLLM_EndUserTable / LiteLLM_UserTable. Values that do not resolve to a +# known row are dropped before reaching spend logs. Defaults to False for +# backwards compatibility — arbitrary client-supplied identifiers still +# pass through unchanged. +validate_end_user_id_in_db: bool = False disable_end_user_cost_tracking: Optional[bool] = None disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None @@ -632,6 +642,7 @@ minimax_models: Set = set() aws_polly_models: Set = set() gigachat_models: Set = set() llamagate_models: Set = set() +reducto_models: Set = set() bedrock_mantle_models: Set = set() @@ -899,6 +910,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None): gigachat_models.add(key) elif value.get("litellm_provider") == "llamagate": llamagate_models.add(key) + elif value.get("litellm_provider") == "reducto": + reducto_models.add(key) elif value.get("litellm_provider") == "bedrock_mantle": bedrock_mantle_models.add(key) @@ -1010,6 +1023,7 @@ model_list = list( | ovhcloud_models | lemonade_models | docker_model_runner_models + | reducto_models | bedrock_mantle_models | set(clarifai_models) ) @@ -1116,6 +1130,7 @@ models_by_provider: dict = { "aws_polly": aws_polly_models, "gigachat": gigachat_models, "llamagate": llamagate_models, + "reducto": reducto_models, "bedrock_mantle": bedrock_mantle_models, } @@ -1288,6 +1303,18 @@ from .responses.main import * # Interactions API is available as litellm.interactions module # Usage: litellm.interactions.create(), litellm.interactions.get(), etc. from . import interactions +from .interactions.agents.main import ( + acreate as acreate_agent, + create as create_agent, + alist as alist_agents, + list as list_agents, + aget as aget_agent, + get as get_agent, + adelete as adelete_agent, + delete as delete_agent, + alist_versions as alist_agent_versions, + list_versions as list_agent_versions, +) from .skills.main import ( create_skill, acreate_skill, diff --git a/litellm/_uuid.py b/litellm/_uuid.py index 52acf647dd8..2b7c3b82d35 100644 --- a/litellm/_uuid.py +++ b/litellm/_uuid.py @@ -6,7 +6,6 @@ Always uses fastuuid for performance. import fastuuid as _uuid # type: ignore - # Expose a module-like alias so callers can use: uuid.uuid4() uuid = _uuid diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py index 28020e763f4..4548185bbdc 100644 --- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py +++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py @@ -9,7 +9,6 @@ from typing import Dict, Optional from .exceptions import AnthropicErrorResponse, AnthropicErrorType - # HTTP status code -> Anthropic error type # Source: https://docs.anthropic.com/en/api/errors ANTHROPIC_ERROR_TYPE_MAP: Dict[int, AnthropicErrorType] = { diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py index 984390fa702..b289e493e6b 100644 --- a/litellm/anthropic_interface/exceptions/exceptions.py +++ b/litellm/anthropic_interface/exceptions/exceptions.py @@ -2,7 +2,6 @@ from typing_extensions import Literal, Required, TypedDict - # Known Anthropic error types # Source: https://docs.anthropic.com/en/api/errors AnthropicErrorType = Literal[ diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index e3cbf422e5d..51abbbf729b 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -30,6 +30,11 @@ from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.bridges.completion_transformation import ( CompletionTransformationBridge, ) +from litellm.responses.sse_output_recovery import ( + parse_sse_json_chunk, + record_output_item_chunk, + record_output_text_chunk, +) from litellm.types.llms.openai import ( ChatCompletionAnnotation, ChatCompletionReasoningItem, @@ -97,7 +102,7 @@ def _build_reasoning_item( def _reasoning_item_to_response_input( - r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]] + r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]], ) -> Dict[str, Any]: """Convert a stored ChatCompletionReasoningItem back to a Responses API input item.""" r_input: Dict[str, Any] = { @@ -601,6 +606,79 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): return choices + @classmethod + def _extract_output_from_completed_event( + cls, parsed_chunk: Dict[str, Any] + ) -> Optional[List[Dict[str, Any]]]: + response_payload = parsed_chunk.get("response") + if not isinstance(response_payload, dict): + return None + response_output = response_payload.get("output") + if not isinstance(response_output, list) or len(response_output) == 0: + return None + return cast(List[Dict[str, Any]], response_output) + + @classmethod + def _recover_output_items_from_raw_sse( + cls, raw_sse: Optional[str] + ) -> List[Dict[str, Any]]: + if not raw_sse or not isinstance(raw_sse, str): + return [] + + recovered_output_items: Dict[int, Dict[str, Any]] = {} + recovered_text_only_items: Dict[int, Dict[str, Any]] = {} + + for chunk in raw_sse.splitlines(): + parsed_chunk = parse_sse_json_chunk(chunk) + if parsed_chunk is None: + continue + + event_type = parsed_chunk.get("type") + + if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + recovered_output = cls._extract_output_from_completed_event( + parsed_chunk + ) + if recovered_output is not None: + return recovered_output + continue + + if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: + record_output_item_chunk( + parsed_chunk=parsed_chunk, + output_items=recovered_output_items, + ) + continue + + if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE: + record_output_text_chunk( + parsed_chunk=parsed_chunk, + output_items=recovered_output_items, + text_only_items=recovered_text_only_items, + ) + continue + + # Merge text-only items into the recovered output items. Real + # OUTPUT_ITEM_DONE events take precedence at any given output_index, + # but text-only items at indices without a matching OUTPUT_ITEM_DONE + # must still be preserved (e.g. multi-output responses where some + # indices only emitted OUTPUT_TEXT_DONE). + merged_items: Dict[int, Dict[str, Any]] = {**recovered_text_only_items} + merged_items.update(recovered_output_items) + + if merged_items: + return [item for _, item in sorted(merged_items.items())] + + return [] + + @classmethod + def _recover_output_items_from_logging( + cls, logging_obj: "LiteLLMLoggingObj" + ) -> List[Dict[str, Any]]: + model_call_details = getattr(logging_obj, "model_call_details", {}) or {} + original_response = model_call_details.get("original_response") + return cls._recover_output_items_from_raw_sse(original_response) + def transform_response( # noqa: PLR0915 self, model: str, @@ -625,9 +703,22 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if raw_response.error is not None: raise ValueError(f"Error in response: {raw_response.error}") + output_items = raw_response.output + if len(output_items) == 0: + recovered_output_items = self._recover_output_items_from_logging( + logging_obj + ) + if recovered_output_items: + output_items = cast(Any, recovered_output_items) + raw_response.output = cast(Any, recovered_output_items) + verbose_logger.warning( + "Recovered empty Responses API output from raw SSE for model=%s", + model, + ) + # Convert response output to choices using the static helper choices = self._convert_response_output_to_choices( - output_items=raw_response.output, + output_items=output_items, handle_raw_dict_callback=self._handle_raw_dict_response_item, ) @@ -641,7 +732,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) else: raise ValueError( - f"Unknown items in responses API response: {raw_response.output}" + f"Unknown items in responses API response: {output_items}" ) setattr(model_response, "choices", choices) @@ -1237,7 +1328,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): raise ValueError( f"Chat provider: Invalid function argument delta {parsed_chunk}" ) - elif event_type == "response.output_item.done": + elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: # New output item added output_item = parsed_chunk.get("item", {}) if output_item.get("type") == "function_call": diff --git a/litellm/compression/content_detection.py b/litellm/compression/content_detection.py index 0655a42daf5..975117eb608 100644 --- a/litellm/compression/content_detection.py +++ b/litellm/compression/content_detection.py @@ -5,7 +5,6 @@ Auto-detect content type per message: code, JSON, or text. import json import re - _CODE_KEYWORDS = re.compile( r"\b(?:def |function |class |import |from |require\(|#include|fn |func |const |let |var |public |private |static )\b" ) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 2257861aff6..98e00cf5788 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1879,10 +1879,6 @@ def ocr_cost( if response.usage_info is None: raise ValueError("OCR response usage_info is None") - pages_processed = response.usage_info.pages_processed - if pages_processed is None: - raise ValueError("OCR response pages_processed is None") - try: model_info: Optional[ModelInfo] = litellm.get_model_info( model=model, custom_llm_provider=custom_llm_provider @@ -1890,9 +1886,49 @@ def ocr_cost( except Exception: model_info = None - ocr_cost_per_page: float = 0.0 + credits = getattr(response.usage_info, "credits", None) + cost_per_credit = None if model_info is not None: - ocr_cost_per_page = model_info.get("ocr_cost_per_page") or 0.0 + cost_per_credit = model_info.get("ocr_cost_per_credit") + if credits is not None and cost_per_credit is not None: + return cost_per_credit * credits, 0.0 + + ocr_cost_per_page: Optional[float] = None + if model_info is not None: + ocr_cost_per_page = model_info.get("ocr_cost_per_page") + + pages_processed = response.usage_info.pages_processed + if pages_processed is None: + if cost_per_credit is not None or ocr_cost_per_page is None: + # Surface missing usage data instead of silently under-reporting + # cost. The previous behavior raised ValueError; we now return 0.0 + # for credit-priced or unpriced models, so log a warning to keep + # the regression visible to operators. + verbose_logger.warning( + "OCR cost: model=%s custom_llm_provider=%s response.usage_info." + "pages_processed is None and credits=%s; returning 0.0 cost.", + model, + custom_llm_provider, + credits, + ) + return 0.0, 0.0 + raise ValueError("OCR response pages_processed is None") + + if ocr_cost_per_page is None: + # No per-page pricing configured. Either the model is on credit-based + # pricing (and credits weren't returned, so the credit branch above did + # not match) or the model has no OCR pricing entry at all. Surface a + # warning so that missing pricing entries are visible rather than + # silently producing zero cost for billable usage. + verbose_logger.warning( + "OCR cost: model=%s custom_llm_provider=%s reported " + "pages_processed=%s but no ocr_cost_per_page is configured; " + "returning 0.0 cost.", + model, + custom_llm_provider, + pages_processed, + ) + return 0.0, 0.0 total_ocr_processing_cost: float = ocr_cost_per_page * pages_processed return total_ocr_processing_cost, 0.0 diff --git a/litellm/files/types.py b/litellm/files/types.py index 688bc86f0cf..ba42a39f666 100644 --- a/litellm/files/types.py +++ b/litellm/files/types.py @@ -1,6 +1,5 @@ from typing import AsyncIterator, Dict, Iterator, Literal, NamedTuple, Union - FileContentProvider = Literal[ "openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "anthropic", "manus" ] diff --git a/litellm/google_genai/adapters/__init__.py b/litellm/google_genai/adapters/__init__.py index bfa9e712678..6fbe7d95a55 100644 --- a/litellm/google_genai/adapters/__init__.py +++ b/litellm/google_genai/adapters/__init__.py @@ -1,10 +1,10 @@ """ Google GenAI Adapters for LiteLLM -This module provides adapters for transforming Google GenAI generate_content requests +This module provides adapters for transforming Google GenAI generate_content requests to/from LiteLLM completion format with full support for: - Text content transformation -- Tool calling (function declarations, function calls, function responses) +- Tool calling (function declarations, function calls, function responses) - Streaming (both regular and tool calling) - Mixed content (text + tool calls) """ diff --git a/litellm/integrations/SlackAlerting/batching_handler.py b/litellm/integrations/SlackAlerting/batching_handler.py index fdce2e04793..828f3eb4175 100644 --- a/litellm/integrations/SlackAlerting/batching_handler.py +++ b/litellm/integrations/SlackAlerting/batching_handler.py @@ -1,9 +1,9 @@ """ -Handles Batching + sending Httpx Post requests to slack +Handles Batching + sending Httpx Post requests to slack -Slack alerts are sent every 10s or when events are greater than X events +Slack alerts are sent every 10s or when events are greater than X events -see custom_batch_logger.py for more details / defaults +see custom_batch_logger.py for more details / defaults """ from typing import TYPE_CHECKING, Any diff --git a/litellm/integrations/SlackAlerting/utils.py b/litellm/integrations/SlackAlerting/utils.py index e695266c88b..e2580768178 100644 --- a/litellm/integrations/SlackAlerting/utils.py +++ b/litellm/integrations/SlackAlerting/utils.py @@ -18,7 +18,7 @@ else: def process_slack_alerting_variables( - alert_to_webhook_url: Optional[Dict[AlertType, Union[List[str], str]]] + alert_to_webhook_url: Optional[Dict[AlertType, Union[List[str], str]]], ) -> Optional[Dict[AlertType, Union[List[str], str]]]: """ process alert_to_webhook_url diff --git a/litellm/integrations/additional_logging_utils.py b/litellm/integrations/additional_logging_utils.py index 795afd81d41..59319140a18 100644 --- a/litellm/integrations/additional_logging_utils.py +++ b/litellm/integrations/additional_logging_utils.py @@ -1,5 +1,5 @@ """ -Base class for Additional Logging Utils for CustomLoggers +Base class for Additional Logging Utils for CustomLoggers - Health Check for the logging util - Get Request / Response Payload for the logging util diff --git a/litellm/integrations/custom_batch_logger.py b/litellm/integrations/custom_batch_logger.py index f9d4496c21f..8f4844501c3 100644 --- a/litellm/integrations/custom_batch_logger.py +++ b/litellm/integrations/custom_batch_logger.py @@ -1,5 +1,5 @@ """ -Custom Logger that handles batching logic +Custom Logger that handles batching logic Use this if you want your logs to be stored in memory and flushed periodically. """ @@ -14,22 +14,38 @@ from litellm.integrations.custom_logger import CustomLogger class CustomBatchLogger(CustomLogger): + preserve_events_added_during_flush = False + + # Default cap on the in-memory log queue. Prevents unbounded memory growth + # if ``async_send_batch`` consistently fails (e.g. the destination is + # unreachable) and events are preserved across flush attempts. Subclasses + # may override by passing ``max_queue_size`` or by setting the attribute + # directly (see ``RubrikLogger`` for an example). + DEFAULT_MAX_QUEUE_SIZE = 50_000 + def __init__( self, flush_lock: Optional[asyncio.Lock] = None, batch_size: Optional[int] = None, flush_interval: Optional[int] = None, + max_queue_size: Optional[int] = None, **kwargs, ) -> None: """ Args: flush_lock (Optional[asyncio.Lock], optional): Lock to use when flushing the queue. Defaults to None. Only used for custom loggers that do batching + max_queue_size (Optional[int], optional): Maximum number of events to retain in ``log_queue``. When the limit is exceeded (e.g. because the send destination is unreachable and events are preserved for retry), the oldest events are dropped. Defaults to ``DEFAULT_MAX_QUEUE_SIZE``. """ self.log_queue: List = [] self.flush_interval = flush_interval or litellm.DEFAULT_FLUSH_INTERVAL_SECONDS self.batch_size: int = batch_size or litellm.DEFAULT_BATCH_SIZE self.last_flush_time = time.time() self.flush_lock = flush_lock + self.max_queue_size: int = ( + max_queue_size + if max_queue_size is not None + else self.DEFAULT_MAX_QUEUE_SIZE + ) super().__init__(**kwargs) @@ -47,11 +63,40 @@ class CustomBatchLogger(CustomLogger): async with self.flush_lock: if self.log_queue: + log_queue_length = len(self.log_queue) verbose_logger.debug( "CustomLogger: Flushing batch of %s events", len(self.log_queue) ) - await self.async_send_batch() - self.log_queue.clear() + try: + await self.async_send_batch() + except Exception: + # If the underlying batch send raised, do NOT drop the + # in-flight events. They will be retried on the next flush. + # Most existing async_send_batch implementations swallow + # their own errors, so this only affects loggers that opt + # in to surfacing failures (e.g. Rubrik). + verbose_logger.exception( + "CustomLogger: async_send_batch raised; preserving " + "%s events in queue for retry", + log_queue_length, + ) + # Guard against unbounded queue growth if the destination + # is persistently unreachable. Drop the oldest events + # beyond ``max_queue_size``. + overflow = len(self.log_queue) - self.max_queue_size + if overflow > 0: + del self.log_queue[:overflow] + verbose_logger.warning( + "CustomLogger: log queue exceeded max_queue_size=%s; " + "dropped %s oldest events.", + self.max_queue_size, + overflow, + ) + return + if self.preserve_events_added_during_flush: + del self.log_queue[:log_queue_length] + else: + self.log_queue.clear() self.last_flush_time = time.time() async def async_send_batch(self, *args, **kwargs): diff --git a/litellm/integrations/focus/transformer.py b/litellm/integrations/focus/transformer.py index b7d28e3dbb9..6f4433b4a05 100644 --- a/litellm/integrations/focus/transformer.py +++ b/litellm/integrations/focus/transformer.py @@ -9,7 +9,6 @@ import polars as pl from .schema import FOCUS_NORMALIZED_SCHEMA - _TAG_KEYS = ( "team_id", "team_alias", diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index ced15a01660..6c8510380a8 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -673,6 +673,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): if parent_otel_span is not None: parent_otel_span.set_status(Status(StatusCode.ERROR)) + # Stamp team attributes onto the SERVER (root) span too, so the + # trace root is team-filterable on the failure path like the + # child exception span below. + self._set_team_attributes_on_span( + span=parent_otel_span, + team_id=user_api_key_dict.team_id, + team_alias=user_api_key_dict.team_alias, + ) + # Stamp structured error attrs on the SERVER span itself; the # failure path otherwise only sets its status (_handle_failure # records on the litellm_request child span). Inline import: @@ -709,12 +718,65 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): key="exception", value=str(original_exception), ) + self._set_team_attributes_on_span( + span=exception_logging_span, + team_id=user_api_key_dict.team_id, + team_alias=user_api_key_dict.team_alias, + ) exception_logging_span.set_status(Status(StatusCode.ERROR)) exception_logging_span.end(end_time=self._to_ns(datetime.now())) + # Emit guardrail spans for any guardrail invocations that + # ran during this request. _handle_failure typically does this, + # but for pre-call guardrail blocks the standard_logging_object + # may not carry guardrail_information by the time _handle_failure + # fires (the data lives only in request_data["metadata"]). Pull + # directly from request_data so the span is recorded either way; + # _emit_once dedupes if _handle_failure already emitted it. + self._emit_guardrail_spans_from_request_data( + request_data=request_data, + parent_span=parent_otel_span, + ) + # End Parent OTEL Sspan parent_otel_span.end(end_time=self._to_ns(datetime.now())) + def _emit_guardrail_spans_from_request_data( + self, + request_data: dict, + parent_span: Optional[Any], + ) -> None: + """Emit ``guardrail`` spans from ``request_data["metadata"] + ["standard_logging_guardrail_information"]``. + + Routed through ``_create_guardrail_span`` so the dedupe state in + ``_otel_internal`` is honoured — if ``_handle_failure`` already + emitted these spans for the same kwargs, this is a no-op. + """ + from opentelemetry import trace as _trace + + metadata = (request_data or {}).get("metadata") or {} + guardrail_information = metadata.get("standard_logging_guardrail_information") + if not guardrail_information: + return + + # _create_guardrail_span reads guardrail_information from + # kwargs["standard_logging_object"] and shares its dedupe state via + # kwargs["litellm_params"]["metadata"]["_otel_internal"]. Pass the + # SAME metadata dict the proxy populated so _handle_failure and + # this hook see the same dedupe markers. + kwargs: Dict[str, Any] = { + "litellm_params": {"metadata": metadata}, + "standard_logging_object": { + "guardrail_information": guardrail_information, + "metadata": metadata, + }, + } + context = ( + _trace.set_span_in_context(parent_span) if parent_span is not None else None + ) + self._create_guardrail_span(kwargs=kwargs, context=context) + async def async_post_call_success_hook( self, data: dict, @@ -1012,6 +1074,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ): parent_span.end(end_time=self._to_ns(end_time)) + # Stamp team attributes onto the SERVER (root) span before it is + # closed, so the trace root carries them like every child span. + self._set_team_attributes_on_proxy_span_from_kwargs(kwargs) + # close the proxy span explicitly from kwargs metadata # after all child spans (litellm_request, guardrail, raw_request) # have been fully recorded and exported. @@ -1070,8 +1136,70 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ) raw_span.set_status(Status(StatusCode.OK)) self.set_raw_request_attributes(raw_span, kwargs, response_obj) + self._set_team_attributes_from_kwargs(raw_span, kwargs) raw_span.end(end_time=self._to_ns(end_time)) + def _set_team_attributes_on_span( + self, + span: Span, + team_id: Optional[str], + team_alias: Optional[str], + ) -> None: + """Stamp team_id / team_alias onto a span so every child span of a + litellm_request trace carries them, not just the root span. + + Empty strings are treated as absent: a request made with the master + key or a team-less virtual key carries ``user_api_key_team_id=""`` + in ``standard_logging_object.metadata``; propagating that to every + span only adds noise that makes traces look mis-instrumented. + """ + if team_id: + self.safe_set_attribute( + span=span, + key="metadata.user_api_key_team_id", + value=team_id, + ) + if team_alias: + self.safe_set_attribute( + span=span, + key="metadata.user_api_key_team_alias", + value=team_alias, + ) + + def _set_team_attributes_from_kwargs(self, span: Span, kwargs: dict) -> None: + """Pull team_id / team_alias from the standard logging metadata in kwargs and stamp them onto span.""" + std_log = kwargs.get("standard_logging_object") + md: dict = {} + if isinstance(std_log, dict): + md = std_log.get("metadata") or {} + elif std_log is not None: + md = getattr(std_log, "metadata", None) or {} + self._set_team_attributes_on_span( + span=span, + team_id=md.get("user_api_key_team_id"), + team_alias=md.get("user_api_key_team_alias"), + ) + + def _set_team_attributes_on_proxy_span_from_kwargs(self, kwargs: dict) -> None: + """Stamp team attributes onto the proxy SERVER (root) span so the + trace root is filterable by team, not just its children. The root + span is created in auth before the team is resolved and is + otherwise only closed (never re-attributed) on the success path. + + Guarded to the LiteLLM-created proxy span (by name + recording) so + externally provided parent spans are never mutated. + """ + litellm_params = kwargs.get("litellm_params") or {} + metadata = litellm_params.get("metadata") or {} + proxy_span = metadata.get("litellm_parent_otel_span") + if ( + proxy_span is not None + and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME + and hasattr(proxy_span, "is_recording") + and proxy_span.is_recording() + ): + self._set_team_attributes_from_kwargs(proxy_span, kwargs) + def _record_metrics(self, kwargs, response_obj, start_time, end_time): duration_s = (end_time - start_time).total_seconds() params = kwargs.get("litellm_params") or {} @@ -1531,12 +1659,45 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): "masked_entity_count", safe_dumps(masked_entity_count) ) + guardrail_response = guardrail_information.get("guardrail_response") + if guardrail_response is not None: + guardrail_span.set_attribute( + "guardrail_response", safe_dumps(guardrail_response) + ) + + # Surface guardrail_status (success / guardrail_intervened / + # guardrail_failed_to_respond / not_run) as a top-level span + # attribute so trace backends can filter on it without parsing + # guardrail_response. self.safe_set_attribute( span=guardrail_span, - key="guardrail_response", - value=guardrail_information.get("guardrail_response"), + key="guardrail_status", + value=guardrail_information.get("guardrail_status"), ) + # Provider's raw top-level action (e.g. Bedrock's + # ``GUARDRAIL_INTERVENED`` / ``NONE``). Populated by the provider + # hook onto StandardLoggingGuardrailInformation so this integration + # stays provider-agnostic — we only read a normalised string. + guardrail_action = guardrail_information.get("guardrail_action") + if guardrail_action: + guardrail_span.set_attribute("guardrail_action", guardrail_action) + + # The provider hook (e.g. Bedrock) extracts violation_categories + # from the raw response BEFORE redaction and stamps them onto + # StandardLoggingGuardrailInformation. Surfacing them here as a + # queryable attribute lets dashboards group by violation category + # without parsing the redacted guardrail_response blob. + violation_categories = guardrail_information.get("violation_categories") + if violation_categories: + # OTel sequence attributes must be homogeneous primitives; + # serialise to JSON once so set_attribute never coerces. + guardrail_span.set_attribute( + "guardrail_violation_categories", safe_dumps(violation_categories) + ) + + self._set_team_attributes_from_kwargs(guardrail_span, kwargs) + guardrail_span.end(end_time=self._to_ns(end_time_datetime)) def _handle_failure(self, kwargs, response_obj, start_time, end_time): diff --git a/litellm/integrations/opik/utils.py b/litellm/integrations/opik/utils.py index b0ab5991c91..43577505c11 100644 --- a/litellm/integrations/opik/utils.py +++ b/litellm/integrations/opik/utils.py @@ -105,7 +105,7 @@ def _remove_nulls(x: Dict[str, Any]) -> Dict[str, Any]: def get_traces_and_spans_from_payload( - payload: List[Dict[str, Any]] + payload: List[Dict[str, Any]], ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """ Separate traces and spans from payload. diff --git a/litellm/integrations/rubrik.py b/litellm/integrations/rubrik.py new file mode 100644 index 00000000000..af396ecdc73 --- /dev/null +++ b/litellm/integrations/rubrik.py @@ -0,0 +1,605 @@ +"""Rubrik LiteLLM Plugin for tool blocking and batch logging.""" + +import asyncio +import os +import random +import time +import urllib.parse +import uuid +from collections import Counter +from typing import TYPE_CHECKING, Any, Literal, Optional + +import httpx +from litellm._logging import verbose_logger +from litellm.integrations.custom_batch_logger import CustomBatchLogger +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, +) +from litellm.litellm_core_utils.core_helpers import safe_deep_copy +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import ( + ChatCompletionMessageToolCall, + Function, + GenericGuardrailAPIInputs, + StandardLoggingPayload, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import ( + Logging as LiteLLMLoggingObj, + ) + +_ENDPOINT_ANTHROPIC_MESSAGES = "/v1/messages" +_WEBHOOK_PATH_TOOL_BLOCKING = "/v1/after_completion/openai/v1" +_WEBHOOK_PATH_LOGGING_BATCH = "/v1/litellm/batch" +_MAX_QUEUE_SIZE = 10_000 +_DROP_WARNING_INTERVAL_SECONDS = 60.0 + + +class _MalformedToolBlockingResponseError(Exception): + """Raised when the tool blocking service returns a structurally invalid + response (e.g. empty ``choices``). + + Distinct from transient network/HTTP errors so callers can surface a + louder, misconfiguration-style log instead of treating it as a routine + fail-open. + """ + + +class RubrikLogger(CustomGuardrail, CustomBatchLogger): + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + **kwargs, + ): + self.flush_lock = asyncio.Lock() + kwargs.setdefault("guardrail_name", "rubrik") + # `initialize_guardrail` always passes these kwargs explicitly, with + # value `None` when the user omits `mode` / `default_on` from the + # guardrail config. Coerce None (omitted) to the desired default + # while preserving any explicit value the caller did set -- + # in particular `default_on=False` if the user wants the guardrail + # off by default. + kwargs["event_hook"] = kwargs.get("event_hook") or GuardrailEventHooks.post_call + if kwargs.get("default_on") is None: + kwargs["default_on"] = True + super().__init__( + flush_lock=self.flush_lock, + **kwargs, + ) + + verbose_logger.debug("initializing rubrik logger") + + self.sampling_rate = 1.0 + rbrk_sampling_rate = os.getenv("RUBRIK_SAMPLING_RATE") + if rbrk_sampling_rate is not None: + try: + parsed_rate = float(rbrk_sampling_rate.strip()) + self.sampling_rate = max(0.0, min(1.0, parsed_rate)) + if parsed_rate != self.sampling_rate: + verbose_logger.warning( + f"RUBRIK_SAMPLING_RATE={parsed_rate} clamped to " + f"{self.sampling_rate}" + ) + except ValueError: + verbose_logger.warning( + f"Invalid RUBRIK_SAMPLING_RATE: {rbrk_sampling_rate!r}, using 1.0" + ) + + self.key = api_key or os.getenv("RUBRIK_API_KEY") + if not self.key: + verbose_logger.warning( + "Rubrik: No API key configured. Requests will be unauthenticated." + ) + _batch_size = os.getenv("RUBRIK_BATCH_SIZE") + + if _batch_size: + try: + self.batch_size = int(_batch_size) + except ValueError: + verbose_logger.warning( + f"Invalid RUBRIK_BATCH_SIZE: {_batch_size!r}, using default" + ) + + # Cap the in-memory retry queue so a Rubrik webhook outage cannot let + # authenticated traffic accumulate prompt/response payloads until the + # proxy runs out of memory. Once the cap is reached, oldest events are + # dropped to make room for fresh ones (drop-oldest backpressure). + self.max_queue_size = _MAX_QUEUE_SIZE + self._dropped_since_warning = 0 + self._last_drop_warning_time = 0.0 + + _webhook_url = api_base or os.getenv("RUBRIK_WEBHOOK_URL") + + if _webhook_url is None: + raise ValueError( + "Rubrik webhook URL not configured. " + "Set RUBRIK_WEBHOOK_URL or pass api_base." + ) + + _webhook_url = _webhook_url.rstrip("/").removesuffix("/v1") + self.tool_blocking_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_TOOL_BLOCKING}" + self.logging_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_LOGGING_BATCH}" + + self.async_httpx_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.LoggingCallback + ) + + self.tool_blocking_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.LoggingCallback, + params={"timeout": httpx.Timeout(5.0, connect=2.0)}, + ) + + self._headers: dict[str, str] = {"Content-Type": "application/json"} + if self.key: + self._headers["Authorization"] = f"Bearer {self.key}" + + # Periodic flush is started lazily on the first log event so that + # low-traffic deployments still get their batches drained even when the + # logger is instantiated outside a running event loop (sync init). + self._flush_task: Optional[asyncio.Task[Any]] = ( + self._start_periodic_flush_task() + ) + + def _start_periodic_flush_task(self) -> Optional[asyncio.Task[Any]]: + """Start the periodic flush task only when an event loop is already running.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + verbose_logger.debug( + "Rubrik logger init: no running event loop, " + "periodic flush will start on first log event." + ) + return None + return loop.create_task(self.periodic_flush()) + + def _ensure_periodic_flush_task(self) -> None: + # Synchronous helper: in asyncio's cooperative model there is no await + # between the check and assignment, so two callers cannot race here. + if self._flush_task is None or self._flush_task.done(): + self._flush_task = self._start_periodic_flush_task() + + async def aclose(self): + """Close the dedicated HTTP clients used by this logger.""" + # Cancel the periodic flush task before closing the HTTP clients so + # the loop doesn't wake up and try to POST via a closed client. + if self._flush_task is not None and not self._flush_task.done(): + self._flush_task.cancel() + try: + await self._flush_task + except (asyncio.CancelledError, Exception): + pass + self._flush_task = None + await self.tool_blocking_client.close() + await self.async_httpx_client.close() + + # -- Guardrail hook -------------------------------------------------------- + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + """Validate tool calls against the blocking service (fail-open).""" + if input_type != "response": + return inputs + + tool_calls = inputs.get("tool_calls") + if not tool_calls: + return inputs + + try: + return await self._check_tool_calls( + inputs, tool_calls, request_data, logging_obj + ) + except ModifyResponseException: + raise + except _MalformedToolBlockingResponseError as e: + # Distinct from transient errors: the service responded but the + # payload was structurally invalid, which usually indicates a + # misconfigured webhook or a breaking change in its response + # format. Log loudly so operators notice their tool-blocking + # policy is not actually being enforced. + verbose_logger.critical( + "Tool blocking service returned a malformed response: %s. " + "Tool calls are NOT being checked -- verify the webhook " + "configuration. Returning original response unchanged.", + e, + exc_info=True, + ) + return inputs + except Exception as e: + verbose_logger.error( + f"Tool blocking hook failed: {e}. " + "Returning original response unchanged.", + exc_info=True, + ) + return inputs + + async def _check_tool_calls( + self, + inputs: GenericGuardrailAPIInputs, + tool_calls: Any, + request_data: dict, + logging_obj: Optional["LiteLLMLoggingObj"], + ) -> GenericGuardrailAPIInputs: + """Send tool calls to blocking service, raise if any are blocked.""" + message_tool_calls = self._normalize_tool_calls(tool_calls) + + call_details = ( + getattr(logging_obj, "model_call_details", {}) if logging_obj else {} + ) + response = request_data.get("response") + request_id = getattr(response, "id", None) if response else None + if logging_obj and not call_details: + verbose_logger.warning( + "Rubrik: logging_obj present but model_call_details is empty " + "-- request context will be missing" + ) + + response_data = self._build_tool_call_payload(message_tool_calls, request_id) + req_data = self._extract_request_data(call_details) + + service_response = await self._post_to_tool_blocking_service( + response_data, req_data + ) + blocked_explanation = self._extract_blocked_tools( + service_response, message_tool_calls + ) + + if blocked_explanation is not None: + model = self._resolve_model(request_data, call_details) + raise ModifyResponseException( + message=blocked_explanation, + model=model, + request_data=request_data, + guardrail_name=self.guardrail_name, + ) + + return inputs + + @staticmethod + def _normalize_tool_calls(tool_calls: Any) -> list[ChatCompletionMessageToolCall]: + """Convert tool_calls from inputs to ChatCompletionMessageToolCall objects.""" + result = [] + for tc in tool_calls: + if isinstance(tc, ChatCompletionMessageToolCall): + result.append(tc) + elif isinstance(tc, dict): + func = tc.get("function", {}) + result.append( + ChatCompletionMessageToolCall( + id=tc.get("id", ""), + type=tc.get("type", "function"), + function=Function( + name=func.get("name", ""), + arguments=func.get("arguments", ""), + ), + ) + ) + elif hasattr(tc, "id") and hasattr(tc, "function"): + result.append( + ChatCompletionMessageToolCall( + id=tc.id or "", + type=getattr(tc, "type", None) or "function", + function=tc.function, + ) + ) + else: + raise TypeError( + f"Cannot normalize tool_call of type {type(tc).__name__}" + ) + return result + + @staticmethod + def _build_tool_call_payload( + tool_calls: list[ChatCompletionMessageToolCall], + request_id: str | None, + ) -> dict[str, Any]: + """Build a full OpenAI ChatCompletion-format dict for the blocking service.""" + return { + "id": request_id or f"chatcmpl-{uuid.uuid4()}", + "object": "chat.completion", + "created": int(time.time()), + "model": "", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + tc.model_dump(exclude_none=True) for tc in tool_calls + ], + }, + "finish_reason": "tool_calls", + } + ], + } + + @staticmethod + def _extract_request_data(call_details: dict[str, Any]) -> dict[str, Any]: + """Extract original request data from model_call_details.""" + if not call_details: + return {} + litellm_params = call_details.get("litellm_params", {}) or {} + return { + "messages": call_details.get("messages"), + "model": call_details.get("model"), + "proxy_server_request": RubrikLogger._sanitize_proxy_server_request( + litellm_params.get("proxy_server_request") + ), + } + + @staticmethod + def _sanitize_proxy_server_request(proxy_server_request: Any) -> Any: + """Allowlist only routing fields (``url``, ``method``) when forwarding + ``proxy_server_request`` to the external Rubrik webhook, dropping + inbound ``headers`` (Authorization, Cookie, x-api-key, ...) and the raw + request ``body`` so proxy credentials are not exfiltrated.""" + if not isinstance(proxy_server_request, dict): + return proxy_server_request + return { + key: proxy_server_request[key] + for key in ("url", "method") + if key in proxy_server_request + } + + @staticmethod + def _resolve_model( + request_data: dict[str, Any], call_details: dict[str, Any] + ) -> str: + """Get the model name for the ModifyResponseException.""" + response = request_data.get("response") + if response and hasattr(response, "model"): + return response.model or "unknown" + return call_details.get("model", "unknown") + + # -- Logging hooks --------------------------------------------------------- + + async def _prepare_log_payload( + self, kwargs: dict, event_type: str + ) -> StandardLoggingPayload | None: + """Shared logic for success and failure logging.""" + if random.random() > self.sampling_rate: + verbose_logger.debug( + f"Skipping Rubrik {event_type} logging " + f"(sampling_rate={self.sampling_rate})" + ) + return None + + # Deep-copy so mutations don't affect other callbacks sharing this object + standard_logging_payload: StandardLoggingPayload = safe_deep_copy( + kwargs["standard_logging_object"] + ) + + # For Anthropic /v1/messages requests, LiteLLM creates a separate + # ModelResponse (with a generated chatcmpl-* id) for logging, which + # differs from the original Anthropic msg-* id on the response dict. + # Normalize to litellm_call_id so that the logging and tool-blocking + # endpoints see the same request identifier. + litellm_params = kwargs.get("litellm_params", {}) or {} + proxy_request = litellm_params.get("proxy_server_request", {}) or {} + url_path = urllib.parse.urlparse(proxy_request.get("url", "")).path + if url_path.endswith(_ENDPOINT_ANTHROPIC_MESSAGES): + _litellm_call_id = kwargs.get("litellm_call_id") + if _litellm_call_id: + standard_logging_payload["id"] = _litellm_call_id # type: ignore[literal-required] + + if "system" in kwargs: + system_prompt_msg_list = kwargs["system"] + try: + if system_prompt_msg_list: + system_scaffold = { + "role": "system", + "content": system_prompt_msg_list, + } + if isinstance(standard_logging_payload["messages"], list): + standard_logging_payload["messages"].insert(0, system_scaffold) + elif isinstance(standard_logging_payload["messages"], (dict, str)): + standard_logging_payload["messages"] = [ + system_scaffold, + standard_logging_payload["messages"], + ] + except Exception as e: + verbose_logger.warning( + f"Rubrik: failed to prepend system prompt: {e}", + exc_info=True, + ) + + return standard_logging_payload + + async def _enqueue_log_event(self, kwargs: dict, event_type: str): + try: + self._ensure_periodic_flush_task() + payload = await self._prepare_log_payload(kwargs, event_type) + if payload is None: + return + + self.log_queue.append(payload) + self._enforce_max_queue_size() + + if len(self.log_queue) >= self.batch_size: + await self.flush_queue() + except Exception as e: + verbose_logger.error( + f"Rubrik {event_type} logging hook failed: {e}. " + "Skipping logging for this event.", + exc_info=True, + ) + + def _enforce_max_queue_size(self) -> None: + overflow = len(self.log_queue) - self.max_queue_size + if overflow <= 0: + return + del self.log_queue[:overflow] + self._dropped_since_warning += overflow + now = time.time() + if now - self._last_drop_warning_time >= _DROP_WARNING_INTERVAL_SECONDS: + verbose_logger.warning( + "Rubrik: log queue exceeded max_queue_size=%s; dropped %s " + "oldest events since the last warning. The Rubrik webhook may " + "be unhealthy or undersized for current traffic.", + self.max_queue_size, + self._dropped_since_warning, + ) + self._dropped_since_warning = 0 + self._last_drop_warning_time = now + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + await self._enqueue_log_event(kwargs, "success") + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + await self._enqueue_log_event(kwargs, "failure") + + # -- Batch logging --------------------------------------------------------- + + async def _log_batch_to_rubrik(self, data): + # NOTE: this method intentionally re-raises on failure so the parent + # CustomBatchLogger.flush_queue keeps the unsent events in the queue + # for the next flush attempt instead of silently dropping them. + try: + response = await self.async_httpx_client.post( + url=self.logging_endpoint, + json=data, + headers=self._headers, + ) + response.raise_for_status() + except httpx.HTTPStatusError as e: + verbose_logger.exception( + f"Rubrik HTTP Error: {e.response.status_code} - {e.response.text}" + ) + raise + except Exception: + verbose_logger.exception("Rubrik Layer Error") + raise + + async def async_send_batch(self): + """Handles sending batches of responses to Rubrik. + + Note: the canonical flush path is :meth:`flush_queue`, which takes a + single snapshot used for both sending and queue draining. This method + is kept for direct callers / tests; it intentionally does NOT remove + events from the queue. + """ + if not self.log_queue: + return + + log_queue_snapshot = list(self.log_queue) + verbose_logger.debug( + "Rubrik: Flushing batch of %s events", len(log_queue_snapshot) + ) + await self._log_batch_to_rubrik( + data=log_queue_snapshot, + ) + + async def flush_queue(self): + """Snapshot, send, and drain in one consistent step. + + Overrides the base implementation so the same snapshot drives both + the HTTP send and the queue truncation. This avoids the subtle + coupling where the base class captures `len(self.log_queue)` + separately from the snapshot taken inside `async_send_batch`, + which could otherwise drift in a future refactor and cause + duplicate deliveries to Rubrik. + """ + if self.flush_lock is None: + return + + async with self.flush_lock: + if not self.log_queue: + return + snapshot = list(self.log_queue) + verbose_logger.debug("Rubrik: Flushing batch of %s events", len(snapshot)) + try: + await self._log_batch_to_rubrik(data=snapshot) + except Exception: + # Already logged with traceback inside _log_batch_to_rubrik. + # Preserve the in-flight events for retry on the next flush. + return + del self.log_queue[: len(snapshot)] + self.last_flush_time = time.time() + + # -- Tool blocking service ------------------------------------------------- + + async def _post_to_tool_blocking_service( + self, + response_data: dict[str, Any], + request_data: dict[str, Any], + ) -> dict[str, Any]: + """Post a payload to the tool blocking service and return the response. + + Args: + response_data: The OpenAI-formatted response payload to send. + request_data: Original LLM request data to include alongside + the response for additional context. Empty dict if unavailable. + + Raises: + Exception: If the service is unavailable or returns an error. + """ + envelope = { + "request": request_data, + "response": response_data, + } + verbose_logger.debug( + f"Sending request to tool blocking service: " + f"{self.tool_blocking_endpoint}" + ) + http_response = await self.tool_blocking_client.post( + self.tool_blocking_endpoint, + json=envelope, + headers=self._headers, + ) + http_response.raise_for_status() + result: dict[str, Any] = http_response.json() + return result + + @staticmethod + def _extract_blocked_tools( + service_response: dict[str, Any], + all_tool_calls: list[ChatCompletionMessageToolCall], + ) -> Optional[str]: + """Return the blocking explanation if any tool calls were blocked. + + Compares the service response (which contains only allowed tools) against + the full set of tool calls. Returns ``None`` if all tools are allowed, or + the explanation string (prefixed with newlines) otherwise. + + Expects service_response in OpenAI chat completion format: + {"choices": [{"message": {"tool_calls": [...], "content": "..."}}]} + """ + choices = service_response.get("choices", []) + if not choices: + raise _MalformedToolBlockingResponseError( + "Tool blocking service returned empty response" + ) + + message = choices[0].get("message", {}) + returned_tool_calls = message.get("tool_calls") or [] + blocking_explanation = message.get("content", "") + + allowed_id_counts: Counter = Counter( + tc["id"] + for tc in returned_tool_calls + if isinstance(tc, dict) and tc.get("id") + ) + required_id_counts: Counter = Counter(tc.id for tc in all_tool_calls if tc.id) + + all_allowed = len(returned_tool_calls) >= len(all_tool_calls) and all( + allowed_id_counts.get(tc_id, 0) >= count + for tc_id, count in required_id_counts.items() + ) + + if all_allowed: + return None + + explanation = blocking_explanation or "Tool call blocked by policy." + return f"\n\n{explanation}" diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 332e84dd07d..4ed8a809a13 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -1,8 +1,8 @@ """ s3 Bucket Logging Integration -async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 -async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 +async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 +async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 NOTE 1: S3 does not provide a BATCH PUT API endpoint, so we create tasks to upload each element individually """ diff --git a/litellm/interactions/__init__.py b/litellm/interactions/__init__.py index e1125b649a6..ed01462cba6 100644 --- a/litellm/interactions/__init__.py +++ b/litellm/interactions/__init__.py @@ -5,31 +5,40 @@ This module provides SDK methods for Google's Interactions API. Usage: import litellm - + # Create an interaction with a model response = litellm.interactions.create( model="gemini-2.5-flash", input="Hello, how are you?" ) - + # Create an interaction with an agent response = litellm.interactions.create( agent="deep-research-pro-preview-12-2025", input="Research the current state of cancer research" ) - + # Async version response = await litellm.interactions.acreate(...) - + # Get an interaction response = litellm.interactions.get(interaction_id="...") - + # Delete an interaction result = litellm.interactions.delete(interaction_id="...") - + # Cancel an interaction result = litellm.interactions.cancel(interaction_id="...") + # Create a managed agent on the provider side + result = litellm.interactions.agents.create( + name="waverunner", + custom_llm_provider="gemini", + api_key="...", + base_agent="gemini-2.5-flash", + instructions="You are a helpful assistant.", + ) + Methods: - create(): Sync create interaction - acreate(): Async create interaction @@ -39,8 +48,12 @@ Methods: - adelete(): Async delete interaction - cancel(): Sync cancel interaction - acancel(): Async cancel interaction + +Sub-modules: +- agents: Provider-side agent creation (litellm.interactions.agents.create) """ +from litellm.interactions import agents from litellm.interactions.main import ( acancel, acreate, @@ -65,4 +78,6 @@ __all__ = [ # Cancel "cancel", "acancel", + # Sub-modules + "agents", ] diff --git a/litellm/interactions/agents/__init__.py b/litellm/interactions/agents/__init__.py new file mode 100644 index 00000000000..711a54fdcbb --- /dev/null +++ b/litellm/interactions/agents/__init__.py @@ -0,0 +1,39 @@ +""" +litellm.interactions.agents + +Full CRUD SDK for provider-side managed agents (e.g. Gemini v1beta/agents). + + litellm.interactions.agents.create(name=..., ...) + litellm.interactions.agents.list(api_key=...) + litellm.interactions.agents.get(name=..., ...) + litellm.interactions.agents.delete(name=..., ...) + litellm.interactions.agents.list_versions(name=..., ...) + +Async counterparts: acreate, alist, aget, adelete, alist_versions +""" + +from litellm.interactions.agents.main import ( + acreate, + adelete, + aget, + alist, + alist_versions, + create, + delete, + get, + list, + list_versions, +) + +__all__ = [ + "create", + "acreate", + "list", + "alist", + "get", + "aget", + "delete", + "adelete", + "list_versions", + "alist_versions", +] diff --git a/litellm/interactions/agents/http_handler.py b/litellm/interactions/agents/http_handler.py new file mode 100644 index 00000000000..d45ca6f4346 --- /dev/null +++ b/litellm/interactions/agents/http_handler.py @@ -0,0 +1,478 @@ +""" +HTTP handler for the Agents API. + +Extends InteractionsHTTPHandler so that the shared HTTP infrastructure +(_handle_error, _sync_client, _async_client) is reused rather than +duplicated. BaseAgentsAPIConfig stays as pure transform code. +""" + +from typing import Any, Coroutine, Dict, Optional, Union + +import httpx + +from litellm.constants import request_timeout +from litellm.interactions.http_handler import InteractionsHTTPHandler +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.types.agents import ( + AgentCreateResponse, + AgentDeleteResult, + AgentListResponse, + AgentVersionsResponse, +) +from litellm.types.router import GenericLiteLLMParams + + +class AgentsHTTPHandler(InteractionsHTTPHandler): + """HTTP handler for Agents API CRUD requests.""" + + # ------------------------------------------------------------------ # + # CREATE # + # ------------------------------------------------------------------ # + + def create_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[HTTPHandler] = None, + _is_async: bool = False, + ) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]: + if _is_async: + return self.async_create_agent( + agents_api_config=agents_api_config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + sync_httpx_client = self._sync_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url = agents_api_config.get_complete_url( + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + data = agents_api_config.transform_create_request( + name=name, litellm_params=dict(litellm_params) + ) + if extra_body: + data.update(extra_body) + + logging_obj.pre_call( + input=name, + api_key="", + additional_args={ + "complete_input_dict": data, + "api_base": url, + "headers": headers, + }, + ) + try: + response = sync_httpx_client.post( + url=url, headers=headers, json=data, timeout=timeout or request_timeout + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call( + original_response=response.text, + additional_args={"complete_input_dict": data}, + ) + return agents_api_config.transform_create_response( + raw_response=response, name=name + ) + + async def async_create_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[AsyncHTTPHandler] = None, + ) -> AgentCreateResponse: + async_httpx_client = self._async_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url = agents_api_config.get_complete_url( + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + data = agents_api_config.transform_create_request( + name=name, litellm_params=dict(litellm_params) + ) + if extra_body: + data.update(extra_body) + + logging_obj.pre_call( + input=name, + api_key="", + additional_args={ + "complete_input_dict": data, + "api_base": url, + "headers": headers, + }, + ) + try: + response = await async_httpx_client.post( + url=url, headers=headers, json=data, timeout=timeout or request_timeout + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call( + original_response=response.text, + additional_args={"complete_input_dict": data}, + ) + return agents_api_config.transform_create_response( + raw_response=response, name=name + ) + + # ------------------------------------------------------------------ # + # LIST # + # ------------------------------------------------------------------ # + + def list_agents( + self, + agents_api_config: BaseAgentsAPIConfig, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[HTTPHandler] = None, + _is_async: bool = False, + ) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]: + if _is_async: + return self.async_list_agents( + agents_api_config=agents_api_config, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + ) + + sync_httpx_client = self._sync_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_list_request( + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input="list_agents", + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = sync_httpx_client.get(url=url, headers=headers, params=params) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_list_response(raw_response=response) + + async def async_list_agents( + self, + agents_api_config: BaseAgentsAPIConfig, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[AsyncHTTPHandler] = None, + ) -> AgentListResponse: + async_httpx_client = self._async_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_list_request( + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input="list_agents", + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = await async_httpx_client.get( + url=url, headers=headers, params=params + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_list_response(raw_response=response) + + # ------------------------------------------------------------------ # + # GET # + # ------------------------------------------------------------------ # + + def get_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[HTTPHandler] = None, + _is_async: bool = False, + ) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]: + if _is_async: + return self.async_get_agent( + agents_api_config=agents_api_config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + ) + + sync_httpx_client = self._sync_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_get_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = sync_httpx_client.get(url=url, headers=headers, params=params) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_get_response( + raw_response=response, name=name + ) + + async def async_get_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[AsyncHTTPHandler] = None, + ) -> AgentCreateResponse: + async_httpx_client = self._async_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_get_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = await async_httpx_client.get( + url=url, headers=headers, params=params + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_get_response( + raw_response=response, name=name + ) + + # ------------------------------------------------------------------ # + # DELETE # + # ------------------------------------------------------------------ # + + def delete_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[HTTPHandler] = None, + _is_async: bool = False, + ) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]: + if _is_async: + return self.async_delete_agent( + agents_api_config=agents_api_config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + ) + + sync_httpx_client = self._sync_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url = agents_api_config.transform_delete_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = sync_httpx_client.delete( + url=url, headers=headers, timeout=timeout or request_timeout + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_delete_response( + raw_response=response, name=name + ) + + async def async_delete_agent( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[AsyncHTTPHandler] = None, + ) -> AgentDeleteResult: + async_httpx_client = self._async_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url = agents_api_config.transform_delete_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = await async_httpx_client.delete( + url=url, headers=headers, timeout=timeout or request_timeout + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_delete_response( + raw_response=response, name=name + ) + + # ------------------------------------------------------------------ # + # LIST VERSIONS # + # ------------------------------------------------------------------ # + + def list_agent_versions( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[HTTPHandler] = None, + _is_async: bool = False, + ) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]: + if _is_async: + return self.async_list_agent_versions( + agents_api_config=agents_api_config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + ) + + sync_httpx_client = self._sync_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_list_versions_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = sync_httpx_client.get(url=url, headers=headers, params=params) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_list_versions_response( + raw_response=response, name=name + ) + + async def async_list_agent_versions( + self, + agents_api_config: BaseAgentsAPIConfig, + name: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLoggingObj, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + client: Optional[AsyncHTTPHandler] = None, + ) -> AgentVersionsResponse: + async_httpx_client = self._async_client(litellm_params, client) + headers = agents_api_config.validate_environment( + headers=extra_headers or {}, litellm_params=dict(litellm_params) + ) + url, params = agents_api_config.transform_list_versions_request( + name=name, + api_base=litellm_params.get("api_base"), + litellm_params=dict(litellm_params), + ) + logging_obj.pre_call( + input=name, + api_key="", + additional_args={"api_base": url, "headers": headers}, + ) + try: + response = await async_httpx_client.get( + url=url, headers=headers, params=params + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=agents_api_config) + + logging_obj.post_call(original_response=response.text, additional_args={}) + return agents_api_config.transform_list_versions_response( + raw_response=response, name=name + ) + + +agents_http_handler = AgentsHTTPHandler() diff --git a/litellm/interactions/agents/main.py b/litellm/interactions/agents/main.py new file mode 100644 index 00000000000..f56c6f3ed5e --- /dev/null +++ b/litellm/interactions/agents/main.py @@ -0,0 +1,522 @@ +""" +LiteLLM Agents API - Main Module + +Usage: + import litellm + + # Create + response = litellm.interactions.agents.create( + name="waverunner", + custom_llm_provider="gemini", + api_key="...", + base_agent="gemini-2.5-flash", + instructions="You are a helpful assistant.", + ) + + # List + response = litellm.interactions.agents.list(api_key="...", custom_llm_provider="gemini") + + # Get + response = litellm.interactions.agents.get(name="waverunner", api_key="...") + + # Delete + result = litellm.interactions.agents.delete(name="waverunner", api_key="...") + + # List versions + result = litellm.interactions.agents.list_versions(name="waverunner", api_key="...") + + # Async versions: acreate, alist, aget, adelete, alist_versions +""" + +import asyncio +import contextvars +from functools import partial +from typing import Any, Coroutine, Dict, Optional, Union + +import httpx + +import litellm +from litellm.interactions.agents.http_handler import agents_http_handler +from litellm.interactions.agents.utils import get_provider_agents_api_config +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.types.agents import ( + AgentCreateResponse, + AgentDeleteResult, + AgentListResponse, + AgentVersionsResponse, +) +from litellm.types.interactions import InteractionEnvironment +from litellm.types.router import GenericLiteLLMParams +from litellm.utils import client + +# ------------------------------------------------------------------ # +# Shared helpers # +# ------------------------------------------------------------------ # + + +def _get_agents_api_config(custom_llm_provider: str): + config = get_provider_agents_api_config(custom_llm_provider) + if config is None: + raise litellm.BadRequestError( + message=( + f"Provider '{custom_llm_provider}' does not have a native " + "agents API. Use the proxy POST /v1/agents endpoint to store " + "agents locally." + ), + model="", + llm_provider=custom_llm_provider, + ) + return config + + +def _make_logging_obj( + kwargs: Dict[str, Any], + model: str, + custom_llm_provider: str, + call_type: str, + optional_params: Dict[str, Any], +) -> LiteLLMLoggingObj: + litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore + litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) + litellm_logging_obj.update_from_kwargs( + kwargs=kwargs, + model=model, + optional_params=optional_params, + litellm_params={"litellm_call_id": litellm_call_id}, + custom_llm_provider=custom_llm_provider, + ) + return litellm_logging_obj + + +# ================================================================== # +# CREATE # +# ================================================================== # + + +@client +async def acreate( + name: str, + base_agent: Optional[str] = None, + instructions: Optional[str] = None, + base_environment: Optional[InteractionEnvironment] = None, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> AgentCreateResponse: + """Async: Create a managed agent on the provider side.""" + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["acreate_agent"] = True + func = partial( + create, + name=name, + base_agent=base_agent, + instructions=instructions, + base_environment=base_environment, + custom_llm_provider=custom_llm_provider or "gemini", + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + **kwargs, + ) + ctx = contextvars.copy_context() + init_response = await loop.run_in_executor(None, partial(ctx.run, func)) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider or "gemini", + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def create( + name: str, + base_agent: Optional[str] = None, + instructions: Optional[str] = None, + base_environment: Optional[InteractionEnvironment] = None, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]: + """ + Sync: Create a managed agent on the provider side. + + Args: + name: Name for the agent (required). + base_agent: Base agent to derive from (e.g. "waverunner"). + instructions: System instructions for the agent. + base_environment: Environment to fork from — an env_id string or a + dict like ``{"type": "remote", "sources": [...]}``. + custom_llm_provider: Provider to use, e.g. "gemini". + extra_headers: Additional HTTP headers. + extra_body: Additional request body fields. + timeout: Request timeout. + **kwargs: Forwarded to GenericLiteLLMParams (api_key, api_base, etc.). + """ + local_vars = locals() + custom_llm_provider = ( + custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" + ) + try: + _is_async = kwargs.pop("acreate_agent", False) is True + if base_agent is not None: + kwargs["base_agent"] = base_agent + if instructions is not None: + kwargs["instructions"] = instructions + if base_environment is not None: + kwargs["base_environment"] = base_environment + kwargs.setdefault("custom_llm_provider", custom_llm_provider) + litellm_params = GenericLiteLLMParams(**kwargs) + logging_obj = _make_logging_obj( + kwargs, name, custom_llm_provider, "create_agent", {} + ) + config = _get_agents_api_config(custom_llm_provider) + return agents_http_handler.create_agent( + agents_api_config=config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + _is_async=_is_async, + ) + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +# ================================================================== # +# LIST # +# ================================================================== # + + +@client +async def alist( + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> AgentListResponse: + """Async: List all agents on the provider side.""" + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["alist_agents"] = True + func = partial( + list, + custom_llm_provider=custom_llm_provider or "gemini", + extra_headers=extra_headers, + timeout=timeout, + **kwargs, + ) + ctx = contextvars.copy_context() + init_response = await loop.run_in_executor(None, partial(ctx.run, func)) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: + raise litellm.exception_type( + model="", + custom_llm_provider=custom_llm_provider or "gemini", + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def list( + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]: + """Sync: List all agents on the provider side.""" + local_vars = locals() + custom_llm_provider = ( + custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" + ) + try: + _is_async = kwargs.pop("alist_agents", False) is True + kwargs.setdefault("custom_llm_provider", custom_llm_provider) + litellm_params = GenericLiteLLMParams(**kwargs) + logging_obj = _make_logging_obj( + kwargs, "", custom_llm_provider, "list_agents", {} + ) + config = _get_agents_api_config(custom_llm_provider) + return agents_http_handler.list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + _is_async=_is_async, + ) + except Exception as e: + raise litellm.exception_type( + model="", + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +# ================================================================== # +# GET # +# ================================================================== # + + +@client +async def aget( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> AgentCreateResponse: + """Async: Get a specific agent by name.""" + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["aget_agent"] = True + func = partial( + get, + name=name, + custom_llm_provider=custom_llm_provider or "gemini", + extra_headers=extra_headers, + timeout=timeout, + **kwargs, + ) + ctx = contextvars.copy_context() + init_response = await loop.run_in_executor(None, partial(ctx.run, func)) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider or "gemini", + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def get( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]: + """Sync: Get a specific agent by name.""" + local_vars = locals() + custom_llm_provider = ( + custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" + ) + try: + _is_async = kwargs.pop("aget_agent", False) is True + kwargs.setdefault("custom_llm_provider", custom_llm_provider) + litellm_params = GenericLiteLLMParams(**kwargs) + logging_obj = _make_logging_obj( + kwargs, name, custom_llm_provider, "get_agent", {"name": name} + ) + config = _get_agents_api_config(custom_llm_provider) + return agents_http_handler.get_agent( + agents_api_config=config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + _is_async=_is_async, + ) + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +# ================================================================== # +# DELETE # +# ================================================================== # + + +@client +async def adelete( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> AgentDeleteResult: + """Async: Delete a specific agent by name.""" + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["adelete_agent"] = True + func = partial( + delete, + name=name, + custom_llm_provider=custom_llm_provider or "gemini", + extra_headers=extra_headers, + timeout=timeout, + **kwargs, + ) + ctx = contextvars.copy_context() + init_response = await loop.run_in_executor(None, partial(ctx.run, func)) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider or "gemini", + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def delete( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]: + """Sync: Delete a specific agent by name.""" + local_vars = locals() + custom_llm_provider = ( + custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" + ) + try: + _is_async = kwargs.pop("adelete_agent", False) is True + kwargs.setdefault("custom_llm_provider", custom_llm_provider) + litellm_params = GenericLiteLLMParams(**kwargs) + logging_obj = _make_logging_obj( + kwargs, name, custom_llm_provider, "delete_agent", {"name": name} + ) + config = _get_agents_api_config(custom_llm_provider) + return agents_http_handler.delete_agent( + agents_api_config=config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + _is_async=_is_async, + ) + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +# ================================================================== # +# LIST VERSIONS # +# ================================================================== # + + +@client +async def alist_versions( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> AgentVersionsResponse: + """Async: List versions of a specific agent.""" + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["alist_agent_versions"] = True + func = partial( + list_versions, + name=name, + custom_llm_provider=custom_llm_provider or "gemini", + extra_headers=extra_headers, + timeout=timeout, + **kwargs, + ) + ctx = contextvars.copy_context() + init_response = await loop.run_in_executor(None, partial(ctx.run, func)) + if asyncio.iscoroutine(init_response): + return await init_response + return init_response + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider or "gemini", + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def list_versions( + name: str, + custom_llm_provider: Optional[str] = None, + extra_headers: Optional[Dict[str, Any]] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + **kwargs, +) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]: + """Sync: List versions of a specific agent.""" + local_vars = locals() + custom_llm_provider = ( + custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" + ) + try: + _is_async = kwargs.pop("alist_agent_versions", False) is True + kwargs.setdefault("custom_llm_provider", custom_llm_provider) + litellm_params = GenericLiteLLMParams(**kwargs) + logging_obj = _make_logging_obj( + kwargs, name, custom_llm_provider, "list_agent_versions", {"name": name} + ) + config = _get_agents_api_config(custom_llm_provider) + return agents_http_handler.list_agent_versions( + agents_api_config=config, + name=name, + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers=extra_headers, + timeout=timeout, + _is_async=_is_async, + ) + except Exception as e: + raise litellm.exception_type( + model=name, + custom_llm_provider=custom_llm_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) diff --git a/litellm/interactions/agents/utils.py b/litellm/interactions/agents/utils.py new file mode 100644 index 00000000000..d16a9597f53 --- /dev/null +++ b/litellm/interactions/agents/utils.py @@ -0,0 +1,23 @@ +""" +Utility functions for the Agents API SDK. +""" + +from typing import Optional + +from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig + + +def get_provider_agents_api_config( + custom_llm_provider: Optional[str], +) -> Optional[BaseAgentsAPIConfig]: + """ + Return a provider-specific BaseAgentsAPIConfig if the provider has a + native agent-creation API, or None otherwise. + """ + from litellm.types.utils import LlmProviders + + if custom_llm_provider == LlmProviders.GEMINI.value: + from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig + + return GeminiAgentsConfig() + return None diff --git a/litellm/interactions/http_handler.py b/litellm/interactions/http_handler.py index 7fead07043f..695da2be89a 100644 --- a/litellm/interactions/http_handler.py +++ b/litellm/interactions/http_handler.py @@ -41,27 +41,55 @@ from litellm.types.interactions import ( from litellm.types.router import GenericLiteLLMParams -class InteractionsHTTPHandler: +class _BaseHTTPHandler: + """ + Shared HTTP infrastructure for LiteLLM handler classes. + + Provides common client resolution and error-mapping helpers so that + handler subclasses (InteractionsHTTPHandler, AgentsHTTPHandler, …) do + not duplicate this boilerplate. + """ + + def _handle_error(self, e: Exception, provider_config: Any) -> Exception: + if isinstance(e, httpx.HTTPStatusError): + return provider_config.get_error_class( + error_message=e.response.text, + status_code=e.response.status_code, + headers=dict(e.response.headers), + ) + return e + + def _sync_client( + self, + litellm_params: GenericLiteLLMParams, + client: Optional[HTTPHandler], + ) -> HTTPHandler: + return client or _get_httpx_client( + params={"ssl_verify": litellm_params.get("ssl_verify", None)} + ) + + def _async_client( + self, + litellm_params: GenericLiteLLMParams, + client: Optional[AsyncHTTPHandler], + ) -> AsyncHTTPHandler: + # GenericLiteLLMParams.get uses getattr; an unset field is None, not the default. + custom_llm_provider = litellm_params.get("custom_llm_provider") or "gemini" + return client or get_async_httpx_client( + llm_provider=litellm.LlmProviders(custom_llm_provider), + params={"ssl_verify": litellm_params.get("ssl_verify", None)}, + ) + + +class InteractionsHTTPHandler(_BaseHTTPHandler): """ HTTP handler for Interactions API requests. """ - def _handle_error( - self, - e: Exception, - provider_config: BaseInteractionsAPIConfig, - ) -> Exception: - """Handle errors from HTTP requests.""" - if isinstance(e, httpx.HTTPStatusError): - error_message = e.response.text - status_code = e.response.status_code - headers = dict(e.response.headers) - return provider_config.get_error_class( - error_message=error_message, - status_code=status_code, - headers=headers, - ) - return e + # _handle_error is inherited from _BaseHTTPHandler (accepts Any provider_config). + # AgentsHTTPHandler also extends this class and passes BaseAgentsAPIConfig, which + # is structurally compatible but a different type — keeping the override here with + # BaseInteractionsAPIConfig would cause type errors in the subclass. # ========================================================= # CREATE INTERACTION diff --git a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py index 72a3afbc3c5..4a3eb63084e 100644 --- a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py +++ b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py @@ -2,7 +2,17 @@ Streaming iterator for transforming Responses API stream to Interactions API stream. """ -from typing import Any, AsyncIterator, Dict, Iterator, Optional, cast +from collections import deque +from typing import ( + Any, + AsyncIterator, + Deque, + Dict, + Iterator, + List, + Optional, + cast, +) from litellm.responses.streaming_iterator import ( BaseResponsesAPIStreamingIterator, @@ -29,7 +39,13 @@ class LiteLLMResponsesInteractionsStreamingIterator: This class handles both sync and async iteration, transforming Responses API streaming events (output.text.delta, response.completed, etc.) to Interactions - API streaming events (content.delta, interaction.complete, etc.). + API streaming events. + + Schema selection: + - New schema (default, use_legacy_interactions_schema=False): + interaction.created -> step.start -> step.delta ... -> step.stop -> interaction.completed + - Legacy schema (use_legacy_interactions_schema=True, remove after June 8 2026): + interaction.start -> content.start -> content.delta ... -> content.stop -> interaction.complete """ def __init__( @@ -41,6 +57,8 @@ class LiteLLMResponsesInteractionsStreamingIterator: custom_llm_provider: Optional[str] = None, litellm_metadata: Optional[Dict[str, Any]] = None, ): + import litellm + self.model = model self.responses_stream_iterator = litellm_custom_stream_wrapper self.request_input = request_input @@ -51,66 +69,156 @@ class LiteLLMResponsesInteractionsStreamingIterator: self.collected_text = "" self.sent_interaction_start = False self.sent_content_start = False + # Capture the schema flag once at construction time so all events + # emitted by this stream use a consistent schema, even if the global + # flag is mutated mid-stream (e.g. by a config reload). + self._use_legacy: bool = litellm.use_legacy_interactions_schema + # Buffer of events that have been derived from upstream chunks but not + # yet returned to the caller. A single Responses API chunk may expand + # into multiple Interactions API events (e.g. the first text delta + # produces interaction.created + step.start + step.delta), and the + # terminal sequence on stream end may also span multiple events + # (step.stop + interaction.completed). + self._pending_events: Deque[InteractionsAPIStreamingResponse] = deque() + # Tracks whether we've already emitted a terminal completion event so + # the StopIteration fallback path doesn't double-emit. + self._sent_completion_event = False + # ID resolved from the first upstream chunk (item_id on a text delta or + # response.id on response.created). Persisted so the EOF terminal + # events stay correlated with the start events delivered earlier. + self._interaction_id: Optional[str] = None - def _transform_responses_chunk_to_interactions_chunk( - self, - responses_chunk: ResponsesAPIStreamingResponse, - ) -> Optional[InteractionsAPIStreamingResponse]: + # ------------------------------------------------------------------ + # Event builders + # ------------------------------------------------------------------ + + def _build_interaction_start_event( + self, interaction_id: str + ) -> InteractionsAPIStreamingResponse: + event_type = "interaction.start" if self._use_legacy else "interaction.created" + return InteractionsAPIStreamingResponse( + event_type=event_type, + id=interaction_id, + object="interaction", + status="in_progress", + model=self.model, + ) + + def _build_content_start_event( + self, interaction_id: str + ) -> InteractionsAPIStreamingResponse: + if self._use_legacy: + return InteractionsAPIStreamingResponse( + event_type="content.start", + id=interaction_id, + object="content", + delta={"type": "text", "text": ""}, + ) + return InteractionsAPIStreamingResponse( + event_type="step.start", + index=0, + step={"type": "model_output", "content": []}, + ) + + def _build_text_delta_event( + self, interaction_id: str, delta_text: str + ) -> InteractionsAPIStreamingResponse: + if self._use_legacy: + return InteractionsAPIStreamingResponse( + event_type="content.delta", + id=interaction_id, + object="content", + delta={"type": "text", "text": delta_text}, + ) + return InteractionsAPIStreamingResponse( + event_type="step.delta", + index=0, + delta={"type": "text", "text": delta_text}, + ) + + def _build_content_stop_event( + self, interaction_id: Optional[str] + ) -> InteractionsAPIStreamingResponse: + if self._use_legacy: + return InteractionsAPIStreamingResponse( + event_type="content.stop", + id=interaction_id, + object="content", + delta={"type": "text", "text": self.collected_text}, + ) + return InteractionsAPIStreamingResponse( + event_type="step.stop", + index=0, + ) + + def _build_completion_event( + self, response_id: str + ) -> InteractionsAPIStreamingResponse: + if self._use_legacy: + return InteractionsAPIStreamingResponse( + event_type="interaction.complete", + id=response_id, + object="interaction", + status="completed", + model=self.model, + outputs=[{"type": "text", "text": self.collected_text}], + ) + return InteractionsAPIStreamingResponse( + event_type="interaction.completed", + id=response_id, + object="interaction", + status="completed", + model=self.model, + steps=[ + { + "type": "model_output", + "content": [{"type": "text", "text": self.collected_text}], + } + ], + ) + + # ------------------------------------------------------------------ + # Per-chunk transform (returns a list of events to enqueue) + # ------------------------------------------------------------------ + + def _events_for_chunk( + self, responses_chunk: ResponsesAPIStreamingResponse + ) -> List[InteractionsAPIStreamingResponse]: """ - Transform a Responses API streaming chunk to an Interactions API streaming chunk. + Translate a single upstream Responses API chunk into the list of + Interactions API events it should produce. - Responses API events: - - output.text.delta -> content.delta - - response.completed -> interaction.complete - - Interactions API events: - - interaction.start - - content.start - - content.delta - - content.stop - - interaction.complete + Returning a list (rather than a single event) lets a chunk emit any + synthetic start events that haven't been sent yet *together with* the + actual delta event, so we never silently drop the chunk's payload. """ if not responses_chunk: - return None + return [] - # Handle OutputTextDeltaEvent -> content.delta + # Text delta: emit any missing start events, then the delta itself. if isinstance(responses_chunk, OutputTextDeltaEvent): delta_text = ( responses_chunk.delta if isinstance(responses_chunk.delta, str) else "" ) self.collected_text += delta_text + interaction_id = ( + getattr(responses_chunk, "item_id", None) or f"interaction_{id(self)}" + ) + if self._interaction_id is None: + self._interaction_id = interaction_id - # Send interaction.start if not sent + events: List[InteractionsAPIStreamingResponse] = [] if not self.sent_interaction_start: self.sent_interaction_start = True - return InteractionsAPIStreamingResponse( - event_type="interaction.start", - id=getattr(responses_chunk, "item_id", None) - or f"interaction_{id(self)}", - object="interaction", - status="in_progress", - model=self.model, - ) - - # Send content.start if not sent + events.append(self._build_interaction_start_event(interaction_id)) if not self.sent_content_start: self.sent_content_start = True - return InteractionsAPIStreamingResponse( - event_type="content.start", - id=getattr(responses_chunk, "item_id", None), - object="content", - delta={"type": "text", "text": ""}, - ) + events.append(self._build_content_start_event(interaction_id)) + events.append(self._build_text_delta_event(interaction_id, delta_text)) + return events - # Send content.delta - return InteractionsAPIStreamingResponse( - event_type="content.delta", - id=getattr(responses_chunk, "item_id", None), - object="content", - delta={"text": delta_text}, - ) - - # Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start + # Response created / in-progress: synthesize interaction start if we + # haven't already sent one. if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)): if not self.sent_interaction_start: self.sent_interaction_start = True @@ -118,169 +226,136 @@ class LiteLLMResponsesInteractionsStreamingIterator: getattr(responses_chunk.response, "id", None) if hasattr(responses_chunk, "response") else None - ) - return InteractionsAPIStreamingResponse( - event_type="interaction.start", - id=response_id or f"interaction_{id(self)}", - object="interaction", - status="in_progress", - model=self.model, - ) + ) or f"interaction_{id(self)}" + if self._interaction_id is None: + self._interaction_id = response_id + return [self._build_interaction_start_event(response_id)] + return [] - # Handle ResponseCompletedEvent -> interaction.complete + # Response completed: emit step.stop (if content was started) followed + # by the terminal completion event. Prefer the interaction id already + # established by earlier events so consumers can correlate the start + # and completion events by id (response.id may differ from the item_id + # used to derive the initial id when the stream starts directly with a + # text delta). if isinstance(responses_chunk, ResponseCompletedEvent): self.finished = True response = responses_chunk.response - - # Send content.stop first if content was started - if self.sent_content_start: - # Note: We'll send this in the iterator, not here - pass - - # Send interaction.complete - return InteractionsAPIStreamingResponse( - event_type="interaction.complete", - id=getattr(response, "id", None) or f"interaction_{id(self)}", - object="interaction", - status="completed", - model=self.model, - outputs=[ - { - "type": "text", - "text": self.collected_text, - } - ], + response_id = ( + self._interaction_id + or getattr(response, "id", None) + or f"interaction_{id(self)}" ) - # For other event types, return None (skip) - return None + terminal: List[InteractionsAPIStreamingResponse] = [] + if self.sent_content_start: + terminal.append(self._build_content_stop_event(response_id)) + terminal.append(self._build_completion_event(response_id)) + self._sent_completion_event = True + return terminal + + return [] + + def _build_terminal_events_on_eof( + self, + ) -> List[InteractionsAPIStreamingResponse]: + """ + Build the events to flush when the upstream stream ends without a + ResponseCompletedEvent. Ensures consumers always observe a terminal + interaction.completed/interaction.complete carrying the full text. + """ + if self._sent_completion_event: + return [] + + fallback_id = self._interaction_id or f"interaction_{id(self)}" + terminal: List[InteractionsAPIStreamingResponse] = [] + if self.sent_content_start: + terminal.append(self._build_content_stop_event(fallback_id)) + if self.sent_interaction_start or self.collected_text: + terminal.append(self._build_completion_event(fallback_id)) + self._sent_completion_event = True + return terminal + + # ------------------------------------------------------------------ + # Iteration + # ------------------------------------------------------------------ def __iter__(self) -> Iterator[InteractionsAPIStreamingResponse]: - """Sync iterator implementation.""" return self def __next__(self) -> InteractionsAPIStreamingResponse: - """Get next chunk in sync mode.""" + if self._pending_events: + return self._pending_events.popleft() + if self.finished: raise StopIteration - # Check if we have a pending interaction.complete to send - if hasattr(self, "_pending_interaction_complete"): - pending: InteractionsAPIStreamingResponse = getattr( - self, "_pending_interaction_complete" - ) - delattr(self, "_pending_interaction_complete") - return pending - - # Use a loop instead of recursion to avoid stack overflow sync_iterator = cast( SyncResponsesAPIStreamingIterator, self.responses_stream_iterator ) while True: try: - # Get next chunk from responses API stream chunk = next(sync_iterator) - - # Transform chunk (chunk is already a ResponsesAPIStreamingResponse) - transformed = self._transform_responses_chunk_to_interactions_chunk( - chunk - ) - - if transformed: - # If we finished and content was started, send content.stop before interaction.complete - if ( - self.finished - and self.sent_content_start - and transformed.event_type == "interaction.complete" - ): - # Send content.stop first - content_stop = InteractionsAPIStreamingResponse( - event_type="content.stop", - id=transformed.id, - object="content", - delta={"type": "text", "text": self.collected_text}, - ) - # Store the interaction.complete to send next - self._pending_interaction_complete = transformed - return content_stop - return transformed - - # If no transformation, continue to next chunk (loop continues) - except StopIteration: self.finished = True + self._pending_events.extend(self._build_terminal_events_on_eof()) + if self._pending_events: + return self._pending_events.popleft() + raise - # Send final events if needed - if self.sent_content_start: - return InteractionsAPIStreamingResponse( - event_type="content.stop", - object="content", - delta={"type": "text", "text": self.collected_text}, - ) - - raise StopIteration + events = self._events_for_chunk(chunk) + if events: + self._pending_events.extend(events) + return self._pending_events.popleft() def __aiter__(self) -> AsyncIterator[InteractionsAPIStreamingResponse]: - """Async iterator implementation.""" return self async def __anext__(self) -> InteractionsAPIStreamingResponse: - """Get next chunk in async mode.""" + if self._pending_events: + return self._pending_events.popleft() + if self.finished: raise StopAsyncIteration - # Check if we have a pending interaction.complete to send - if hasattr(self, "_pending_interaction_complete"): - pending: InteractionsAPIStreamingResponse = getattr( - self, "_pending_interaction_complete" - ) - delattr(self, "_pending_interaction_complete") - return pending - - # Use a loop instead of recursion to avoid stack overflow async_iterator = cast( ResponsesAPIStreamingIterator, self.responses_stream_iterator ) while True: try: - # Get next chunk from responses API stream chunk = await async_iterator.__anext__() - - # Transform chunk (chunk is already a ResponsesAPIStreamingResponse) - transformed = self._transform_responses_chunk_to_interactions_chunk( - chunk - ) - - if transformed: - # If we finished and content was started, send content.stop before interaction.complete - if ( - self.finished - and self.sent_content_start - and transformed.event_type == "interaction.complete" - ): - # Send content.stop first - content_stop = InteractionsAPIStreamingResponse( - event_type="content.stop", - id=transformed.id, - object="content", - delta={"type": "text", "text": self.collected_text}, - ) - # Store the interaction.complete to send next - self._pending_interaction_complete = transformed - return content_stop - return transformed - - # If no transformation, continue to next chunk (loop continues) - except StopAsyncIteration: self.finished = True + self._pending_events.extend(self._build_terminal_events_on_eof()) + if self._pending_events: + return self._pending_events.popleft() + raise - # Send final events if needed - if self.sent_content_start: - return InteractionsAPIStreamingResponse( - event_type="content.stop", - object="content", - delta={"type": "text", "text": self.collected_text}, - ) + events = self._events_for_chunk(chunk) + if events: + self._pending_events.extend(events) + return self._pending_events.popleft() - raise StopAsyncIteration + # ------------------------------------------------------------------ + # Backwards-compatible single-chunk transform (used by tests and any + # external callers that drove the iterator chunk-by-chunk pre-fix). + # ------------------------------------------------------------------ + + def _transform_responses_chunk_to_interactions_chunk( + self, + responses_chunk: ResponsesAPIStreamingResponse, + ) -> Optional[InteractionsAPIStreamingResponse]: + """ + Compatibility shim: returns the *first* event produced for this chunk + and queues any remaining events on ``self._pending_events`` so they + are surfaced on subsequent calls/iterations. + + Prefer ``_events_for_chunk`` in new code. + """ + events = self._events_for_chunk(responses_chunk) + if not events: + return None + first = events[0] + if len(events) > 1: + self._pending_events.extend(events[1:]) + return first diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py index 100300af7b5..173d4ca8764 100644 --- a/litellm/interactions/litellm_responses_transformation/transformation.py +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -226,29 +226,37 @@ class LiteLLMResponsesInteractionsConfig: - Map status - Extract usage """ - # Extract text from outputs - outputs = [] + # Extract text from outputs and build both `outputs` (legacy) and `steps` (new schema). + outputs: List[Dict[str, Any]] = [] + steps: List[Dict[str, Any]] = [] if hasattr(responses_response, "output") and responses_response.output: for output_item in responses_response.output: # Use getattr with None default to safely access content content = getattr(output_item, "content", None) if content is not None: content_items = content if isinstance(content, list) else [content] + model_output_contents: List[Dict[str, Any]] = [] for content_item in content_items: # Check if content_item has text attribute text = getattr(content_item, "text", None) if text is not None: - outputs.append( - { - "type": "text", - "text": text, - } - ) + # Use independent dict instances so mutations to one + # of `outputs` / `steps` don't leak into the other. + outputs.append({"type": "text", "text": text}) + model_output_contents.append({"type": "text", "text": text}) elif ( isinstance(content_item, dict) and content_item.get("type") == "text" ): - outputs.append(content_item) + outputs.append({**content_item}) + model_output_contents.append({**content_item}) + if model_output_contents: + steps.append( + { + "type": "model_output", + "content": model_output_contents, + } + ) # Convert created_at to ISO string created_at = getattr(responses_response, "created_at", None) @@ -270,12 +278,14 @@ class LiteLLMResponsesInteractionsConfig: else: interactions_status = status - # Build interactions response + # Build interactions response — populate both `outputs` (legacy schema) and + # `steps` (new schema) so callers work regardless of which schema they expect. interactions_response_dict: Dict[str, Any] = { "id": getattr(responses_response, "id", ""), "object": "interaction", "status": interactions_status, "outputs": outputs, + "steps": steps, "model": model or getattr(responses_response, "model", ""), "created": created, } diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py index ab429ef6db5..d99cc3d11c7 100644 --- a/litellm/interactions/main.py +++ b/litellm/interactions/main.py @@ -8,25 +8,25 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json): Usage: import litellm - + # Create an interaction with a model response = litellm.interactions.create( model="gemini-2.5-flash", input="Hello, how are you?" ) - + # Create an interaction with an agent response = litellm.interactions.create( agent="deep-research-pro-preview-12-2025", input="Research the current state of cancer research" ) - + # Async version response = await litellm.interactions.acreate(...) - + # Get an interaction response = litellm.interactions.get(interaction_id="...") - + # Delete an interaction result = litellm.interactions.delete(interaction_id="...") """ @@ -48,6 +48,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.types.interactions import ( CancelInteractionResult, DeleteInteractionResult, + InteractionEnvironment, InteractionInput, InteractionsAPIResponse, InteractionsAPIStreamingResponse, @@ -80,6 +81,8 @@ async def acreate( store: Optional[bool] = None, # Background execution background: Optional[bool] = None, + # Agent execution environment ("remote", env id, or remote config object) + environment: Optional[InteractionEnvironment] = None, # Response format response_modalities: Optional[List[str]] = None, response_format: Optional[Dict[str, Any]] = None, @@ -109,6 +112,10 @@ async def acreate( stream: Whether to stream the response store: Whether to store the response for later retrieval background: Whether to run in background + environment: Agent execution environment — ``"remote"``, an existing env id + string, or a config object such as + ``{"type": "remote", "sources": [...]}`` / + ``{"type": "remote", "network": {...}}`` response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO) response_format: JSON schema for response format response_mime_type: MIME type of the response @@ -144,6 +151,7 @@ async def acreate( stream=stream, store=store, background=background, + environment=environment, response_modalities=response_modalities, response_format=response_format, response_mime_type=response_mime_type, @@ -194,6 +202,8 @@ def create( store: Optional[bool] = None, # Background execution background: Optional[bool] = None, + # Agent execution environment ("remote", env id, or remote config object) + environment: Optional[InteractionEnvironment] = None, # Response format response_modalities: Optional[List[str]] = None, response_format: Optional[Dict[str, Any]] = None, @@ -231,6 +241,10 @@ def create( stream: Whether to stream the response store: Whether to store the response for later retrieval background: Whether to run in background + environment: Agent execution environment — ``"remote"``, an existing env id + string, or a config object such as + ``{"type": "remote", "sources": [...]}`` / + ``{"type": "remote", "network": {...}}`` response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO) response_format: JSON schema for response format response_mime_type: MIME type of the response @@ -252,7 +266,14 @@ def create( litellm_params = GenericLiteLLMParams(**kwargs) - if model: + # Routing logic: + # - agent provided (no model, or model accidentally set to agent name) → gemini + # - model provided → resolve provider via get_llm_provider (normal routing) + if agent and model == agent: + model = None + if agent and not model: + custom_llm_provider = custom_llm_provider or "gemini" + elif model: model, custom_llm_provider, _, _ = litellm.get_llm_provider( model=model, custom_llm_provider=custom_llm_provider, diff --git a/litellm/interactions/streaming_iterator.py b/litellm/interactions/streaming_iterator.py index a5a7f9e06e5..561686a3e1b 100644 --- a/litellm/interactions/streaming_iterator.py +++ b/litellm/interactions/streaming_iterator.py @@ -101,10 +101,14 @@ class BaseInteractionsAPIStreamingIterator: ) ) - # Store the completed response (check for status=completed) - if ( - streaming_response - and getattr(streaming_response, "status", None) == "completed" + # Store the completed response. + # Legacy schema signals completion via status="completed". + # New schema (Api-Revision: 2026-05-20) uses event_type="interaction.completed". + # Remove the legacy check after June 8, 2026. + if streaming_response and ( + getattr(streaming_response, "status", None) == "completed" + or getattr(streaming_response, "event_type", None) + == "interaction.completed" ): self.completed_response = streaming_response self._handle_logging_completed_response() diff --git a/litellm/interactions/utils.py b/litellm/interactions/utils.py index 3a18ddf52fe..84437f4d3d8 100644 --- a/litellm/interactions/utils.py +++ b/litellm/interactions/utils.py @@ -15,6 +15,7 @@ INTERACTIONS_API_OPTIONAL_PARAMS = { "stream", "store", "background", + "environment", "response_modalities", "response_format", "response_mime_type", diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 876f1b167db..2ab037afb0d 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -994,10 +994,8 @@ class Logging(LiteLLMLoggingBaseClass): try: # [Non-blocking Extra Debug Information in metadata] if turn_off_message_logging is True: - _metadata["raw_request"] = ( - "redacted by litellm. \ + _metadata["raw_request"] = "redacted by litellm. \ 'litellm.turn_off_message_logging=True'" - ) else: curl_command = self._get_request_curl_command( api_base=additional_args.get("api_base", ""), @@ -1031,12 +1029,8 @@ class Logging(LiteLLMLoggingBaseClass): error=str(e), ) ) - _metadata["raw_request"] = ( - "Unable to Log \ - raw request: {}".format( - str(e) - ) - ) + _metadata["raw_request"] = "Unable to Log \ + raw request: {}".format(str(e)) if getattr(self, "logger_fn", None) and callable(self.logger_fn): try: self.logger_fn( @@ -1769,9 +1763,12 @@ class Logging(LiteLLMLoggingBaseClass): self.model_call_details["response_cost"] = 0.0 elif "response_cost" in hidden_params: self.model_call_details["response_cost"] = hidden_params["response_cost"] - elif self.model_call_details.get("response_cost") is not None: + elif ( + existing_cost := self.model_call_details.get("response_cost") + ) is not None and existing_cost != 0: # Preserve response_cost if already calculated (e.g., by pass-through - # handlers like Gemini/Vertex which call completion_cost directly) + # handlers like Gemini/Vertex which call completion_cost directly). + # Do not preserve 0 from failure_handler on intermediate router retries. pass else: self.model_call_details["response_cost"] = self._response_cost_calculator( diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 79d527d1eb8..f169f86079a 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1233,6 +1233,7 @@ def infer_protocol_value( def _gemini_tool_call_invoke_helper( function_call_params: ChatCompletionToolCallFunctionChunk, + tool_call_id: Optional[str] = None, ) -> Optional[VertexFunctionCall]: name = function_call_params.get("name", "") or "" arguments = function_call_params.get("arguments", "") @@ -1248,6 +1249,10 @@ def _gemini_tool_call_invoke_helper( name=name, args=arguments_dict, ) + if tool_call_id: + clean_id = tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0] + if clean_id: + function_call["id"] = clean_id return function_call @@ -1339,6 +1344,7 @@ def _get_dummy_thought_signature() -> str: def convert_to_gemini_tool_call_invoke( message: ChatCompletionAssistantMessage, model: Optional[str] = None, + custom_llm_provider: Optional[str] = None, ) -> List[VertexPartType]: """ OpenAI tool invokes: @@ -1384,12 +1390,26 @@ def convert_to_gemini_tool_call_invoke( tool_calls = message.get("tool_calls", None) function_call = message.get("function_call", None) + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + forward_tool_call_id = bool( + model + and VertexGeminiConfig._forward_gemini_function_call_id( + model, custom_llm_provider + ) + ) + if tool_calls is not None: for idx, tool in enumerate(tool_calls): if "function" in tool: gemini_function_call: Optional[VertexFunctionCall] = ( _gemini_tool_call_invoke_helper( - function_call_params=tool["function"] + function_call_params=tool["function"], + tool_call_id=( + tool.get("id") if forward_tool_call_id else None + ), ) ) if gemini_function_call is not None: @@ -1429,10 +1449,6 @@ def convert_to_gemini_tool_call_invoke( thought_signature = provider_fields.get("thought_signature") # If no signature found and model is gemini-3, use dummy signature - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - ) - if ( not thought_signature and model @@ -1462,6 +1478,8 @@ def convert_to_gemini_tool_call_invoke( def convert_to_gemini_tool_call_result( # noqa: PLR0915 message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage], last_message_with_tool_calls: Optional[dict], + model: Optional[str] = None, + custom_llm_provider: Optional[str] = None, ) -> Union[VertexPartType, List[VertexPartType]]: """ OpenAI message with a tool result looks like: @@ -1602,6 +1620,23 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 ): name = tool.get("function", {}).get("name", "") + # Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix). + # Only Google AI Studio Gemini 3+ accepts `id` on function_response parts. + # Vertex AI and older Gemini models reject the field with HTTP 400. + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + gemini_call_id: Optional[str] = None + if model and VertexGeminiConfig._forward_gemini_function_call_id( + model, custom_llm_provider + ): + raw_tool_call_id = message.get("tool_call_id") + if raw_tool_call_id and isinstance(raw_tool_call_id, str): + stripped_id = raw_tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0] + if stripped_id: + gemini_call_id = stripped_id + if not name: raise Exception( "Missing corresponding tool call for tool response message. Received - message={}, last_message_with_tool_calls={}".format( @@ -1632,6 +1667,8 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 name=name, response=response_data, # type: ignore ) + if gemini_call_id: + _function_response["id"] = gemini_call_id # Create part with function_response, and optionally inline_data for images (Computer Use) _part: VertexPartType = {"function_response": _function_response} @@ -5553,9 +5590,7 @@ def default_response_schema_prompt(response_schema: dict) -> str: prompt_str = """Use this JSON schema: ```json {} - ```""".format( - response_schema - ) + ```""".format(response_schema) return prompt_str diff --git a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py index 13341f27a61..0a6a4e82c72 100644 --- a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py +++ b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py @@ -1,9 +1,9 @@ """ This is a cache for LangfuseLoggers. -Langfuse Python SDK initializes a thread for each client. +Langfuse Python SDK initializes a thread for each client. -This ensures we do +This ensures we do 1. Proper cleanup of Langfuse initialized clients. 2. Re-use created langfuse clients. """ diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1ce80207552..0b56eb86d9c 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1506,9 +1506,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): optional_params["metadata"] = {"user_id": value} elif param == "thinking": optional_params["thinking"] = value - elif param == "reasoning_effort" and isinstance(value, str): + elif param == "reasoning_effort": + # Accept both string ("low") and dict ({"effort": "low", + # "summary": "concise"}). The Responses->Chat parser keeps the + # full dict when `summary` is set (see #25359), so a dict here + # is the standard shape Otto/OpenAI-Responses-Bridge callers + # send. Coerce to the effort string before mapping — same + # shape-tolerance the GPT-5 path already implements in + # `_normalize_reasoning_effort_for_chat_completion`. + effort_value = value + if isinstance(effort_value, dict): + effort_value = effort_value.get("effort") + if not isinstance(effort_value, str): + continue mapped_thinking = AnthropicConfig._map_reasoning_effort( - reasoning_effort=value, + reasoning_effort=effort_value, model=model, llm_provider=self.custom_llm_provider or "anthropic", ) @@ -1519,12 +1531,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): optional_params["thinking"] = mapped_thinking if AnthropicConfig._is_adaptive_thinking_model(model): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get( - value + effort_value ) if mapped_effort is None: AnthropicConfig._raise_invalid_reasoning_effort( model=model, - value=value, + value=effort_value, llm_provider=self.custom_llm_provider or "anthropic", ) optional_params["output_config"] = {"effort": mapped_effort} diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py index d0780c82d06..d693d50b8e5 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py @@ -13,7 +13,6 @@ from typing import Any, AsyncIterator, Dict, List, Optional, cast from litellm._logging import verbose_logger - # --------------------------------------------------------------------------- # SSE parsing helpers (module-level to keep the class lean) # --------------------------------------------------------------------------- diff --git a/litellm/llms/azure/chat/o_series_transformation.py b/litellm/llms/azure/chat/o_series_transformation.py index cae7513245c..0a73597a4e4 100644 --- a/litellm/llms/azure/chat/o_series_transformation.py +++ b/litellm/llms/azure/chat/o_series_transformation.py @@ -4,10 +4,10 @@ Support for o1 and o3 model families https://platform.openai.com/docs/guides/reasoning Translations handled by LiteLLM: -- modalities: image => drop param (if user opts in to dropping param) -- role: system ==> translate to role 'user' -- streaming => faked by LiteLLM -- Tools, response_format => drop param (if user opts in to dropping param) +- modalities: image => drop param (if user opts in to dropping param) +- role: system ==> translate to role 'user' +- streaming => faked by LiteLLM +- Tools, response_format => drop param (if user opts in to dropping param) - Logprobs => drop param (if user opts in to dropping param) - Temperature => drop param (if user opts in to dropping param) """ diff --git a/litellm/llms/azure/containers/transformation.py b/litellm/llms/azure/containers/transformation.py index 586b2e379a0..cd897511585 100644 --- a/litellm/llms/azure/containers/transformation.py +++ b/litellm/llms/azure/containers/transformation.py @@ -1,9 +1,16 @@ from typing import Optional +from urllib.parse import parse_qs, urlparse, urlunparse from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.openai.containers.transformation import OpenAIContainerConfig from litellm.types.router import GenericLiteLLMParams +# Endpoint-specific path suffixes that may appear in a deployment's api_base +# (e.g. the responses endpoint URL is stored as api_base for Azure models). +# Strip these before building the containers URL so we always start from the +# resource root (https://resource.cognitiveservices.azure.com). +_AZURE_ENDPOINT_PATHS = ("/openai/responses",) + class AzureContainerConfig(OpenAIContainerConfig): """ @@ -27,6 +34,27 @@ class AzureContainerConfig(OpenAIContainerConfig): litellm_params=GenericLiteLLMParams(api_key=api_key), ) + @staticmethod + def _normalize_api_base(api_base: Optional[str]) -> Optional[str]: + """Strip endpoint-specific path suffixes from api_base to get the resource root.""" + if not api_base: + return api_base + parsed = urlparse(api_base) + path = parsed.path.rstrip("/") + for ep in _AZURE_ENDPOINT_PATHS: + if path.endswith(ep): + return urlunparse( + (parsed.scheme, parsed.netloc, path[: -len(ep)], "", "", "") + ) + return api_base + + @staticmethod + def _extract_api_version(api_base: Optional[str]) -> Optional[str]: + """Return the api-version query param from api_base if present.""" + if not api_base: + return None + return parse_qs(urlparse(api_base).query).get("api-version", [None])[0] + def get_complete_url( self, api_base: Optional[str], @@ -39,10 +67,19 @@ class AzureContainerConfig(OpenAIContainerConfig): {endpoint}/openai/v1/containers when api_version is 'v1', 'latest', or 'preview'; otherwise: {endpoint}/openai/containers + + The deployment's api_base may be the responses endpoint URL + (e.g. .../openai/responses?api-version=2025-04-01-preview). We + prefer the api-version embedded there over the deployment's + api_version field, which may point to an older chat API version. """ + effective_params = dict(litellm_params) + api_version_from_base = self._extract_api_version(api_base) + if api_version_from_base: + effective_params["api_version"] = api_version_from_base return BaseAzureLLM._get_base_azure_url( - api_base=api_base, - litellm_params=litellm_params, + api_base=self._normalize_api_base(api_base), + litellm_params=effective_params, route="/openai/containers", default_api_version="v1", ) diff --git a/litellm/llms/azure_ai/embed/cohere_transformation.py b/litellm/llms/azure_ai/embed/cohere_transformation.py index 64433c21b61..bbbfb60fbde 100644 --- a/litellm/llms/azure_ai/embed/cohere_transformation.py +++ b/litellm/llms/azure_ai/embed/cohere_transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI /v1/embeddings format to Azure AI Cohere's /v1/embed. +Transformation logic from OpenAI /v1/embeddings format to Azure AI Cohere's /v1/embed. Why separate file? Make it easy to see how transformation works diff --git a/litellm/llms/azure_ai/rerank/transformation.py b/litellm/llms/azure_ai/rerank/transformation.py index b5993040ea0..f64133afa8b 100644 --- a/litellm/llms/azure_ai/rerank/transformation.py +++ b/litellm/llms/azure_ai/rerank/transformation.py @@ -1,5 +1,5 @@ """ -Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format. +Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format. """ from typing import Optional diff --git a/litellm/llms/base_llm/agents/__init__.py b/litellm/llms/base_llm/agents/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/base_llm/agents/transformation.py b/litellm/llms/base_llm/agents/transformation.py new file mode 100644 index 00000000000..508e54cb7ab --- /dev/null +++ b/litellm/llms/base_llm/agents/transformation.py @@ -0,0 +1,165 @@ +""" +Base transformation class for provider-side Agents API. + +Providers that have a native agents CRUD API (e.g. Gemini v1beta/agents) +subclass BaseAgentsAPIConfig and implement the abstract methods. + +The HTTP calls are handled by AgentsHTTPHandler — this class is pure +transform logic (same separation as BaseInteractionsAPIConfig / +InteractionsHTTPHandler). +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Tuple, Union + +import httpx + +from litellm.types.agents import ( + AgentCreateResponse, + AgentDeleteResult, + AgentListResponse, + AgentVersionsResponse, +) + + +class BaseAgentsAPIConfig(ABC): + """ + Minimal interface for providers that expose a native agents CRUD API. + """ + + # ------------------------------------------------------------------ # + # CREATE # + # ------------------------------------------------------------------ # + + @abstractmethod + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> str: + """Return the full URL for POST /agents (create).""" + + @abstractmethod + def validate_environment( + self, + headers: Dict[str, str], + litellm_params: Dict[str, Any], + ) -> Dict[str, str]: + """Validate credentials and return auth headers.""" + + @abstractmethod + def transform_create_request( + self, + name: str, + litellm_params: Dict[str, Any], + ) -> Dict[str, Any]: + """Map name + litellm_params to the provider's create-agent body.""" + + @abstractmethod + def transform_create_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentCreateResponse: + """Parse create response. Raise on non-2xx.""" + + # ------------------------------------------------------------------ # + # LIST # + # ------------------------------------------------------------------ # + + @abstractmethod + def transform_list_request( + self, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + """Return (url, query_params) for GET /agents.""" + + @abstractmethod + def transform_list_response( + self, + raw_response: httpx.Response, + ) -> AgentListResponse: + """Parse list-agents response. Raise on non-2xx.""" + + # ------------------------------------------------------------------ # + # GET # + # ------------------------------------------------------------------ # + + @abstractmethod + def transform_get_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + """Return (url, query_params) for GET /agents/{name}.""" + + @abstractmethod + def transform_get_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentCreateResponse: + """Parse get-agent response. Raise on non-2xx.""" + + # ------------------------------------------------------------------ # + # DELETE # + # ------------------------------------------------------------------ # + + @abstractmethod + def transform_delete_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> str: + """Return the URL for DELETE /agents/{name}.""" + + @abstractmethod + def transform_delete_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentDeleteResult: + """Parse delete-agent response. Raise on non-2xx.""" + + # ------------------------------------------------------------------ # + # LIST VERSIONS # + # ------------------------------------------------------------------ # + + @abstractmethod + def transform_list_versions_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + """Return (url, query_params) for GET /agents/{name}/versions.""" + + @abstractmethod + def transform_list_versions_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentVersionsResponse: + """Parse list-versions response. Raise on non-2xx.""" + + # ------------------------------------------------------------------ # + # ERROR HANDLING # + # ------------------------------------------------------------------ # + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: Union[dict, httpx.Headers], + ) -> Exception: + """Map HTTP error status codes to provider-specific exceptions.""" + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + return BaseLLMException( + status_code=status_code, + message=error_message, + headers=headers, + ) diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index b7f4d8e3b2d..263e0c094ce 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -54,6 +54,7 @@ class OCRUsageInfo(LiteLLMPydanticObjectBase): """Usage information from OCR response.""" pages_processed: Optional[int] = None + credits: Optional[float] = None doc_size_bytes: Optional[int] = None model_config = {"extra": "allow"} diff --git a/litellm/llms/bedrock/chat/invoke_agent/transformation.py b/litellm/llms/bedrock/chat/invoke_agent/transformation.py index e4072c24557..c88fa32b6a0 100644 --- a/litellm/llms/bedrock/chat/invoke_agent/transformation.py +++ b/litellm/llms/bedrock/chat/invoke_agent/transformation.py @@ -299,9 +299,9 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM): ) def _get_response_stream_shape(self): - from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE + from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape - return BEDROCK_RESPONSE_STREAM_SHAPE + return get_bedrock_response_stream_shape() def _extract_response_content(self, events: InvokeAgentEventList) -> str: """Extract the final response content from parsed events.""" diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 92ca75db95b..7a9916f1f31 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -68,9 +68,9 @@ from litellm.utils import CustomStreamWrapper, get_secret from ..base_aws_llm import BaseAWSLLM from ..common_utils import ( - BEDROCK_RESPONSE_STREAM_SHAPE, BedrockError, ModelResponseIterator, + get_bedrock_response_stream_shape, get_bedrock_tool_name, ) @@ -1828,7 +1828,8 @@ class AWSEventStreamDecoder: yield self._chunk_parser(chunk_data=_data) def _parse_message_from_event(self, event) -> Optional[str]: - if BEDROCK_RESPONSE_STREAM_SHAPE is None: + response_stream_shape = get_bedrock_response_stream_shape() + if response_stream_shape is None: raise BedrockError( status_code=500, message=( @@ -1837,9 +1838,7 @@ class AWSEventStreamDecoder: ), ) response_dict = event.to_response_dict() - parsed_response = self.parser.parse( - response_dict, BEDROCK_RESPONSE_STREAM_SHAPE - ) + parsed_response = self.parser.parse(response_dict, response_stream_shape) if response_dict["status_code"] != 200: decoded_body = response_dict["body"].decode() diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index c883ab68dff..d9599b8b9c4 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, List, Optional import httpx from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers +from litellm.litellm_core_utils.litellm_logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_anthropic_image_obj, ) @@ -22,6 +23,7 @@ from litellm.llms.bedrock.common_utils import ( from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import ModelResponse +from litellm.utils import _supports_factory if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -169,6 +171,24 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): anthropic_request.pop("model", None) anthropic_request.pop("stream", None) anthropic_request.pop("output_format", None) + if not ( + _supports_factory( + model=model, + custom_llm_provider="bedrock", + key="supports_output_config", + ) + or AnthropicConfig._model_supports_effort_param(model) + ): + if anthropic_request.pop("output_config", None) is not None: + verbose_logger.warning( + "Bedrock Invoke: stripping unsupported `output_config` for " + "model=%s — neither `supports_output_config` nor any " + "`supports_*_reasoning_effort` flag is set in " + "model_prices_and_context_window.json. Add the capability " + "flag to the model JSON entry if this model accepts " + "`output_config`.", + model, + ) if "anthropic_version" not in anthropic_request: anthropic_request["anthropic_version"] = self.anthropic_version diff --git a/litellm/llms/bedrock/claude_platform/common_utils.py b/litellm/llms/bedrock/claude_platform/common_utils.py index 121221518c8..3abb8710de7 100644 --- a/litellm/llms/bedrock/claude_platform/common_utils.py +++ b/litellm/llms/bedrock/claude_platform/common_utils.py @@ -4,7 +4,6 @@ import litellm from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.secret_managers.main import get_secret_str - CLAUDE_PLATFORM_SERVICE_NAME: Literal["aws-external-anthropic"] = ( "aws-external-anthropic" ) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 0256d5d4b95..4f4729e4019 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -4,6 +4,7 @@ from __future__ import annotations Common utilities used across bedrock chat/embedding/image generation """ +import functools import json import os from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union @@ -963,10 +964,8 @@ def _load_bedrock_response_stream_shape(): """ Load the ResponseStream shape from botocore's bundled bedrock-runtime schema. - Called once at module import time; the result is stored in - ``BEDROCK_RESPONSE_STREAM_SHAPE`` and reused for the process lifetime. Returns ``None`` if botocore is unavailable or the service model cannot be - loaded, so the module still imports cleanly. + loaded. """ try: from botocore.loaders import Loader @@ -977,15 +976,22 @@ def _load_bedrock_response_stream_shape(): return ServiceModel(service_dict).shape_for("ResponseStream") except Exception as e: verbose_logger.warning( - "litellm: could not pre-load bedrock-runtime response stream shape " + "litellm: could not load bedrock-runtime response stream shape " "— Bedrock event-stream decoding will be unavailable. Error: %s", e, ) return None -# Eagerly resolved once per process — avoids per-instance or per-request disk I/O. -BEDROCK_RESPONSE_STREAM_SHAPE = _load_bedrock_response_stream_shape() +@functools.lru_cache(maxsize=1) +def get_bedrock_response_stream_shape(): + """ + Lazily load and cache the bedrock-runtime ResponseStream shape for the process. + + Avoids importing botocore (and logging warnings) unless Bedrock event-stream + decoding is actually needed. + """ + return _load_bedrock_response_stream_shape() class BedrockEventStreamDecoderBase: @@ -999,7 +1005,8 @@ class BedrockEventStreamDecoderBase: self.parser = EventStreamJSONParser() def _parse_message_from_event(self, event) -> Optional[str]: - if BEDROCK_RESPONSE_STREAM_SHAPE is None: + response_stream_shape = get_bedrock_response_stream_shape() + if response_stream_shape is None: raise BedrockError( status_code=500, message=( @@ -1008,9 +1015,7 @@ class BedrockEventStreamDecoderBase: ), ) response_dict = event.to_response_dict() - parsed_response = self.parser.parse( - response_dict, BEDROCK_RESPONSE_STREAM_SHAPE - ) + parsed_response = self.parser.parse(response_dict, response_stream_shape) if response_dict["status_code"] != 200: decoded_body = response_dict["body"].decode() diff --git a/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py b/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py index 2747551af81..64a79b73273 100644 --- a/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py +++ b/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI /v1/embeddings format to Bedrock Amazon Titan G1 /invoke format. +Transformation logic from OpenAI /v1/embeddings format to Bedrock Amazon Titan G1 /invoke format. Why separate file? Make it easy to see how transformation works diff --git a/litellm/llms/bedrock/embed/cohere_transformation.py b/litellm/llms/bedrock/embed/cohere_transformation.py index 2c0dc834144..9570ff1a14c 100644 --- a/litellm/llms/bedrock/embed/cohere_transformation.py +++ b/litellm/llms/bedrock/embed/cohere_transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI /v1/embeddings format to Bedrock Cohere /invoke format. +Transformation logic from OpenAI /v1/embeddings format to Bedrock Cohere /invoke format. Why separate file? Make it easy to see how transformation works """ diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 151e0e404a0..69b61298d33 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -45,6 +45,7 @@ from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import GenericStreamingChunk from litellm.types.utils import GenericStreamingChunk as GChunk from litellm.types.utils import ModelResponseStream +from litellm.utils import _supports_factory if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -557,7 +558,29 @@ class AmazonAnthropicClaudeMessagesConfig( anthropic_messages_request=anthropic_messages_request, ) - # 5a. Remove `custom` field from tools (Bedrock doesn't support it) + # 5a. Bedrock Invoke supports output_config (effort) for Claude 4.6+ models, + # but older models do not — strip it to avoid request rejection. + # Ref: https://github.com/BerriAI/litellm/issues/22797 + if not ( + _supports_factory( + model=model, + custom_llm_provider="bedrock", + key="supports_output_config", + ) + or AnthropicConfig._model_supports_effort_param(model) + ): + if anthropic_messages_request.pop("output_config", None) is not None: + verbose_logger.warning( + "Bedrock Invoke: stripping unsupported `output_config` for " + "model=%s — neither `supports_output_config` nor any " + "`supports_*_reasoning_effort` flag is set in " + "model_prices_and_context_window.json. Add the capability " + "flag to the model JSON entry if this model accepts " + "`output_config`.", + model, + ) + + # 5b. Remove `custom` field from tools (Bedrock doesn't support it) # Claude Code sends `custom: {defer_loading: true}` on tool definitions, # which causes Bedrock to reject the request with "Extra inputs are not permitted" # Ref: https://github.com/BerriAI/litellm/issues/22847 diff --git a/litellm/llms/bedrock_mantle/chat/transformation.py b/litellm/llms/bedrock_mantle/chat/transformation.py index e413bb22b2d..81a56030a5c 100644 --- a/litellm/llms/bedrock_mantle/chat/transformation.py +++ b/litellm/llms/bedrock_mantle/chat/transformation.py @@ -16,7 +16,6 @@ from litellm.secret_managers.main import get_secret_str from ...openai_like.chat.transformation import OpenAILikeChatConfig - BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1" diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 66acd933416..56b61b66c84 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -1,7 +1,5 @@ -import json -from typing import Any, Optional +from typing import Any, Dict, Optional -from litellm.constants import STREAM_SSE_DONE_STRING from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( @@ -9,13 +7,17 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo ) from litellm.llms.openai.common_utils import OpenAIError from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.responses.sse_output_recovery import ( + parse_sse_json_chunk, + record_output_item_chunk, + record_output_text_chunk, +) from litellm.types.llms.openai import ( ResponsesAPIResponse, ResponsesAPIStreamEvents, ) from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders -from litellm.utils import CustomStreamWrapper from ..authenticator import Authenticator from ..common_utils import ( @@ -111,86 +113,139 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): raw_response: Any, logging_obj: Any, ): - content_type = (raw_response.headers or {}).get("content-type", "") body_text = raw_response.text or "" - if "text/event-stream" not in content_type.lower(): - trimmed_body = body_text.lstrip() - if not ( - trimmed_body.startswith("event:") - or trimmed_body.startswith("data:") - or "\nevent:" in body_text - or "\ndata:" in body_text - ): - return super().transform_response_api_response( - model=model, - raw_response=raw_response, - logging_obj=logging_obj, - ) + if not self._should_parse_as_sse( + raw_response=raw_response, body_text=body_text + ): + return super().transform_response_api_response( + model=model, + raw_response=raw_response, + logging_obj=logging_obj, + ) logging_obj.post_call( original_response=raw_response.text, additional_args={"complete_input_dict": {}}, ) - completed_response = None - error_message = None - for chunk in body_text.splitlines(): - stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk) - if not stripped_chunk: - continue - stripped_chunk = stripped_chunk.strip() - if not stripped_chunk: - continue - if stripped_chunk == STREAM_SSE_DONE_STRING: - break - try: - parsed_chunk = json.loads(stripped_chunk) - except json.JSONDecodeError: - continue - if not isinstance(parsed_chunk, dict): - continue - event_type = parsed_chunk.get("type") - if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: - response_payload = parsed_chunk.get("response") - if isinstance(response_payload, dict): - response_payload = dict(response_payload) - if "created_at" in response_payload: - response_payload["created_at"] = _safe_convert_created_field( - response_payload["created_at"] - ) - try: - completed_response = ResponsesAPIResponse(**response_payload) - except Exception: - completed_response = ResponsesAPIResponse.model_construct( - **response_payload - ) - break - if event_type in ( - ResponsesAPIStreamEvents.RESPONSE_FAILED, - ResponsesAPIStreamEvents.ERROR, - ): - error_obj = parsed_chunk.get("error") or ( - parsed_chunk.get("response") or {} - ).get("error") - if error_obj is not None: - if isinstance(error_obj, dict): - error_message = error_obj.get("message") or str(error_obj) - else: - error_message = str(error_obj) - + completed_response, error_message = self._extract_completed_response_from_sse( + body_text=body_text + ) if completed_response is None: raise OpenAIError( message=error_message or raw_response.text, status_code=raw_response.status_code, ) + self._attach_response_headers( + completed_response=completed_response, raw_response=raw_response + ) + return completed_response + + def _should_parse_as_sse(self, raw_response: Any, body_text: str) -> bool: + content_type = (raw_response.headers or {}).get("content-type", "") + if "text/event-stream" in content_type.lower(): + return True + trimmed_body = body_text.lstrip() + return bool( + trimmed_body.startswith("event:") + or trimmed_body.startswith("data:") + or "\nevent:" in body_text + or "\ndata:" in body_text + ) + + def _extract_completed_response_from_sse( + self, body_text: str + ) -> tuple[Optional[ResponsesAPIResponse], Optional[str]]: + completed_response = None + error_message = None + streamed_output_items: Dict[int, dict] = {} + text_only_output_items: Dict[int, dict] = {} + for chunk in body_text.splitlines(): + parsed_chunk = parse_sse_json_chunk(chunk) + if parsed_chunk is None: + continue + + event_type = parsed_chunk.get("type") + if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE: + record_output_item_chunk( + parsed_chunk=parsed_chunk, + output_items=streamed_output_items, + ) + continue + + if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE: + record_output_text_chunk( + parsed_chunk=parsed_chunk, + output_items=streamed_output_items, + text_only_items=text_only_output_items, + ) + continue + + if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED: + # Real OUTPUT_ITEM_DONE events take precedence at any given + # output_index, but text-only items at indices without a + # matching OUTPUT_ITEM_DONE must still be preserved (e.g. + # providers that emit only OUTPUT_TEXT_DONE for some indices). + merged_items: Dict[int, dict] = {**text_only_output_items} + merged_items.update(streamed_output_items) + completed_response = self._build_completed_response_from_chunk( + parsed_chunk=parsed_chunk, + streamed_output_items=merged_items, + ) + break + + if event_type in ( + ResponsesAPIStreamEvents.RESPONSE_FAILED, + ResponsesAPIStreamEvents.ERROR, + ): + extracted_error = self._extract_error_message(parsed_chunk) + if extracted_error is not None: + error_message = extracted_error + + return completed_response, error_message + + def _build_completed_response_from_chunk( + self, parsed_chunk: Dict[str, Any], streamed_output_items: Dict[int, dict] + ) -> Optional[ResponsesAPIResponse]: + response_payload = parsed_chunk.get("response") + if not isinstance(response_payload, dict): + return None + response_payload = dict(response_payload) + if not response_payload.get("output") and streamed_output_items: + response_payload["output"] = [ + item for _, item in sorted(streamed_output_items.items()) + ] + if "created_at" in response_payload: + response_payload["created_at"] = _safe_convert_created_field( + response_payload["created_at"] + ) + try: + return ResponsesAPIResponse(**response_payload) + except Exception: + return ResponsesAPIResponse.model_construct(**response_payload) + + def _extract_error_message(self, parsed_chunk: Dict[str, Any]) -> Optional[str]: + error_obj = parsed_chunk.get("error") or ( + parsed_chunk.get("response") or {} + ).get("error") + if error_obj is None: + return None + if isinstance(error_obj, dict): + return error_obj.get("message") or str(error_obj) + return str(error_obj) + + def _attach_response_headers( + self, + completed_response: ResponsesAPIResponse, + raw_response: Any, + ) -> None: raw_headers = dict(raw_response.headers) processed_headers = process_response_headers(raw_headers) if not hasattr(completed_response, "_hidden_params"): setattr(completed_response, "_hidden_params", {}) completed_response._hidden_params["additional_headers"] = processed_headers completed_response._hidden_params["headers"] = raw_headers - return completed_response def get_complete_url( self, diff --git a/litellm/llms/cohere/embed/handler.py b/litellm/llms/cohere/embed/handler.py index 3ab8baf7ba8..81b6a1c7aec 100644 --- a/litellm/llms/cohere/embed/handler.py +++ b/litellm/llms/cohere/embed/handler.py @@ -1,5 +1,5 @@ """ -Legacy /v1/embedding handler for Bedrock Cohere. +Legacy /v1/embedding handler for Bedrock Cohere. """ import json diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index 599cd705ebf..501390d840b 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -257,14 +257,19 @@ class GenericContainerHandler: returns_binary = endpoint_config.get("returns_binary", False) is_multipart = endpoint_config.get("is_multipart", False) + # An empty dict passed as `params` to httpx strips any existing query + # string from the URL (e.g. ?api-version=...). Use None instead so + # httpx leaves the URL's own query string intact. + effective_params = query_params or None + try: if method == "GET": response = http_client.get( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "DELETE": response = http_client.delete( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "POST": if is_multipart and "file" in kwargs: @@ -272,11 +277,11 @@ class GenericContainerHandler: kwargs["file"], headers ) response = http_client.post( - url=url, headers=headers, params=query_params, files=files + url=url, headers=headers, params=effective_params, files=files ) else: response = http_client.post( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -376,14 +381,19 @@ class GenericContainerHandler: returns_binary = endpoint_config.get("returns_binary", False) is_multipart = endpoint_config.get("is_multipart", False) + # An empty dict passed as `params` to httpx strips any existing query + # string from the URL (e.g. ?api-version=...). Use None instead so + # httpx leaves the URL's own query string intact. + effective_params = query_params or None + try: if method == "GET": response = await http_client.get( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "DELETE": response = await http_client.delete( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) elif method == "POST": if is_multipart and "file" in kwargs: @@ -391,11 +401,11 @@ class GenericContainerHandler: kwargs["file"], headers ) response = await http_client.post( - url=url, headers=headers, params=query_params, files=files + url=url, headers=headers, params=effective_params, files=files ) else: response = await http_client.post( - url=url, headers=headers, params=query_params + url=url, headers=headers, params=effective_params ) else: raise ValueError(f"Unsupported HTTP method: {method}") diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 2ff63cc2d7f..96fdf4494f9 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1409,6 +1409,8 @@ class BaseLLMHTTPHandler: document=document, optional_params=optional_params, headers=headers, + api_key=api_key, + api_base=api_base, ) # All providers return OCRRequestData @@ -1477,6 +1479,8 @@ class BaseLLMHTTPHandler: document=document, optional_params=optional_params, headers=headers, + api_key=api_key, + api_base=api_base, ) # All providers return OCRRequestData @@ -7834,7 +7838,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_list_response( @@ -7911,7 +7915,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_list_response( @@ -8001,7 +8005,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_retrieve_response( @@ -8078,7 +8082,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_retrieve_response( @@ -8168,7 +8172,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.delete( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_delete_response( @@ -8245,7 +8249,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.delete( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_delete_response( @@ -8341,7 +8345,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_list_response( @@ -8420,7 +8424,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_list_response( @@ -8508,7 +8512,7 @@ class BaseLLMHTTPHandler: response = sync_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_content_response( @@ -8584,7 +8588,7 @@ class BaseLLMHTTPHandler: response = await async_httpx_client.get( url=url, headers=headers, - params=params, + params=params or None, ) return container_provider_config.transform_container_file_content_response( diff --git a/litellm/llms/custom_httpx/mock_transport.py b/litellm/llms/custom_httpx/mock_transport.py index c9844753e0e..ad93cc134ee 100644 --- a/litellm/llms/custom_httpx/mock_transport.py +++ b/litellm/llms/custom_httpx/mock_transport.py @@ -13,7 +13,6 @@ from typing import Tuple import httpx - # --------------------------------------------------------------------------- # Pre-built response templates # --------------------------------------------------------------------------- diff --git a/litellm/llms/dashscope/cost_calculator.py b/litellm/llms/dashscope/cost_calculator.py index 9b3e3851162..8bb7f605b82 100644 --- a/litellm/llms/dashscope/cost_calculator.py +++ b/litellm/llms/dashscope/cost_calculator.py @@ -1,5 +1,5 @@ """ -Cost calculator for Dashscope Chat models. +Cost calculator for Dashscope Chat models. Handles tiered pricing and prompt caching scenarios. """ diff --git a/litellm/llms/datarobot/chat/transformation.py b/litellm/llms/datarobot/chat/transformation.py index 23ce63c25b2..f81e2420930 100644 --- a/litellm/llms/datarobot/chat/transformation.py +++ b/litellm/llms/datarobot/chat/transformation.py @@ -1,5 +1,5 @@ """ -Support for OpenAI's `/v1/chat/completions` endpoint. +Support for OpenAI's `/v1/chat/completions` endpoint. Calls done in OpenAI/openai.py as DataRobot is openai-compatible. """ diff --git a/litellm/llms/deepinfra/rerank/transformation.py b/litellm/llms/deepinfra/rerank/transformation.py index 276735f4758..e4bfbcb2513 100644 --- a/litellm/llms/deepinfra/rerank/transformation.py +++ b/litellm/llms/deepinfra/rerank/transformation.py @@ -1,5 +1,5 @@ """ -Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format. +Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format. """ from typing import Any, Dict, List, Optional, Union diff --git a/litellm/llms/deepseek/cost_calculator.py b/litellm/llms/deepseek/cost_calculator.py index 0f4490cb3df..e652ebeac54 100644 --- a/litellm/llms/deepseek/cost_calculator.py +++ b/litellm/llms/deepseek/cost_calculator.py @@ -1,5 +1,5 @@ """ -Cost calculator for DeepSeek Chat models. +Cost calculator for DeepSeek Chat models. Handles prompt caching scenario. """ diff --git a/litellm/llms/elevenlabs/text_to_speech/transformation.py b/litellm/llms/elevenlabs/text_to_speech/transformation.py index 6a59911701b..612fc687ef9 100644 --- a/litellm/llms/elevenlabs/text_to_speech/transformation.py +++ b/litellm/llms/elevenlabs/text_to_speech/transformation.py @@ -22,7 +22,6 @@ from litellm.types.utils import all_litellm_params from ..common_utils import ElevenLabsException - if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.llms.openai import HttpxBinaryResponseContent diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index eaf01c5fe18..d39adf0b6f4 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -4,6 +4,7 @@ from typing import Any, List, Literal, Optional, Tuple, Union, cast import httpx import litellm +from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -26,6 +27,7 @@ from litellm.types.utils import ( ProviderSpecificModelInfo, ) from litellm.utils import ( + get_model_cost_mutation_generation, supports_function_calling, supports_reasoning, supports_tool_choice, @@ -112,6 +114,19 @@ class FireworksAIConfig(OpenAIGPTConfig): # Only add tools for models that support function calling if supports_function_calling(model=model, custom_llm_provider="fireworks_ai"): supported_params.append("tools") + supported_params.append("parallel_tool_calls") + else: + # Historically every Fireworks model advertised tool support, so a + # JSON entry that flips `supports_function_calling` to false will + # silently drop `tools` from requests. Surface this so users can + # tell why their tool calls suddenly stop working. + verbose_logger.debug( + "fireworks_ai model %r is marked as not supporting " + "function calling in model_prices_and_context_window.json; " + "`tools` and `parallel_tool_calls` will be dropped from the " + "request.", + model, + ) # Only add tool_choice for models that explicitly support it if supports_tool_choice(model=model, custom_llm_provider="fireworks_ai"): @@ -251,34 +266,100 @@ class FireworksAIConfig(OpenAIGPTConfig): return messages - def get_provider_info(self, model: str) -> ProviderSpecificModelInfo: - # Models that support reasoning_effort - reasoning_supported_models = [ - "qwen3-8b", - "qwen3-32b", - "qwen3-coder-480b-a35b-instruct", - "deepseek-v3p1", - "deepseek-v3p2", - "glm-4p5", - "glm-4p5-air", - "glm-4p6", - "gpt-oss-120b", - "gpt-oss-20b", + # Cached index of fireworks_ai/* entries from litellm.model_cost. Building + # this index requires a full scan of model_cost (tens of thousands of + # entries), so we memoize it. The cache key is (id(model_cost), + # mutation_generation): the generation counter is bumped on every + # register_model / reload path, so add+remove or in-place value + # replacement (which can leave id and len unchanged) still invalidates. + _fireworks_index_cache: Optional[Tuple[int, int, List[Tuple[str, dict]]]] = None + + @classmethod + def _get_fireworks_index(cls) -> List[Tuple[str, dict]]: + model_cost = litellm.model_cost + signature = (id(model_cost), get_model_cost_mutation_generation()) + cached = cls._fireworks_index_cache + if ( + cached is not None + and cached[0] == signature[0] + and cached[1] == signature[1] + ): + return cached[2] + + index: List[Tuple[str, dict]] = [] + for key, model_info in model_cost.items(): + if not key.startswith("fireworks_ai/"): + continue + if not isinstance(model_info, dict): + continue + key_short = key[len("fireworks_ai/") :] + if key_short.startswith("accounts/fireworks/models/"): + key_short = key_short[len("accounts/fireworks/models/") :] + if not key_short: + continue + index.append((key_short, model_info)) + + cls._fireworks_index_cache = (signature[0], signature[1], index) + return index + + @staticmethod + def _matches_on_hyphen_boundary(short_name: str, key_short: str) -> bool: + """Return True if `key_short` appears in `short_name` aligned to + hyphen-separated word boundaries (or end-of-string). This avoids + spurious substring matches like `"some-model"` matching + `"awesome-model"`.""" + if short_name == key_short: + return True + if short_name.startswith(key_short + "-"): + return True + if short_name.endswith("-" + key_short): + return True + return ("-" + key_short + "-") in short_name + + def _get_model_cost_capability(self, model: str, capability: str) -> Optional[bool]: + short_name = model + if short_name.startswith("fireworks_ai/"): + short_name = short_name[len("fireworks_ai/") :] + if short_name.startswith("accounts/fireworks/models/"): + short_name = short_name[len("accounts/fireworks/models/") :] + + candidate_keys = [ + model, + f"fireworks_ai/{short_name}", + f"fireworks_ai/accounts/fireworks/models/{short_name}", ] - # Normalize model name - remove prefix if present - normalized_model = model - if model.startswith("fireworks_ai/"): - normalized_model = model.replace("fireworks_ai/", "") - if normalized_model.startswith("accounts/fireworks/models/"): - normalized_model = normalized_model.replace( - "accounts/fireworks/models/", "" - ) + for candidate_key in candidate_keys: + model_info = litellm.model_cost.get(candidate_key) + if model_info is not None and model_info.get(capability) is not None: + return cast(Optional[bool], model_info.get(capability)) - # Check if model supports reasoning - supports_reasoning_value = any( - reasoning_model in normalized_model - for reasoning_model in reasoning_supported_models + # Fallback: preserve historical substring matching for model name + # variants (e.g. fine-tuned or regionally-suffixed versions of a + # known model). Pick the *longest* matching entry so a more specific + # known model (e.g. "qwen3-8b-instruct") wins over a less specific + # one (e.g. "qwen3-8b") when the query model is more specific still. + # Use hyphen-aligned matching to avoid false positives where a short + # known model name is an unrelated substring of a longer one. + best_match_short: Optional[str] = None + best_match_value: Optional[bool] = None + for key_short, model_info in self._get_fireworks_index(): + if model_info.get(capability) is None: + continue + if not self._matches_on_hyphen_boundary(short_name, key_short): + continue + if best_match_short is None or len(key_short) > len(best_match_short): + best_match_short = key_short + best_match_value = cast(Optional[bool], model_info.get(capability)) + + return best_match_value + + def get_provider_info(self, model: str) -> ProviderSpecificModelInfo: + supports_function_calling_value = self._get_model_cost_capability( + model=model, capability="supports_function_calling" + ) + supports_reasoning_value = self._get_model_cost_capability( + model=model, capability="supports_reasoning" ) provider_specific_model_info: ProviderSpecificModelInfo = { @@ -288,9 +369,16 @@ class FireworksAIConfig(OpenAIGPTConfig): "supports_vision": True, # via document inlining } + if supports_function_calling_value is not None: + provider_specific_model_info["supports_function_calling"] = ( + supports_function_calling_value + ) + # Only include supports_reasoning if True if supports_reasoning_value: - provider_specific_model_info["supports_reasoning"] = True + provider_specific_model_info["supports_reasoning"] = ( + supports_reasoning_value + ) return provider_specific_model_info diff --git a/litellm/llms/gemini/agents/__init__.py b/litellm/llms/gemini/agents/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/gemini/agents/transformation.py b/litellm/llms/gemini/agents/transformation.py new file mode 100644 index 00000000000..f6e0b95cf28 --- /dev/null +++ b/litellm/llms/gemini/agents/transformation.py @@ -0,0 +1,298 @@ +""" +Google AI Studio Agents API configuration. + +Proxies the Gemini v1beta Agents API: + POST /v1beta/agents create + GET /v1beta/agents list + GET /v1beta/agents/{name} get + DELETE /v1beta/agents/{name} delete + GET /v1beta/agents/{name}/versions list versions +""" + +from typing import Any, Dict, Optional, Tuple, Union + +import httpx + +from litellm._logging import verbose_logger +from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig +from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo +from litellm.types.agents import ( + AgentCreateResponse, + AgentDeleteResult, + AgentListResponse, + AgentVersionsResponse, +) + +# Keys inside litellm_params that should be forwarded to the Gemini +# create-agent body verbatim. +_GEMINI_AGENT_BODY_KEYS = ("base_agent", "instructions", "base_environment") + +# LiteLLM-internal keys that must never be forwarded to Gemini. +_LITELLM_INTERNAL_KEYS = frozenset( + { + "custom_llm_provider", + "api_key", + "api_base", + "make_public", + "cost_per_query", + "input_cost_per_token", + "output_cost_per_token", + "require_trace_id_on_calls_to_agent", + "require_trace_id_on_calls_by_agent", + "max_iterations", + "max_budget_per_session", + "guardrails", + "is_public", + "agent_name", + "agent_id", + "agent_card_params", + "provider_agent_response", + } +) + + +class GeminiAgentsConfig(BaseAgentsAPIConfig): + """ + Configuration for the Google AI Studio (Gemini) native Agents API. + + Authentication uses x-goog-api-key, resolved from (in order): + 1. litellm_params["api_key"] + 2. GOOGLE_API_KEY env var + 3. GEMINI_API_KEY env var + """ + + @property + def api_version(self) -> str: + return "v1beta" + + def _base_url(self, api_base: Optional[str]) -> str: + return f"{GeminiModelInfo.get_api_base(api_base)}/{self.api_version}" + + # ------------------------------------------------------------------ # + # Shared helpers # + # ------------------------------------------------------------------ # + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: Union[dict, httpx.Headers], + ) -> Exception: + return GeminiError( + message=error_message, + status_code=status_code, + headers=dict(headers), + ) + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> str: + return f"{self._base_url(api_base)}/agents" + + def validate_environment( + self, + headers: Dict[str, str], + litellm_params: Dict[str, Any], + ) -> Dict[str, str]: + headers = dict(headers) + headers["Content-Type"] = "application/json" + explicit_api_key = litellm_params.get("api_key") + # SECURITY: when the caller overrides ``api_base``, refuse to fall back + # to the process-wide GOOGLE_API_KEY / GEMINI_API_KEY env vars. Otherwise + # an authenticated proxy user could set ``api_base`` to an attacker- + # controlled host and have the proxy ship its shared Gemini key in the + # ``x-goog-api-key`` header. + if litellm_params.get("api_base") and not explicit_api_key: + raise ValueError( + "When overriding api_base for Gemini agents, you must also " + "supply an explicit api_key. Falling back to GOOGLE_API_KEY / " + "GEMINI_API_KEY env vars with a custom api_base is refused " + "to prevent leaking the shared provider key to arbitrary hosts." + ) + api_key = GeminiModelInfo.get_api_key(explicit_api_key) + if not api_key: + raise ValueError( + "Google API key is required. " + "Set GOOGLE_API_KEY or GEMINI_API_KEY, or pass api_key." + ) + headers["x-goog-api-key"] = api_key + return headers + + def _raise_for_status(self, raw_response: httpx.Response) -> None: + if not (200 <= raw_response.status_code < 300): + raise GeminiError( + message=raw_response.text, + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) + + # ------------------------------------------------------------------ # + # CREATE # + # ------------------------------------------------------------------ # + + def transform_create_request( + self, + name: str, + litellm_params: Dict[str, Any], + ) -> Dict[str, Any]: + body: Dict[str, Any] = {"name": name} + for key in _GEMINI_AGENT_BODY_KEYS: + value = litellm_params.get(key) + if value is not None: + body[key] = value + verbose_logger.debug("GeminiAgentsConfig create body: %s", body) + return body + + def transform_create_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentCreateResponse: + """ + Gemini returns: + {"id": "my-agent", "base_agent": "waverunner", + "system_instruction": "...", "base_environment": {...}} + """ + self._raise_for_status(raw_response) + try: + data: Dict[str, Any] = raw_response.json() + except Exception: + verbose_logger.warning( + "GeminiAgentsConfig: non-JSON create response (status=%d).", + raw_response.status_code, + ) + data = {"id": name} + # Gemini uses "id" as the identifier; normalise to both fields. + data.setdefault("id", name) + data.setdefault("name", data["id"]) + verbose_logger.debug("GeminiAgentsConfig create response: %s", data) + return AgentCreateResponse(**data) + + # ------------------------------------------------------------------ # + # LIST # + # ------------------------------------------------------------------ # + + def transform_list_request( + self, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + url = f"{self._base_url(api_base)}/agents" + params: Dict[str, Any] = {} + if litellm_params.get("page_size"): + params["pageSize"] = litellm_params["page_size"] + if litellm_params.get("page_token"): + params["pageToken"] = litellm_params["page_token"] + return url, params + + def transform_list_response( + self, + raw_response: httpx.Response, + ) -> AgentListResponse: + self._raise_for_status(raw_response) + try: + data = raw_response.json() + except Exception: + data = {} + verbose_logger.debug("GeminiAgentsConfig list response: %s", data) + return AgentListResponse( + agents=data.get("agents", []), + next_page_token=data.get("nextPageToken"), + ) + + # ------------------------------------------------------------------ # + # GET # + # ------------------------------------------------------------------ # + + def transform_get_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + url = f"{self._base_url(api_base)}/agents/{name}" + return url, {} + + def transform_get_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentCreateResponse: + """Same shape as create response — Gemini returns "id" as identifier.""" + self._raise_for_status(raw_response) + try: + data = raw_response.json() + except Exception: + data = {"id": name} + data.setdefault("id", name) + data.setdefault("name", data["id"]) + verbose_logger.debug("GeminiAgentsConfig get response: %s", data) + return AgentCreateResponse(**data) + + # ------------------------------------------------------------------ # + # DELETE # + # ------------------------------------------------------------------ # + + def transform_delete_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> str: + return f"{self._base_url(api_base)}/agents/{name}" + + def transform_delete_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentDeleteResult: + """Gemini returns an empty body ``{}`` with HTTP 200 on success.""" + self._raise_for_status(raw_response) + verbose_logger.debug( + "GeminiAgentsConfig delete (status=%d) agent '%s'", + raw_response.status_code, + name, + ) + return AgentDeleteResult(name=name, deleted=True) + + # ------------------------------------------------------------------ # + # LIST VERSIONS # + # ------------------------------------------------------------------ # + + def transform_list_versions_request( + self, + name: str, + api_base: Optional[str], + litellm_params: Dict[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + url = f"{self._base_url(api_base)}/agents/{name}/versions" + params: Dict[str, Any] = {} + if litellm_params.get("page_size"): + params["pageSize"] = litellm_params["page_size"] + if litellm_params.get("page_token"): + params["pageToken"] = litellm_params["page_token"] + return url, params + + def transform_list_versions_response( + self, + raw_response: httpx.Response, + name: str, + ) -> AgentVersionsResponse: + """ + Gemini returns: + {"agentVersions": [{"agent": "waverunner", "name": "agents/.../versions/uuid", ...}]} + """ + self._raise_for_status(raw_response) + try: + data = raw_response.json() + except Exception: + data = {} + verbose_logger.debug( + "GeminiAgentsConfig list_versions response for '%s': %s", name, data + ) + return AgentVersionsResponse( + agent_versions=data.get("agentVersions", []), + next_page_token=data.get("nextPageToken"), + ) diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index 16e17dcc876..b69b7e1913e 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -164,5 +164,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): # If conversion fails, leave as is and let the API handle it pass return _gemini_convert_messages_with_history( - messages=messages, model=model, litellm_params=litellm_params + messages=messages, + model=model, + litellm_params=litellm_params, + custom_llm_provider="gemini", ) diff --git a/litellm/llms/gemini/interactions/transformation.py b/litellm/llms/gemini/interactions/transformation.py index 593cbf7c2cf..b18b6a28ce4 100644 --- a/litellm/llms/gemini/interactions/transformation.py +++ b/litellm/llms/gemini/interactions/transformation.py @@ -6,13 +6,18 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json): - Get: GET https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id} - Delete: DELETE https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id} -This is a thin wrapper - no transformation needed since we follow the spec directly. +Schema versioning: +- Default (Api-Revision: 2026-05-20): new `steps` schema. +- Legacy (Api-Revision: 2026-05-07): old `outputs` schema, controlled via + litellm.use_legacy_interactions_schema = True. Remove flag after June 8, 2026. """ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import httpx +import litellm + from litellm._logging import verbose_logger from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.url_utils import encode_url_path_segment @@ -64,6 +69,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): "stream", "store", "background", + "environment", "response_modalities", "response_format", "response_mime_type", @@ -83,6 +89,15 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key")) if api_key: headers["x-goog-api-key"] = api_key + + # Inject the Api-Revision header to select the response schema. + # Default to the new `steps` schema unless the operator has opted out. + # Remove this conditional after June 8, 2026 and always use 2026-05-20. + if litellm.use_legacy_interactions_schema: + headers["Api-Revision"] = "2026-05-07" + else: + headers["Api-Revision"] = "2026-05-20" + return headers def get_complete_url( @@ -118,8 +133,19 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): headers: dict, ) -> Dict: """ - Build request body per OpenAPI spec - minimal transformation. + Build request body per OpenAPI spec. + + When on the new schema (use_legacy_interactions_schema=False, the default): + - ``response_mime_type`` is folded into ``response_format`` and stripped from + the body (the field was removed in Api-Revision 2026-05-20). + - ``generation_config.image_config`` is moved to a ``response_format`` entry + with ``"type": "image"`` (also removed from generation_config in 2026-05-20). + + When on the legacy schema (use_legacy_interactions_schema=True): + - All fields are forwarded as-is. """ + use_legacy: bool = litellm.use_legacy_interactions_schema + request_body: Dict[str, Any] = {} # Model or Agent (one required) @@ -134,23 +160,81 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): if input is not None: request_body["input"] = input - # Pass through optional params directly (they match the spec) + # Pass through optional params — legacy schema keeps all fields as-is. optional_keys = [ "tools", "system_instruction", - "generation_config", "stream", "store", "background", + "environment", "response_modalities", - "response_format", - "response_mime_type", "previous_interaction_id", ] for key in optional_keys: if optional_params.get(key) is not None: request_body[key] = optional_params[key] + if use_legacy: + # Legacy schema: forward response_mime_type and response_format as-is. + for key in ("response_format", "response_mime_type", "generation_config"): + if optional_params.get(key) is not None: + request_body[key] = optional_params[key] + else: + # New schema (Api-Revision: 2026-05-20): + # response_mime_type is removed — fold it into response_format. + response_format = optional_params.get("response_format") + response_mime_type = optional_params.get("response_mime_type") + + if ( + response_mime_type + and not isinstance(response_format, list) + and ( + not isinstance(response_format, dict) + or "mime_type" not in response_format + ) + ): + # Wrap the legacy schema into the new polymorphic format. + new_rf: Dict[str, Any] = { + "type": "text", + "mime_type": response_mime_type, + } + if response_format is not None: + new_rf["schema"] = response_format + response_format = new_rf + + if response_format is not None: + request_body["response_format"] = response_format + + # image_config moves out of generation_config into response_format. + generation_config: Optional[Dict[str, Any]] = optional_params.get( + "generation_config" + ) + if generation_config is not None: + image_config = None + if isinstance(generation_config, dict): + generation_config = dict( + generation_config + ) # avoid mutating the caller's dict + image_config = generation_config.pop("image_config", None) + if not generation_config: + generation_config = None + + if generation_config is not None: + request_body["generation_config"] = generation_config + + if image_config is not None: + # Move image_config to response_format with type=image. + image_rf: Dict[str, Any] = {"type": "image", **image_config} + existing_rf = request_body.get("response_format") + if existing_rf is None: + request_body["response_format"] = image_rf + elif isinstance(existing_rf, list): + request_body["response_format"] = [*existing_rf, image_rf] + else: + # Convert single entry to array for multimodal output. + request_body["response_format"] = [existing_rf, image_rf] + return request_body def transform_response( diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index c7116940b22..9714c8a3923 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -55,7 +55,7 @@ def _convert_image_to_gemini_format(image_file) -> Dict[str, str]: def _usage_video_resolution_from_parameters( - parameters: Dict[str, Any] + parameters: Dict[str, Any], ) -> Optional[str]: """Normalize Veo ``parameters.resolution`` for usage and cost tracking.""" res = parameters.get("resolution") diff --git a/litellm/llms/infinity/rerank/transformation.py b/litellm/llms/infinity/rerank/transformation.py index 314bf2f8a36..b9804605454 100644 --- a/litellm/llms/infinity/rerank/transformation.py +++ b/litellm/llms/infinity/rerank/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank` format. +Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank` format. Why separate file? Make it easy to see how transformation works """ diff --git a/litellm/llms/jina_ai/rerank/transformation.py b/litellm/llms/jina_ai/rerank/transformation.py index ad4416925a6..56be754fc34 100644 --- a/litellm/llms/jina_ai/rerank/transformation.py +++ b/litellm/llms/jina_ai/rerank/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from Cohere's /v1/rerank format to Jina AI's `/v1/rerank` format. +Transformation logic from Cohere's /v1/rerank format to Jina AI's `/v1/rerank` format. Why separate file? Make it easy to see how transformation works diff --git a/litellm/llms/lm_studio/embed/transformation.py b/litellm/llms/lm_studio/embed/transformation.py index 1285550c30f..87f4f6e73d5 100644 --- a/litellm/llms/lm_studio/embed/transformation.py +++ b/litellm/llms/lm_studio/embed/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI /v1/embeddings format to LM Studio's `/v1/embeddings` format. +Transformation logic from OpenAI /v1/embeddings format to LM Studio's `/v1/embeddings` format. Why separate file? Make it easy to see how transformation works diff --git a/litellm/llms/novita/chat/transformation.py b/litellm/llms/novita/chat/transformation.py index c05d2d7b2c5..5a64a124ade 100644 --- a/litellm/llms/novita/chat/transformation.py +++ b/litellm/llms/novita/chat/transformation.py @@ -1,5 +1,5 @@ """ -Support for OpenAI's `/v1/chat/completions` endpoint. +Support for OpenAI's `/v1/chat/completions` endpoint. Calls done in OpenAI/openai.py as Novita AI is openai-compatible. diff --git a/litellm/llms/nvidia_nim/chat/transformation.py b/litellm/llms/nvidia_nim/chat/transformation.py index b8f8b04eb53..2ef92a90626 100644 --- a/litellm/llms/nvidia_nim/chat/transformation.py +++ b/litellm/llms/nvidia_nim/chat/transformation.py @@ -1,7 +1,7 @@ """ -Nvidia NIM endpoint: https://docs.api.nvidia.com/nim/reference/databricks-dbrx-instruct-infer +Nvidia NIM endpoint: https://docs.api.nvidia.com/nim/reference/databricks-dbrx-instruct-infer -This is OpenAI compatible +This is OpenAI compatible This file only contains param mapping logic diff --git a/litellm/llms/nvidia_nim/embed.py b/litellm/llms/nvidia_nim/embed.py index 24c6cc34e4d..61c8e8244e4 100644 --- a/litellm/llms/nvidia_nim/embed.py +++ b/litellm/llms/nvidia_nim/embed.py @@ -1,7 +1,7 @@ """ Nvidia NIM embeddings endpoint: https://docs.api.nvidia.com/nim/reference/nvidia-nv-embedqa-e5-v5-infer -This is OpenAI compatible +This is OpenAI compatible This file only contains param mapping logic diff --git a/litellm/llms/openai/chat/o_series_transformation.py b/litellm/llms/openai/chat/o_series_transformation.py index 02ae2cc9750..8db7ecf7b3a 100644 --- a/litellm/llms/openai/chat/o_series_transformation.py +++ b/litellm/llms/openai/chat/o_series_transformation.py @@ -1,14 +1,14 @@ """ -Support for o1/o3 model family +Support for o1/o3 model family https://platform.openai.com/docs/guides/reasoning Translations handled by LiteLLM: -- modalities: image => drop param (if user opts in to dropping param) -- role: system ==> translate to role 'user' -- streaming => faked by LiteLLM -- Tools, response_format => drop param (if user opts in to dropping param) -- Logprobs => drop param (if user opts in to dropping param) +- modalities: image => drop param (if user opts in to dropping param) +- role: system ==> translate to role 'user' +- streaming => faked by LiteLLM +- Tools, response_format => drop param (if user opts in to dropping param) +- Logprobs => drop param (if user opts in to dropping param) """ from typing import Any, Coroutine, List, Literal, Optional, Union, cast, overload diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index c13a976c1b9..381f215a13f 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -201,7 +201,7 @@ class BaseOpenAILLM: @staticmethod def get_openai_client_initialization_param_fields( - client_type: Literal["openai", "azure"] + client_type: Literal["openai", "azure"], ) -> Tuple[str, ...]: """Returns a tuple of fields that are used to initialize the OpenAI client""" if client_type == "openai": diff --git a/litellm/llms/openrouter/image_generation/transformation.py b/litellm/llms/openrouter/image_generation/transformation.py index a55716a5e50..9c2293eb3f1 100644 --- a/litellm/llms/openrouter/image_generation/transformation.py +++ b/litellm/llms/openrouter/image_generation/transformation.py @@ -49,7 +49,6 @@ from litellm.types.utils import ( ) from litellm.llms.openrouter.common_utils import OpenRouterException - if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj else: diff --git a/litellm/llms/reducto/__init__.py b/litellm/llms/reducto/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/reducto/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/reducto/common.py b/litellm/llms/reducto/common.py new file mode 100644 index 00000000000..4e7d96dbe87 --- /dev/null +++ b/litellm/llms/reducto/common.py @@ -0,0 +1,159 @@ +import base64 +import binascii +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Tuple + +from litellm.constants import request_timeout + +REDUCTO_API_BASE = "https://platform.reducto.ai" +REDUCTO_ID_PREFIX = "reducto://" + +if TYPE_CHECKING: + from litellm.llms.base_llm.ocr.transformation import OCRPage + + +def _normalize_api_base(api_base: Optional[str]) -> str: + return (api_base or REDUCTO_API_BASE).rstrip("/") + + +def _raise_bad_request(message: str, model: str) -> NoReturn: + import litellm + + raise litellm.BadRequestError( + message=message, + model=model, + llm_provider="reducto", + ) + + +def extract_file_id_or_bytes( + source_url: str, + model: str, +) -> Tuple[Optional[str], Optional[bytes], Optional[str]]: + if source_url.startswith(REDUCTO_ID_PREFIX): + return source_url, None, None + + if source_url.startswith("http://") or source_url.startswith("https://"): + _raise_bad_request( + "Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first.", + model=model, + ) + + if not source_url.startswith("data:"): + _raise_bad_request( + "Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing.", + model=model, + ) + + try: + header, encoded = source_url.split(",", 1) + except ValueError: + _raise_bad_request("Invalid Reducto data URI provided.", model=model) + + if ";base64" not in header: + _raise_bad_request( + "Reducto only supports base64-encoded data URIs.", model=model + ) + + mime = header.removeprefix("data:").split(";")[0] or "application/octet-stream" + try: + raw_bytes = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + _raise_bad_request("Invalid Reducto base64 payload provided.", model=model) + + return None, raw_bytes, mime + + +def _extract_file_id_from_upload_response(response: Any) -> str: + try: + payload = response.json() + except ValueError as exc: + raise ValueError( + "Reducto /upload returned a non-JSON 200 response: {}".format(response.text) + ) from exc + file_id = (payload or {}).get("file_id") if isinstance(payload, dict) else None + if not isinstance(file_id, str) or not file_id: + raise ValueError( + "Reducto /upload returned 200 without a file_id; got payload={}".format( + payload + ) + ) + return file_id + + +def upload_bytes_sync( + raw_bytes: bytes, + mime: Optional[str], + api_key: str, + api_base: Optional[str], +) -> str: + import litellm + + response = litellm.module_level_client.post( + url="{}{}".format(_normalize_api_base(api_base), "/upload"), + headers={"Authorization": f"Bearer {api_key}"}, + files={"file": ("document", raw_bytes, mime or "application/octet-stream")}, + timeout=request_timeout, + ) + response.raise_for_status() + return _extract_file_id_from_upload_response(response) + + +async def upload_bytes_async( + raw_bytes: bytes, + mime: Optional[str], + api_key: str, + api_base: Optional[str], +) -> str: + import litellm + + response = await litellm.module_level_aclient.post( + url="{}{}".format(_normalize_api_base(api_base), "/upload"), + headers={"Authorization": f"Bearer {api_key}"}, + files={"file": ("document", raw_bytes, mime or "application/octet-stream")}, + timeout=request_timeout, + ) + response.raise_for_status() + return _extract_file_id_from_upload_response(response) + + +def build_pages_from_reducto(result: Dict[str, Any]) -> List["OCRPage"]: + from litellm.llms.base_llm.ocr.transformation import OCRPage + + chunks = result.get("chunks", []) or [] + blocks_by_page: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + + for chunk in chunks: + for block in chunk.get("blocks", []) or []: + page_no = (block.get("bbox") or {}).get("page") + if page_no is None: + continue + try: + normalized_page = int(page_no) + except (TypeError, ValueError): + continue + blocks_by_page[normalized_page].append(block) + + if not blocks_by_page: + fallback_markdown = "\n\n".join( + chunk.get("content", "") for chunk in chunks if chunk.get("content") + ) + if fallback_markdown == "": + return [] + return [OCRPage(index=0, markdown=fallback_markdown)] + + pages: List["OCRPage"] = [] + for page_no, blocks in sorted(blocks_by_page.items()): + markdown = "\n\n".join( + block.get("content", "") for block in blocks if block.get("content") + ) + page_index = max(page_no - 1, 0) + page = OCRPage( + index=page_index, + markdown=markdown, + ) + # OCRPage accepts extra keys at runtime; assign blocks after construction + # so static typing does not reject provider-specific metadata. + setattr(page, "blocks", blocks) + pages.append(page) + return pages diff --git a/litellm/llms/reducto/ocr/__init__.py b/litellm/llms/reducto/ocr/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/reducto/ocr/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/reducto/ocr/transformation.py b/litellm/llms/reducto/ocr/transformation.py new file mode 100644 index 00000000000..cc338ecc484 --- /dev/null +++ b/litellm/llms/reducto/ocr/transformation.py @@ -0,0 +1,241 @@ +from typing import Any, Dict, Optional, Tuple + +import httpx + +from litellm.llms.base_llm.ocr.transformation import ( + BaseOCRConfig, + DocumentType, + OCRRequestData, + OCRResponse, + OCRUsageInfo, +) +from litellm.llms.reducto.common import ( + REDUCTO_API_BASE, + build_pages_from_reducto, + extract_file_id_or_bytes, + upload_bytes_async, + upload_bytes_sync, +) + + +class _BaseReductoOCRConfig(BaseOCRConfig): + def map_ocr_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + ) -> dict: + mapped_params = dict(optional_params) + supported_params = self.get_supported_ocr_params(model=model) + for param, value in non_default_params.items(): + if param in supported_params: + mapped_params[param] = value + return mapped_params + + def validate_environment( + self, + headers: Dict, + model: str, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + litellm_params: Optional[dict] = None, + **kwargs, + ) -> Dict: + from litellm.secret_managers.main import get_secret_str + + resolved_key = api_key or get_secret_str("REDUCTO_API_KEY") + if resolved_key is None: + raise ValueError( + "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()" + ) + + return { + "Authorization": f"Bearer {resolved_key}", + "Content-Type": "application/json", + **headers, + } + + def get_complete_url( + self, + api_base: Optional[str], + model: str, + optional_params: dict, + litellm_params: Optional[dict] = None, + **kwargs, + ) -> str: + return "{}/parse".format((api_base or REDUCTO_API_BASE).rstrip("/")) + + def _get_source_url(self, document: DocumentType, model: str) -> str: + source_url = document.get("document_url") or document.get("image_url") + if source_url is None: + raise ValueError( + "Reducto expected OCR preprocessing to produce document_url or image_url for model={}".format( + model + ) + ) + return source_url + + @staticmethod + def _resolve_credentials( + api_key: Optional[str], api_base: Optional[str] + ) -> Tuple[str, str]: + from litellm.secret_managers.main import get_secret_str + + resolved_key = api_key or get_secret_str("REDUCTO_API_KEY") + if resolved_key is None: + raise ValueError( + "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()" + ) + resolved_base = (api_base or REDUCTO_API_BASE).rstrip("/") + return resolved_key, resolved_base + + def _ensure_file_id_sync( + self, + model: str, + document: DocumentType, + api_key: Optional[str], + api_base: Optional[str], + ) -> str: + source_url = self._get_source_url(document=document, model=model) + file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model) + if file_id is not None: + return file_id + resolved_key, resolved_base = self._resolve_credentials(api_key, api_base) + return upload_bytes_sync( + raw_bytes=raw_bytes or b"", + mime=mime, + api_key=resolved_key, + api_base=resolved_base, + ) + + async def _ensure_file_id_async( + self, + model: str, + document: DocumentType, + api_key: Optional[str], + api_base: Optional[str], + ) -> str: + source_url = self._get_source_url(document=document, model=model) + file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model) + if file_id is not None: + return file_id + resolved_key, resolved_base = self._resolve_credentials(api_key, api_base) + return await upload_bytes_async( + raw_bytes=raw_bytes or b"", + mime=mime, + api_key=resolved_key, + api_base=resolved_base, + ) + + def transform_ocr_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: Any, + **kwargs, + ) -> OCRResponse: + response_json = raw_response.json() + result = response_json.get("result", response_json) or {} + usage = response_json.get("usage", {}) or {} + response = OCRResponse( + pages=build_pages_from_reducto(result), + model=model, + usage_info=OCRUsageInfo( + pages_processed=usage.get("num_pages"), + credits=usage.get("credits"), + ), + object="ocr", + ) + response._hidden_params["reducto_raw"] = response_json + return response + + +class ReductoParseV3Config(_BaseReductoOCRConfig): + def get_supported_ocr_params(self, model: str) -> list: + return ["formatting", "retrieval", "settings"] + + def transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: dict, + headers: dict, + **kwargs, + ) -> OCRRequestData: + file_id = self._ensure_file_id_sync( + model=model, + document=document, + api_key=kwargs.get("api_key"), + api_base=kwargs.get("api_base"), + ) + return OCRRequestData(data={"input": file_id, **optional_params}, files=None) + + async def async_transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: dict, + headers: dict, + **kwargs, + ) -> OCRRequestData: + file_id = await self._ensure_file_id_async( + model=model, + document=document, + api_key=kwargs.get("api_key"), + api_base=kwargs.get("api_base"), + ) + return OCRRequestData(data={"input": file_id, **optional_params}, files=None) + + +class ReductoParseLegacyConfig(_BaseReductoOCRConfig): + def get_supported_ocr_params(self, model: str) -> list: + return ["enhance"] + + def _build_legacy_body(self, file_id: str, optional_params: dict) -> Dict[str, Any]: + body: Dict[str, Any] = {"document_url": file_id} + enhance = optional_params.get("enhance") + if enhance is not None: + body["options"] = {"enhance": enhance} + return body + + def transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: dict, + headers: dict, + **kwargs, + ) -> OCRRequestData: + file_id = self._ensure_file_id_sync( + model=model, + document=document, + api_key=kwargs.get("api_key"), + api_base=kwargs.get("api_base"), + ) + return OCRRequestData( + data=self._build_legacy_body( + file_id=file_id, optional_params=optional_params + ), + files=None, + ) + + async def async_transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: dict, + headers: dict, + **kwargs, + ) -> OCRRequestData: + file_id = await self._ensure_file_id_async( + model=model, + document=document, + api_key=kwargs.get("api_key"), + api_base=kwargs.get("api_base"), + ) + return OCRRequestData( + data=self._build_legacy_body( + file_id=file_id, optional_params=optional_params + ), + files=None, + ) diff --git a/litellm/llms/sagemaker/common_utils.py b/litellm/llms/sagemaker/common_utils.py index 50c8ee4220e..6c15d642f8c 100644 --- a/litellm/llms/sagemaker/common_utils.py +++ b/litellm/llms/sagemaker/common_utils.py @@ -1,3 +1,4 @@ +import functools import json from typing import AsyncIterator, Iterator, List, Optional, Union @@ -22,14 +23,22 @@ def _load_sagemaker_response_stream_shape(): ) except Exception as e: verbose_logger.warning( - "litellm: could not pre-load sagemaker-runtime response stream shape " + "litellm: could not load sagemaker-runtime response stream shape " "— SageMaker event-stream decoding will be unavailable. Error: %s", e, ) return None -SAGEMAKER_RESPONSE_STREAM_SHAPE = _load_sagemaker_response_stream_shape() +@functools.lru_cache(maxsize=1) +def get_sagemaker_response_stream_shape(): + """ + Lazily load and cache the sagemaker-runtime stream shape for the process. + + Avoids importing botocore (and logging warnings) unless SageMaker event-stream + decoding is actually needed. + """ + return _load_sagemaker_response_stream_shape() class SagemakerError(BaseLLMException): @@ -207,7 +216,8 @@ class AWSEventStreamDecoder: verbose_logger.error(f"Final error parsing accumulated JSON: {e}") def _parse_message_from_event(self, event) -> Optional[str]: - if SAGEMAKER_RESPONSE_STREAM_SHAPE is None: + response_stream_shape = get_sagemaker_response_stream_shape() + if response_stream_shape is None: raise SagemakerError( status_code=500, message=( @@ -216,9 +226,7 @@ class AWSEventStreamDecoder: ), ) response_dict = event.to_response_dict() - parsed_response = self.parser.parse( - response_dict, SAGEMAKER_RESPONSE_STREAM_SHAPE - ) + parsed_response = self.parser.parse(response_dict, response_stream_shape) if response_dict["status_code"] != 200: raise ValueError(f"Bad response code, expected 200: {response_dict}") diff --git a/litellm/llms/sagemaker/completion/transformation.py b/litellm/llms/sagemaker/completion/transformation.py index 3e4e2460cdb..8fd32bc4460 100644 --- a/litellm/llms/sagemaker/completion/transformation.py +++ b/litellm/llms/sagemaker/completion/transformation.py @@ -1,7 +1,7 @@ """ Translate from OpenAI's `/v1/chat/completions` to Sagemaker's `/invoke` -In the Huggingface TGI format. +In the Huggingface TGI format. """ import json diff --git a/litellm/llms/sagemaker/embedding/transformation.py b/litellm/llms/sagemaker/embedding/transformation.py index 04430171187..09bdb9295e7 100644 --- a/litellm/llms/sagemaker/embedding/transformation.py +++ b/litellm/llms/sagemaker/embedding/transformation.py @@ -1,7 +1,7 @@ """ Translate from OpenAI's `/v1/embeddings` to Sagemaker's `/invoke` -In the Huggingface TGI format. +In the Huggingface TGI format. """ from typing import TYPE_CHECKING, Any, List, Optional, Union diff --git a/litellm/llms/sap/credentials.py b/litellm/llms/sap/credentials.py index 0ae351783e8..dd307ddf496 100644 --- a/litellm/llms/sap/credentials.py +++ b/litellm/llms/sap/credentials.py @@ -207,7 +207,7 @@ def resolve_resource_group(sources: List[Source]) -> Optional[str]: def _parse_service_key_once( - service_key: Optional[Union[str, dict]] + service_key: Optional[Union[str, dict]], ) -> Optional[Dict[str, Any]]: """ Pre-parse service_key if it's a string to avoid repeated JSON parsing. diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 3e590680a75..23bb6f44757 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -14,7 +14,6 @@ from ...openai_like.chat.transformation import OpenAIGPTConfig from ..utils import SnowflakeBaseConfig - if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj diff --git a/litellm/llms/together_ai/chat.py b/litellm/llms/together_ai/chat.py index 7efb12fc1b2..238849cc1ec 100644 --- a/litellm/llms/together_ai/chat.py +++ b/litellm/llms/together_ai/chat.py @@ -1,5 +1,5 @@ """ -Support for OpenAI's `/v1/chat/completions` endpoint. +Support for OpenAI's `/v1/chat/completions` endpoint. Calls done in OpenAI/openai.py as TogetherAI is openai-compatible. diff --git a/litellm/llms/together_ai/embed.py b/litellm/llms/together_ai/embed.py index 577df0256cc..6a39b94acfc 100644 --- a/litellm/llms/together_ai/embed.py +++ b/litellm/llms/together_ai/embed.py @@ -1,5 +1,5 @@ """ -Support for OpenAI's `/v1/embeddings` endpoint. +Support for OpenAI's `/v1/embeddings` endpoint. Calls done in OpenAI/openai.py as TogetherAI is openai-compatible. diff --git a/litellm/llms/together_ai/rerank/transformation.py b/litellm/llms/together_ai/rerank/transformation.py index 63b593dfe42..f4d642bd25a 100644 --- a/litellm/llms/together_ai/rerank/transformation.py +++ b/litellm/llms/together_ai/rerank/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from Cohere's /v1/rerank format to Together AI's `/v1/rerank` format. +Transformation logic from Cohere's /v1/rerank format to Together AI's `/v1/rerank` format. Why separate file? Make it easy to see how transformation works """ diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 950edbeb478..f73eb220cc6 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic for context caching. +Transformation logic for context caching. Why separate file? Make it easy to see how transformation works """ @@ -19,7 +19,7 @@ from ..gemini.transformation import ( def get_first_continuous_block_idx( - filtered_messages: List[Tuple[int, AllMessageValues]] # (idx, message) + filtered_messages: List[Tuple[int, AllMessageValues]], # (idx, message) ) -> int: """ Find the array index that ends the first continuous sequence of message blocks. @@ -174,7 +174,9 @@ def transform_openai_messages_to_gemini_context_caching( ) transformed_messages = _gemini_convert_messages_with_history( - messages=new_messages, model=model + messages=new_messages, + model=model, + custom_llm_provider=custom_llm_provider, ) model_name = "models/{}".format(model) diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py index ac0f07b8e0b..3f945adca0d 100644 --- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py +++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py @@ -41,7 +41,7 @@ class ContextCachingEndpoints(VertexBase): """ def __init__(self) -> None: - pass + super().__init__() def _get_token_and_url_context_caching( self, diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index f56992a2502..4f5846cc5b6 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -682,6 +682,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 messages: List[AllMessageValues], model: Optional[str] = None, litellm_params: Optional[dict] = None, + custom_llm_provider: Optional[str] = None, ) -> List[ContentType]: """ Converts given messages from OpenAI format to Gemini format @@ -983,7 +984,9 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 or assistant_msg.get("function_call") is not None ): # support assistant tool invoke conversion gemini_tool_call_parts = convert_to_gemini_tool_call_invoke( - assistant_msg, model=model + assistant_msg, + model=model, + custom_llm_provider=custom_llm_provider, ) ## check if gemini_tool_call already exists in assistant_content for gemini_tool_call_part in gemini_tool_call_parts: @@ -1042,7 +1045,10 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 and messages[msg_i]["role"] in tool_call_message_roles ): _part = convert_to_gemini_tool_call_result( - messages[msg_i], last_message_with_tool_calls # type: ignore + messages[msg_i], # type: ignore + last_message_with_tool_calls, # type: ignore + model=model, + custom_llm_provider=custom_llm_provider, ) msg_i += 1 # Handle both single part and list of parts (for Computer Use with images) @@ -1067,16 +1073,14 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 contents.append(ContentType(role="user", parts=tool_call_responses)) if len(contents) == 0: - verbose_logger.warning( - """ + verbose_logger.warning(""" No contents in messages. Contents are required. See https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent#request-body. If the original request did not comply to OpenAI API requirements it should have failed by now, but LiteLLM does not check for missing messages. Setting an empty content to prevent an 400 error. Relevant Issue - https://github.com/BerriAI/litellm/issues/9733 - """ - ) + """) contents.append(ContentType(role="user", parts=[PartType(text=" ")])) return contents except Exception as e: diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 49c1c335467..189ac7a7f6a 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -280,6 +280,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): - gemini-3-pro-preview - gemini-3-flash - gemini-3-flash-preview (Gemini 3 Flash) + - gemini-3.1-pro-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview + - gemini-3.5-flash - Any future Gemini 3.x models """ # Check for Gemini 3 models @@ -287,6 +289,20 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): return True return False + @staticmethod + def _forward_gemini_function_call_id( + model: str, custom_llm_provider: Optional[str] = None + ) -> bool: + """ + Whether to include `id` on function_call / function_response parts. + + Gemini 3+ on Google AI Studio accepts (and returns) `id` for strict + tool-call matching. Vertex AI rejects the field with HTTP 400. + """ + if custom_llm_provider != "gemini": + return False + return VertexGeminiConfig._is_gemini_3_or_newer(model) + def _supports_penalty_parameters(self, model: str) -> bool: # Gemini 3 models do not support penalty parameters if VertexGeminiConfig._is_gemini_3_or_newer(model): @@ -300,6 +316,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): supported_params = [ "temperature", "top_p", + "top_k", "max_tokens", "max_completion_tokens", "stream", @@ -363,6 +380,66 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): """ return Tools(googleSearch={}) + @staticmethod + def _search_tool_keys() -> set: + return { + VertexToolName.GOOGLE_SEARCH.value, + VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value, + VertexToolName.ENTERPRISE_WEB_SEARCH.value, + VertexToolName.URL_CONTEXT.value, + "google_search", + "google_search_retrieval", + "enterprise_web_search", + "urlContext", + } + + @classmethod + def _drop_search_tools_mixed_with_functions(cls, optional_params: dict) -> None: + """ + Drop search tools from optional_params when mixed with function declarations + and include_server_side_tool_invocations is not enabled. + + Runs after map_openai_params merges tools and web_search_options so both + code paths (single _map_function call vs split tools + web_search_options) + get the same conflict resolution. + """ + if optional_params.get("include_server_side_tool_invocations"): + return + + tools = optional_params.get("tools") + if not isinstance(tools, list) or not tools: + return + + search_tool_keys = cls._search_tool_keys() + has_function_declarations = any( + isinstance(tool, dict) and tool.get("function_declarations") + for tool in tools + ) + if not has_function_declarations: + return + + has_search_tools = any( + isinstance(tool, dict) and any(key in tool for key in search_tool_keys) + for tool in tools + ) + if not has_search_tools: + return + + verbose_logger.warning( + "Vertex AI does not support mixing function declarations with " + "search tools (googleSearch, enterpriseWebSearch, urlContext, " + "googleSearchRetrieval) in the same request. Dropping search " + "tools and keeping function declarations. To use search tools, " + "send a request without function calling tools." + ) + optional_params["tools"] = [ + tool + for tool in tools + if not ( + isinstance(tool, dict) and any(key in tool for key in search_tool_keys) + ) + ] + def _map_service_tier_param(self, value: str, optional_params: dict) -> None: """ Map OpenAI service_tier (string) to Gemini serviceTier. @@ -884,9 +961,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): GeminiThinkingConfig with thinkingLevel and includeThoughts """ # Check if this is gemini-3-flash which supports MINIMAL thinking level - # Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview, etc. + # Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview, + # gemini-3.5-flash, and any future 3.x-flash variants. is_gemini3flash = model and ( - "gemini-3-flash" in model.lower() or "gemini-3.1-flash" in model.lower() + "flash" in model.lower() and "gemini-3" in model.lower() ) is_gemini31pro = model and ("gemini-3.1-pro-preview" in model.lower()) if reasoning_effort == "minimal": @@ -982,8 +1060,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): # Follow provider defaults unless explicitly opted into legacy behavior. if litellm.enable_gemini_default_thinking_level_low is True: is_gemini3flash = ( - "gemini-3-flash-preview" in model.lower() - or "gemini-3-flash" in model.lower() + "gemini-3" in model.lower() and "flash" in model.lower() ) params["thinkingLevel"] = ( "minimal" if is_gemini3flash else "low" @@ -1077,6 +1154,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): model: str, drop_params: bool, ) -> Dict: + gemini_sampling_params_warned: bool = False for param, value in non_default_params.items(): if param == "temperature": if VertexGeminiConfig._is_gemini_3_or_newer(model): @@ -1086,9 +1164,41 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "can cause infinite loops, degraded reasoning performance, and failure on complex tasks. " "Strongly recommended to use temperature = 1.0 (default)." ) + if not gemini_sampling_params_warned: + verbose_logger.warning( + "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to " + f"function for Gemini 3+ ({model}) but are planned for removal in a " + "future release. Move sampling guidance into the `system` " + "instructions instead." + ) + gemini_sampling_params_warned = True optional_params["temperature"] = value elif param == "top_p": + if ( + VertexGeminiConfig._is_gemini_3_or_newer(model) + and not gemini_sampling_params_warned + ): + verbose_logger.warning( + "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to " + f"function for Gemini 3+ ({model}) but are planned for removal in a " + "future release. Move sampling guidance into the `system` " + "instructions instead." + ) + gemini_sampling_params_warned = True optional_params["top_p"] = value + elif param == "top_k": + if ( + VertexGeminiConfig._is_gemini_3_or_newer(model) + and not gemini_sampling_params_warned + ): + verbose_logger.warning( + "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to " + f"function for Gemini 3+ ({model}) but are planned for removal in a " + "future release. Move sampling guidance into the `system` " + "instructions instead." + ) + gemini_sampling_params_warned = True + optional_params["top_k"] = value elif ( param == "stream" and value is True ): # sending stream = False, can cause it to get passed unchecked and raise issues @@ -1139,11 +1249,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): if _tool_choice_value is not None: optional_params["tool_choice"] = _tool_choice_value elif param == "parallel_tool_calls": - if value is False and not ( - drop_params or litellm.drop_params - ): # if drop params is True, then we should just ignore this - self.validate_parallel_tool_calls(value, non_default_params) - else: + tools_list = non_default_params.get( + "tools", non_default_params.get("functions") + ) + num_tools = len(tools_list) if isinstance(tools_list, list) else 0 + # Gemini does not support parallel_tool_calls=False with multiple + # tools. Drop the param instead of failing — Responses API clients + # often send parallel_tool_calls=false by default. + if not (value is False and num_tools > 1): optional_params["parallel_tool_calls"] = value elif param == "seed": optional_params["seed"] = value @@ -1216,6 +1329,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): if "temperature" not in optional_params: optional_params["temperature"] = 1.0 + self._drop_search_tools_mixed_with_functions(optional_params) + return optional_params def get_mapped_special_auth_params(self) -> dict: @@ -1588,6 +1703,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): } # Extract thought signature if present thought_signature = part.get("thoughtSignature") + # Gemini 3.5+ returns a stable `id` per function call to enable + # strict response matching. Preserve it as the OpenAI + # tool_call_id so it can be echoed back unchanged. + gemini_call_id = part["functionCall"].get("id") if is_function_call is True: function_dict: Dict[str, Any] = dict(_function_chunk) @@ -1605,6 +1724,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "function": _function_chunk, "index": cumulative_tool_call_idx, } + # Gemini 3.5+ returns a stable native `id`; prefer it over + # the synthetic call_ so the same value can be echoed + # back on the matching `functionResponse`. + if gemini_call_id: + _tool_response_chunk["id"] = gemini_call_id # Embed thought signature in ID for OpenAI client compatibility if thought_signature: _tool_response_chunk["provider_specific_fields"] = { # type: ignore @@ -2539,7 +2663,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): litellm_params: Optional[dict] = None, ) -> List[ContentType]: return _gemini_convert_messages_with_history( - messages=messages, model=model, litellm_params=litellm_params + messages=messages, + model=model, + litellm_params=litellm_params, + custom_llm_provider="vertex_ai", ) def get_error_class( diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py index e1b365c9f42..ba6e6f0c056 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic from OpenAI /v1/embeddings format to Google AI Studio /batchEmbedContents format. +Transformation logic from OpenAI /v1/embeddings format to Google AI Studio /batchEmbedContents format. Why separate file? Make it easy to see how transformation works """ diff --git a/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py b/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py index 9d9015c2b91..b835ad7d8fa 100644 --- a/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py +++ b/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py @@ -139,7 +139,7 @@ class VertexTextToSpeechAPI(VertexLLM): ########## End of logging ############ ####### Send the request ################### if _is_async is True: - return self.async_audio_speech( # type:ignore + return self.async_audio_speech( # type: ignore logging_obj=logging_obj, url=url, headers=headers, request=request ) sync_handler = _get_httpx_client() diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py index eb67e3aa828..13aa2a5350e 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py @@ -45,7 +45,7 @@ class PartnerModelPrefixes(str, Enum): class VertexAIPartnerModels(VertexBase): def __init__(self) -> None: - pass + super().__init__() @staticmethod def is_vertex_partner_model(model: str): @@ -116,9 +116,6 @@ class VertexAIPartnerModels(VertexBase): CodestralTextCompletion, ) from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexLLM, - ) except Exception as e: raise VertexAIError( status_code=400, @@ -133,9 +130,7 @@ class VertexAIPartnerModels(VertexBase): message="""Upgrade vertex ai. Run `pip install "google-cloud-aiplatform>=1.38"`""", ) try: - vertex_httpx_logic = VertexLLM() - - access_token, project_id = vertex_httpx_logic._ensure_access_token( + access_token, project_id = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, custom_llm_provider="vertex_ai", diff --git a/litellm/llms/vertex_ai/vertex_gemma_models/main.py b/litellm/llms/vertex_ai/vertex_gemma_models/main.py index 82cfe6de984..b6bf2f73b72 100644 --- a/litellm/llms/vertex_ai/vertex_gemma_models/main.py +++ b/litellm/llms/vertex_ai/vertex_gemma_models/main.py @@ -31,7 +31,7 @@ from ..vertex_llm_base import VertexBase class VertexAIGemmaModels(VertexBase): def __init__(self) -> None: - pass + super().__init__() def completion( self, @@ -62,9 +62,6 @@ class VertexAIGemmaModels(VertexBase): try: import vertexai - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexLLM, - ) from litellm.llms.vertex_ai.vertex_gemma_models.transformation import ( VertexGemmaConfig, ) @@ -83,9 +80,8 @@ class VertexAIGemmaModels(VertexBase): ) try: model = get_vertex_base_model_name(model=model) - vertex_httpx_logic = VertexLLM() - access_token, project_id = vertex_httpx_logic._ensure_access_token( + access_token, project_id = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, custom_llm_provider="vertex_ai", diff --git a/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py b/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py index 6c6446958bc..35cd54d65f6 100644 --- a/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py +++ b/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py @@ -91,6 +91,10 @@ class VertexGemmaConfig(OpenAIGPTConfig): "stream", None ) # Streaming not supported, will be faked client-side openai_request.pop("stream_options", None) # Stream options not supported + # Vertex Gemma's chatCompletions wrapper does not understand + # `context_management` (an Anthropic/Responses API concept). Strip it + # so the upstream endpoint does not 400 on the unknown field. + openai_request.pop("context_management", None) # Wrap in Vertex Gemma format return { diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 6f687dae7e8..990063bb9fb 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -4,8 +4,10 @@ Base Vertex, Google AI Studio LLM Class Handles Authentication and generating request urls for Vertex AI and Google AI Studio """ +import asyncio import json import os +import threading from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple import litellm @@ -30,6 +32,7 @@ GOOGLE_IMPORT_ERROR_MESSAGE = ( if TYPE_CHECKING: from google.auth.credentials import Credentials as GoogleCredentialsObject + from google.auth.credentials import TokenState else: GoogleCredentialsObject = Any @@ -42,10 +45,28 @@ class VertexBase: self._credentials: Optional[GoogleCredentialsObject] = None self._credentials_project_mapping: Dict[ Tuple[Optional[VERTEX_CREDENTIALS_TYPES], Optional[str]], - Tuple[GoogleCredentialsObject, str], + Tuple[GoogleCredentialsObject, Optional[str]], ] = {} self.project_id: Optional[str] = None self.async_handler: Optional[AsyncHTTPHandler] = None + # Per-credential-key asyncio.Lock for single-flight async refresh. + # Prevents thundering herd when token expires under high concurrency. + # Uses a regular dict (not WeakValueDictionary) so the lock identity is + # stable across concurrent callers — a weak reference can be GC'd + # between two coroutines arriving at the lock, breaking single-flight. + # An explicit refcount tracks the number of coroutines currently using + # each lock; the entry is pruned when the count reaches zero, so the + # dict stays bounded even in long-running high-cardinality deployments + # without depending on any private asyncio internals. + self._async_refresh_locks: Dict[tuple, asyncio.Lock] = {} + self._async_refresh_lock_refcounts: Dict[tuple, int] = {} + # Tracks in-flight background refresh tasks to avoid duplicate refreshes. + self._background_refresh_tasks: Dict[tuple, asyncio.Task] = {} + # Protects the sync get_access_token refresh path. + # Use RLock so that the reauthentication retry path (which calls + # back into get_access_token while still holding the lock) can + # re-acquire it without deadlocking the current thread. + self._sync_refresh_lock = threading.RLock() def get_vertex_region(self, vertex_region: Optional[str], model: str) -> str: import litellm @@ -77,7 +98,9 @@ class VertexBase: return vertex_region or "us-central1" def load_auth( - self, credentials: Optional[VERTEX_CREDENTIALS_TYPES], project_id: Optional[str] + self, + credentials: Optional[VERTEX_CREDENTIALS_TYPES], + project_id: Optional[str], ) -> Tuple[Any, str]: if credentials is not None: if isinstance(credentials, str): @@ -343,7 +366,241 @@ class VertexBase: except ImportError: raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE) - credentials.refresh(Request()) + # Serialize all refreshes on this VertexBase across threads. + # ``credentials.refresh()`` is not safe to call concurrently on the + # same credentials object, and this method is invoked from three + # places that can run on different threads: + # - sync ``get_access_token`` (already holds ``_sync_refresh_lock``) + # - the async slow path (via ``asyncify`` in a worker thread) + # - the background proactive refresh task (via ``asyncify``) + # ``_sync_refresh_lock`` is an ``RLock`` so reentrant acquisition + # from the sync path is safe. + with self._sync_refresh_lock: + credentials.refresh(Request()) + + def _acquire_async_refresh_lock(self, credential_cache_key: tuple) -> asyncio.Lock: + """Increment the refcount and return the lock for ``credential_cache_key``. + + Every call must be paired with ``_release_async_refresh_lock`` once the + caller is done with the lock so the entry can be pruned when no other + coroutine is holding or waiting on it. + """ + lock = self._async_refresh_locks.setdefault( + credential_cache_key, asyncio.Lock() + ) + self._async_refresh_lock_refcounts[credential_cache_key] = ( + self._async_refresh_lock_refcounts.get(credential_cache_key, 0) + 1 + ) + return lock + + def _release_async_refresh_lock( + self, credential_cache_key: tuple, lock: asyncio.Lock + ) -> None: + """Decrement the refcount and drop the lock entry when it reaches zero. + + Must be called only after the caller has released ``lock`` (i.e. once + the surrounding ``async with`` has exited). asyncio is cooperative, so + the decrement-then-pop sequence below runs atomically with respect to + other coroutines. + """ + remaining = self._async_refresh_lock_refcounts.get(credential_cache_key, 0) - 1 + if remaining > 0: + self._async_refresh_lock_refcounts[credential_cache_key] = remaining + return + self._async_refresh_lock_refcounts.pop(credential_cache_key, None) + if self._async_refresh_locks.get(credential_cache_key) is lock: + self._async_refresh_locks.pop(credential_cache_key, None) + + def _try_get_cached_token( + self, + credential_cache_key: tuple, + project_id: Optional[str], + ) -> Optional[Tuple[str, str]]: + """ + Look up cached credentials and return (token, project_id) if the token + is FRESH. Returns None if not cached or not fresh. + """ + from google.auth.credentials import TokenState + + creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key) + if ( + creds is not None + and self._get_token_state(creds) == TokenState.FRESH + and creds.token is not None + and isinstance(creds.token, str) + ): + resolved_project = project_id or cached_project_id + if resolved_project: + return creds.token, resolved_project + return None + + def _try_get_usable_cached_token( + self, + credential_cache_key: tuple, + project_id: Optional[str], + ) -> Optional[Tuple[str, str, "TokenState", Any, Optional[str]]]: + """ + Look up cached credentials and return usable token info for FRESH or + STALE tokens (both are still valid for outbound requests). STALE + tokens are returned along with their state and the underlying + credentials object so the caller can schedule a background refresh + without holding the per-key async lock. + """ + from google.auth.credentials import TokenState + + creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key) + if creds is None: + return None + token_state = self._get_token_state(creds) + if token_state not in (TokenState.FRESH, TokenState.STALE): + return None + if creds.token is None or not isinstance(creds.token, str): + return None + resolved_project = project_id or cached_project_id + if not resolved_project: + return None + return creds.token, resolved_project, token_state, creds, cached_project_id + + def _unpack_cached_credentials( + self, credential_cache_key: tuple + ) -> Tuple[Any, Optional[str]]: + """ + Return (credentials, project_id) from the cache, or (None, None) if + not cached. Handles both tuple and legacy cache formats. + """ + if credential_cache_key not in self._credentials_project_mapping: + return None, None + cached_entry = self._credentials_project_mapping[credential_cache_key] + if isinstance(cached_entry, tuple): + return cached_entry + return cached_entry, cached_entry.quota_project_id or getattr( + cached_entry, "project_id", None + ) + + def _get_token_state(self, credentials: Any) -> "TokenState": + """ + Return the token state using google-auth's TokenState enum. + + Falls back to expired/valid checks if token_state is unavailable + (e.g. older google-auth versions or mock objects in tests). + """ + from google.auth.credentials import TokenState as _TokenState + + token_state = getattr(credentials, "token_state", None) + if isinstance(token_state, _TokenState): + return token_state + # Fallback for credentials without a real token_state (e.g. mocks) + if getattr(credentials, "expired", True): + return _TokenState.INVALID + if getattr(credentials, "valid", False): + return _TokenState.FRESH + return _TokenState.INVALID + + async def _load_and_cache_credentials( + self, + credentials: Optional[VERTEX_CREDENTIALS_TYPES], + project_id: Optional[str], + credential_cache_key: tuple, + ) -> Tuple[Any, Optional[str]]: + """Load credentials via load_auth (in thread) and cache the result.""" + try: + _credentials, credential_project_id = await asyncify(self.load_auth)( + credentials=credentials, + project_id=project_id, + ) + except Exception as e: + verbose_logger.exception("Failed to load vertex credentials: %s", str(e)) + raise + if _credentials is None: + raise ValueError("Could not resolve credentials") + self._credentials_project_mapping[credential_cache_key] = ( + _credentials, + credential_project_id, + ) + return _credentials, credential_project_id + + async def _background_refresh_credentials( + self, + credentials: Any, + credential_cache_key: tuple, + credential_project_id: Optional[str], + ) -> None: + """ + Refresh credentials in the background without blocking the calling request. + + Called when the token is still valid but nearing expiry (proactive refresh). + Errors are logged but not raised — the current token is still usable. + """ + try: + verbose_logger.debug("Background proactive credential refresh") + await asyncify(self.refresh_auth)(credentials) + # Only update the cache if it still points at the credentials + # object we just refreshed. The per-key async lock is not held + # here, so a concurrent INVALID path may have already replaced + # this entry (e.g. via _handle_reauthentication_async, which + # creates a fresh credentials object). In that case our write + # would clobber the newer entry with a stale reference. + cached_creds, _ = self._unpack_cached_credentials(credential_cache_key) + if cached_creds is credentials: + self._credentials_project_mapping[credential_cache_key] = ( + credentials, + credential_project_id, + ) + except Exception: + verbose_logger.debug( + "Background credential refresh failed, will retry on next request", + exc_info=True, + ) + + async def _await_in_flight_background_refresh( + self, credential_cache_key: tuple + ) -> None: + """Wait for an in-flight background refresh to finish, if any. + + google-auth's ``Credentials.refresh()`` is not safe to invoke + concurrently on the same credentials object. Coroutines that need a + blocking refresh must first drain any background refresh that was + scheduled while a previous STALE token was being served. + """ + existing_task = self._background_refresh_tasks.get(credential_cache_key) + if existing_task is None or existing_task.done(): + return + try: + await existing_task + except Exception: + # Background refresh failures are already logged inside + # _background_refresh_credentials; the caller will fall through + # to its own blocking refresh. + pass + + def _schedule_background_refresh( + self, + credentials: Any, + credential_cache_key: tuple, + credential_project_id: Optional[str], + ) -> None: + """Kick off a single background refresh for ``credential_cache_key``. + + Skips scheduling if a refresh is already in flight. The done-callback + guards against removing a newer task that has replaced this one in the + tracking dict (done_callbacks are scheduled via ``call_soon``). + """ + existing = self._background_refresh_tasks.get(credential_cache_key) + if existing is not None and not existing.done(): + return + self._background_refresh_tasks.pop(credential_cache_key, None) + task = asyncio.create_task( + self._background_refresh_credentials( + credentials, credential_cache_key, credential_project_id + ) + ) + + def _drop_background_refresh_task(_fut: asyncio.Future[Any]) -> None: + if self._background_refresh_tasks.get(credential_cache_key) is _fut: + self._background_refresh_tasks.pop(credential_cache_key, None) + + task.add_done_callback(_drop_background_refresh_task) + self._background_refresh_tasks[credential_cache_key] = task def _ensure_access_token( self, @@ -563,6 +820,65 @@ class VertexBase: # Re-raise the original error for better context raise error + async def _handle_reauthentication_async( + self, + credentials: Optional[VERTEX_CREDENTIALS_TYPES], + project_id: Optional[str], + credential_cache_key: Tuple, + error: Exception, + ) -> Tuple[str, str]: + """ + Async reauthentication retry that stays within the per-key async lock. + """ + verbose_logger.debug( + f"Handling async reauthentication for project_id: {project_id}. " + f"Clearing cache and retrying once." + ) + + self._credentials_project_mapping.pop(credential_cache_key, None) + + try: + _credentials, credential_project_id = ( + await self._load_and_cache_credentials( + credentials=credentials, + project_id=project_id, + credential_cache_key=credential_cache_key, + ) + ) + if project_id is None and isinstance(credential_project_id, str): + project_id = credential_project_id + cache_credentials = ( + json.dumps(credentials) + if isinstance(credentials, dict) + else credentials + ) + resolved_cache_key = (cache_credentials, project_id) + # Always overwrite — any pre-existing entry at the resolved key + # references the OLD credentials object we just replaced, and + # leaving it would force the next request to do a redundant + # refresh/reauth before realizing the cached creds are stale. + self._credentials_project_mapping[resolved_cache_key] = ( + _credentials, + credential_project_id, + ) + + if _credentials.token is None or not isinstance(_credentials.token, str): + raise ValueError( + "Could not resolve credentials token. Got None or non-string token (type={})".format( + type(_credentials.token).__name__ + ) + ) + if project_id is None: + raise ValueError("Could not resolve project_id") + + return _credentials.token, project_id + except Exception as retry_error: + verbose_logger.error( + f"Async reauthentication retry failed for project_id: {project_id}. " + f"Original error: {str(error)}. Retry error: {str(retry_error)}" + ) + raise error + def get_access_token( self, credentials: Optional[VERTEX_CREDENTIALS_TYPES], @@ -646,7 +962,7 @@ class VertexBase: ) ## VALIDATE CREDENTIALS - verbose_logger.debug(f"Validating credentials for project_id: {project_id}") + verbose_logger.debug("Validating credentials") if ( project_id is None and credential_project_id is not None @@ -666,26 +982,27 @@ class VertexBase: raise ValueError("Credentials are None after loading") if _credentials.expired: - try: - verbose_logger.debug( - f"Credentials expired, refreshing for project_id: {project_id}" - ) - self.refresh_auth(_credentials) - self._credentials_project_mapping[credential_cache_key] = ( - _credentials, - credential_project_id, - ) - except Exception as e: - # if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login` - # in this case, we should try to reload the credentials by clearing the cache and retrying - if "Reauthentication is needed" in str(e) and not _retry_reauth: - return self._handle_reauthentication( - credentials=credentials, - project_id=project_id, - credential_cache_key=credential_cache_key, - error=e, - ) - raise e + with self._sync_refresh_lock: + # Double-check after acquiring lock + if _credentials.expired: + try: + verbose_logger.debug("Credentials expired, refreshing") + self.refresh_auth(_credentials) + self._credentials_project_mapping[credential_cache_key] = ( + _credentials, + credential_project_id, + ) + except Exception as e: + # if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login` + # in this case, we should try to reload the credentials by clearing the cache and retrying + if "Reauthentication is needed" in str(e) and not _retry_reauth: + return self._handle_reauthentication( + credentials=credentials, + project_id=project_id, + credential_cache_key=credential_cache_key, + error=e, + ) + raise e ## VALIDATION STEP if _credentials.token is None or not isinstance(_credentials.token, str): @@ -700,6 +1017,149 @@ class VertexBase: return _credentials.token, project_id + async def get_access_token_async( + self, + credentials: Optional[VERTEX_CREDENTIALS_TYPES], + project_id: Optional[str], + ) -> Tuple[str, str]: + """ + Async version of get_access_token with single-flight refresh coordination. + + Prevents thundering herd: when credentials expire under high concurrency, + only one coroutine refreshes while others wait on the lock. Uses native + async refresh for service_account and authorized_user credentials. + """ + from google.auth.credentials import TokenState + + cache_credentials = ( + json.dumps(credentials) if isinstance(credentials, dict) else credentials + ) + credential_cache_key = (cache_credentials, project_id) + + # === FAST PATH (no lock) === + # If credentials are FRESH or STALE, return immediately without + # touching the per-key async lock. STALE tokens are still usable; + # we kick off a deduplicated background refresh so subsequent + # requests get a fresh token, but we must not serialize concurrent + # callers on the lock just to schedule that refresh. + usable = self._try_get_usable_cached_token(credential_cache_key, project_id) + if usable is not None: + cached_token, resolved_project, token_state, creds, cached_project_id = ( + usable + ) + if token_state == TokenState.STALE: + self._schedule_background_refresh( + creds, credential_cache_key, cached_project_id + ) + return cached_token, resolved_project + + # === SLOW PATH (per-key lock) === + lock = self._acquire_async_refresh_lock(credential_cache_key) + try: + async with lock: + # Double-check after acquiring lock — another coroutine may have refreshed. + cached = self._try_get_cached_token(credential_cache_key, project_id) + if cached is not None: + return cached + + _credentials, credential_project_id = self._unpack_cached_credentials( + credential_cache_key + ) + + # Load credentials if not cached + if _credentials is None: + _credentials, credential_project_id = ( + await self._load_and_cache_credentials( + credentials, project_id, credential_cache_key + ) + ) + + # Resolve project_id from credentials if not provided + if project_id is None and isinstance(credential_project_id, str): + project_id = credential_project_id + resolved_cache_key = (cache_credentials, project_id) + # Always overwrite — a pre-existing entry at the resolved + # key may reference stale credentials (e.g. from before a + # reauth that only repopulated the unresolved key), which + # would force the next request through an unnecessary + # refresh/reauth cycle. + self._credentials_project_mapping[resolved_cache_key] = ( + _credentials, + credential_project_id, + ) + + # Use google-auth's token_state to decide refresh strategy: + # - STALE: token is usable but within REFRESH_THRESHOLD (3:45) of + # expiry — return it immediately and refresh in the background. + # - INVALID: token is expired or missing — must block on refresh. + token_state = self._get_token_state(_credentials) + + if token_state == TokenState.STALE: + if project_id is None: + raise ValueError("Could not resolve project_id") + current_token = _credentials.token + if current_token is None or not isinstance(current_token, str): + # Token is malformed despite STALE state — block on a full + # refresh using the same path as INVALID credentials. + token_state = TokenState.INVALID + else: + self._schedule_background_refresh( + _credentials, + credential_cache_key, + credential_project_id, + ) + return current_token, project_id + + if token_state == TokenState.INVALID: + # Drain any in-flight background refresh before invoking + # refresh_auth ourselves; google-auth's + # Credentials.refresh() is not safe to call concurrently + # on the same credentials object, and the background task + # runs outside this lock. + await self._await_in_flight_background_refresh(credential_cache_key) + cached = self._try_get_cached_token( + credential_cache_key, project_id + ) + if cached is not None: + return cached + + # Token is expired or missing — must block until refresh completes. + try: + verbose_logger.debug("Credentials expired, refreshing") + await asyncify(self.refresh_auth)(_credentials) + self._credentials_project_mapping[credential_cache_key] = ( + _credentials, + credential_project_id, + ) + except Exception as e: + if "Reauthentication is needed" in str(e): + verbose_logger.debug( + "Reauthentication needed, clearing cache and retrying" + ) + return await self._handle_reauthentication_async( + credentials=credentials, + project_id=project_id, + credential_cache_key=credential_cache_key, + error=e, + ) + raise + + # Final validation + if _credentials.token is None or not isinstance( + _credentials.token, str + ): + raise ValueError( + "Could not resolve credentials token. Got None or non-string token (type={})".format( + type(_credentials.token).__name__ + ) + ) + if project_id is None: + raise ValueError("Could not resolve project_id") + + return _credentials.token, project_id + finally: + self._release_async_refresh_lock(credential_cache_key, lock) + async def _ensure_access_token_async( self, credentials: Optional[VERTEX_CREDENTIALS_TYPES], @@ -714,13 +1174,10 @@ class VertexBase: if custom_llm_provider == "gemini": return "", "" else: - try: - return await asyncify(self.get_access_token)( - credentials=credentials, - project_id=project_id, - ) - except Exception as e: - raise e + return await self.get_access_token_async( + credentials=credentials, + project_id=project_id, + ) def set_headers( self, auth_header: Optional[str], extra_headers: Optional[dict] diff --git a/litellm/llms/vertex_ai/vertex_model_garden/main.py b/litellm/llms/vertex_ai/vertex_model_garden/main.py index 7240d9dce57..732d5f90dc2 100644 --- a/litellm/llms/vertex_ai/vertex_model_garden/main.py +++ b/litellm/llms/vertex_ai/vertex_model_garden/main.py @@ -57,7 +57,7 @@ def create_vertex_url( class VertexAIModelGardenModels(VertexBase): def __init__(self) -> None: - pass + super().__init__() def completion( self, @@ -89,9 +89,6 @@ class VertexAIModelGardenModels(VertexBase): import vertexai from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexLLM, - ) except Exception as e: raise VertexAIError( status_code=400, @@ -107,9 +104,8 @@ class VertexAIModelGardenModels(VertexBase): ) try: model = get_vertex_base_model_name(model=model) - vertex_httpx_logic = VertexLLM() - access_token, project_id = vertex_httpx_logic._ensure_access_token( + access_token, project_id = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, custom_llm_provider="vertex_ai", diff --git a/litellm/llms/vllm/completion/transformation.py b/litellm/llms/vllm/completion/transformation.py index ec4c07e95d8..e03b07f9897 100644 --- a/litellm/llms/vllm/completion/transformation.py +++ b/litellm/llms/vllm/completion/transformation.py @@ -1,5 +1,5 @@ """ -Translates from OpenAI's `/v1/chat/completions` to the VLLM sdk `llm.generate`. +Translates from OpenAI's `/v1/chat/completions` to the VLLM sdk `llm.generate`. NOT RECOMMENDED FOR PRODUCTION USE. Use `hosted_vllm/` instead. """ diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index 40328062e09..1f5ca99f47d 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -1,6 +1,6 @@ """ -This module is used to transform the request and response for the Voyage contextualized embeddings API. -This would be used for all the contextualized embeddings models in Voyage. +This module is used to transform the request and response for the Voyage contextualized embeddings API. +This would be used for all the contextualized embeddings models in Voyage. """ from typing import List, Optional, Union diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index 6300868a641..7325c0596a6 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union import httpx @@ -26,6 +26,7 @@ from ...openai.chat.gpt_transformation import ( class XAIChatConfig(OpenAIGPTConfig): + @property def custom_llm_provider(self) -> Optional[str]: return "xai" @@ -225,21 +226,57 @@ class XAIChatConfig(OpenAIGPTConfig): verbose_logger.debug(f"Error extracting X.AI web search usage: {e}") self._fold_reasoning_tokens_into_completion(response) + self._normalize_openai_compatible_usage_totals(getattr(response, "usage", None)) return response @staticmethod - def _fold_reasoning_tokens_into_completion(model_response: ModelResponse) -> None: + def _fold_reasoning_tokens_into_completion( + target: Union[ModelResponse, Usage, Dict[str, Any], None], + ) -> None: """Reconcile xAI Usage to the OpenAI invariant. xAI accounts ``reasoning_tokens`` separately from ``completion_tokens`` while still summing them into ``total_tokens``. OpenAI's contract (o1/o3) folds reasoning into ``completion_tokens``, so fold here to keep ``total = prompt + completion``. Idempotent. + + Accepts a ``ModelResponse`` (non-streaming), a ``Usage`` object, or a + raw usage ``dict`` (streaming chunk) so streaming and non-streaming + paths stay in sync. """ - usage = getattr(model_response, "usage", None) + if target is None: + return + + if isinstance(target, ModelResponse): + usage: Union[Usage, Dict[str, Any], None] = getattr(target, "usage", None) + else: + usage = target if usage is None: return + if isinstance(usage, dict): + details = usage.get("completion_tokens_details") or {} + if isinstance(details, dict): + reasoning_tokens = int(details.get("reasoning_tokens") or 0) + else: + reasoning_tokens = int(getattr(details, "reasoning_tokens", 0) or 0) + if reasoning_tokens <= 0: + return + + prompt_tokens = int(usage.get("prompt_tokens") or 0) + completion_tokens = int(usage.get("completion_tokens") or 0) + total_tokens = int(usage.get("total_tokens") or 0) + + if total_tokens == prompt_tokens + completion_tokens: + return + + # Guard against double-counting if xAI changes accounting. + if total_tokens != prompt_tokens + completion_tokens + reasoning_tokens: + return + + usage["completion_tokens"] = completion_tokens + reasoning_tokens + return + details = getattr(usage, "completion_tokens_details", None) reasoning_tokens = ( int(getattr(details, "reasoning_tokens", 0) or 0) if details else 0 @@ -284,6 +321,25 @@ class XAIChatConfig(OpenAIGPTConfig): setattr(usage, "num_sources_used", int(num_sources_used)) verbose_logger.debug(f"X.AI web search sources used: {num_sources_used}") + @staticmethod + def _normalize_openai_compatible_usage_totals( + usage: Union[Usage, Dict[str, Any], None], + ) -> None: + if usage is None: + return + if isinstance(usage, dict): + prompt_tokens = int(usage.get("prompt_tokens") or 0) + completion_tokens = int(usage.get("completion_tokens") or 0) + expected_total = prompt_tokens + completion_tokens + if int(usage.get("total_tokens") or 0) < expected_total: + usage["total_tokens"] = expected_total + return + prompt_tokens = int(usage.prompt_tokens or 0) + completion_tokens = int(usage.completion_tokens or 0) + expected_total = prompt_tokens + completion_tokens + if int(usage.total_tokens or 0) < expected_total: + usage.total_tokens = expected_total + class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler): def chunk_parser(self, chunk: dict) -> ModelResponseStream: @@ -304,4 +360,8 @@ class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler): # Add a dummy choice with empty delta to ensure proper processing chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": None}] + if "usage" in chunk and chunk["usage"] is not None: + XAIChatConfig._fold_reasoning_tokens_into_completion(chunk["usage"]) + XAIChatConfig._normalize_openai_compatible_usage_totals(chunk["usage"]) + return super().chunk_parser(chunk) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index fb5bfa6cf4e..6a4a5dd6a03 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1448,6 +1448,35 @@ "supports_native_structured_output": true, "supports_minimal_reasoning_effort": true }, + "jp.anthropic.claude-sonnet-4-6": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_max_reasoning_effort": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true + }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -9602,6 +9631,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -9795,6 +9825,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9828,6 +9859,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9861,6 +9893,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -9895,6 +9928,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -13948,6 +13982,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/glm-5p1": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://fireworks.ai/models/fireworks/glm-5p1", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false + }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "fireworks_ai", @@ -14214,6 +14263,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/glm-5p1": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://fireworks.ai/models/fireworks/glm-5p1", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false + }, "fireworks_ai/kimi-k2p5": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 6e-07, @@ -14883,7 +14947,65 @@ "mode": "chat", "output_cost_per_reasoning_token": 1.5e-06, "output_cost_per_token": 1.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/models", + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, + "gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", @@ -15611,6 +15733,64 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_audio_token": 1e-06, + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -16929,6 +17109,66 @@ "web_search_billing_unit": "per_query", "supports_service_tier": true }, + "gemini/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, @@ -16988,6 +17228,67 @@ }, "web_search_billing_unit": "per_query" }, + "gemini/gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "rpm": 2000, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 800000, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -17173,6 +17474,65 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, @@ -24107,6 +24467,21 @@ "supports_tool_choice": true, "supports_vision": true }, + "mistral/ministral-8b-2512": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "mistral/mistral-tiny": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", @@ -26951,6 +27326,58 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -28725,6 +29152,24 @@ "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, + "reducto/parse-legacy": { + "litellm_provider": "reducto", + "mode": "ocr", + "ocr_cost_per_credit": 0.015, + "source": "https://reducto.ai/pricing", + "supported_endpoints": [ + "/v1/ocr" + ] + }, + "reducto/parse-v3": { + "litellm_provider": "reducto", + "mode": "ocr", + "ocr_cost_per_credit": 0.015, + "source": "https://reducto.ai/pricing", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "recraft/recraftv2": { "litellm_provider": "recraft", "mode": "image_generation", @@ -33427,6 +33872,64 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d1b49039e8e..bbf40f6e9ef 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1226,6 +1226,7 @@ class MCPServerManager: tools = await self._get_tools_from_server( server=server, mcp_auth_header=server_auth_header, + user_api_key_auth=user_api_key_auth, ) return tools except Exception as e: @@ -1406,6 +1407,7 @@ class MCPServerManager: extra_headers: Optional[Dict[str, str]] = None, add_prefix: bool = True, raw_headers: Optional[Dict[str, str]] = None, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> List[MCPTool]: """ Helper method to get tools from a single MCP server with prefixed names. @@ -1432,6 +1434,46 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + # MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook). + # Skip entirely when the signer is not configured (avoid an unnecessary + # dict copy on every list call), when the server has its own static + # Authorization header, when a per-user mcp_auth_header has already + # been resolved, or when the caller already supplied an Authorization + # entry in extra_headers (e.g. a per-user OAuth token resolved + # upstream) — admin-configured static auth and per-user OAuth must + # take precedence so the signer doesn't silently overwrite e.g. an + # upstream API key or a user's OAuth token (MCPClient._get_auth_headers + # applies extra_headers after writing Authorization from auth_value, so + # an injected JWT would otherwise clobber the per-user token). + if user_api_key_auth is not None and not server.spec_path: + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + get_mcp_jwt_signer, + inject_mcp_jwt_headers_for_upstream, + ) + + static_headers = server.static_headers or {} + has_static_authorization = any( + isinstance(k, str) and k.lower() == "authorization" + for k in static_headers.keys() + ) + has_extra_authorization = bool(extra_headers) and any( + isinstance(k, str) and k.lower() == "authorization" + for k in (extra_headers or {}).keys() + ) + + if ( + get_mcp_jwt_signer() is not None + and not has_static_authorization + and not mcp_auth_header + and not has_extra_authorization + ): + extra_headers = await inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=user_api_key_auth, + extra_headers=extra_headers, + raw_headers=raw_headers, + for_list_tools=True, + ) + stdio_env = self._build_stdio_env(server, raw_headers) client = await self._create_mcp_client( @@ -2791,6 +2833,112 @@ class MCPServerManager: return cast(CallToolResult, result) + def _resolve_mcp_server_for_tool_call( + self, + server_name: str, + name: str, + ) -> MCPServer: + """Resolve MCP server for call_tool (prefixed name, registry, fallback).""" + prefixed_tool_name = add_server_prefix_to_name(name, server_name) + mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name) + resolved_by_server_name_only = False + normalized_server_name = normalize_server_name(server_name) + + def _candidate_matches_server_name(candidate: MCPServer) -> bool: + for identifier in ( + candidate.alias, + candidate.server_name, + candidate.name, + ): + if identifier and normalize_server_name(identifier) == ( + normalized_server_name + ): + return True + return False + + if mcp_server is None: + for candidate in self.get_registry().values(): + if _candidate_matches_server_name(candidate): + mcp_server = candidate + resolved_by_server_name_only = True + break + if mcp_server is None: + fallback = self._get_mcp_server_from_tool_name(name) + if fallback is not None and ( + not server_name or _candidate_matches_server_name(fallback) + ): + mcp_server = fallback + if mcp_server is None: + raise ValueError(f"Tool {name} not found") + + if resolved_by_server_name_only: + tool_known = ( + name in self.tool_name_to_mcp_server_name_mapping + or prefixed_tool_name in self.tool_name_to_mcp_server_name_mapping + ) + if not tool_known: + raise ValueError(f"Tool {name} not found") + + return mcp_server + + async def _resolve_oauth2_headers_for_tool_call( + self, + mcp_server: MCPServer, + oauth2_headers: Optional[Dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> Optional[Dict[str, str]]: + """Look up per-user OAuth headers when the client did not supply a token.""" + if ( + not mcp_server.needs_user_oauth_token + or oauth2_headers + or user_api_key_auth is None + ): + return oauth2_headers + + user_id = getattr(user_api_key_auth, "user_id", None) + if not user_id: + return oauth2_headers + + try: + from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415 + _get_user_oauth_extra_headers_from_db, + ) + + stored_headers = await _get_user_oauth_extra_headers_from_db( + server=mcp_server, + user_api_key_auth=user_api_key_auth, + ) + if stored_headers: + return stored_headers + except Exception as _lookup_exc: + verbose_logger.debug( + "call_tool: per-user token lookup failed for " "user=%s server=%s: %s", + user_id, + mcp_server.server_id, + _lookup_exc, + ) + return oauth2_headers + + async def _gather_openapi_tool_tasks( + self, + tasks: List[Any], + proxy_logging_obj: Optional[ProxyLogging], + ) -> CallToolResult: + """Await OpenAPI tool tasks and return the tool call result.""" + try: + mcp_responses = await asyncio.gather(*tasks) + result_index = 1 if proxy_logging_obj else 0 + return cast(CallToolResult, mcp_responses[result_index]) + except ( + BlockedPiiEntityError, + GuardrailRaisedException, + HTTPException, + ) as e: + verbose_logger.error( + f"Guardrail blocked MCP tool call during result check: {str(e)}" + ) + raise e + async def call_tool( self, server_name: str, @@ -2821,12 +2969,7 @@ class MCPServerManager: CallToolResult from the MCP server """ start_time = datetime.datetime.now() - - # Get the MCP server - prefixed_tool_name = add_server_prefix_to_name(name, server_name) - mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name) - if mcp_server is None: - raise ValueError(f"Tool {name} not found") + mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name) ######################################################### # Pre MCP Tool Call Hook @@ -2860,36 +3003,9 @@ class MCPServerManager: ) tasks.append(during_hook_task) - # For per-user OAuth servers: if the client didn't supply a token in - # oauth2_headers, look up the stored token from Redis / DB. This is the - # call_tool equivalent of _get_user_oauth_extra_headers_from_db used in - # list_tools. - if ( - mcp_server.needs_user_oauth_token - and not oauth2_headers - and user_api_key_auth is not None - ): - user_id = getattr(user_api_key_auth, "user_id", None) - if user_id: - try: - from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415 - _get_user_oauth_extra_headers_from_db, - ) - - stored_headers = await _get_user_oauth_extra_headers_from_db( - server=mcp_server, - user_api_key_auth=user_api_key_auth, - ) - if stored_headers: - oauth2_headers = stored_headers - except Exception as _lookup_exc: - verbose_logger.debug( - "call_tool: per-user token lookup failed for " - "user=%s server=%s: %s", - user_id, - mcp_server.server_id, - _lookup_exc, - ) + oauth2_headers = await self._resolve_oauth2_headers_for_tool_call( + mcp_server, oauth2_headers, user_api_key_auth + ) # For OpenAPI servers, call the tool handler directly instead of via MCP client if mcp_server.spec_path: @@ -2925,26 +3041,7 @@ class MCPServerManager: hook_extra_headers=hook_result.get("extra_headers"), ) - # For OpenAPI tools, await outside the client context - try: - mcp_responses = await asyncio.gather(*tasks) - - # If proxy_logging_obj is None, the tool call result is at index 0 - # If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task) - result_index = 1 if proxy_logging_obj else 0 - result = mcp_responses[result_index] - - return cast(CallToolResult, result) - except ( - BlockedPiiEntityError, - GuardrailRaisedException, - HTTPException, - ) as e: - # Re-raise guardrail exceptions to properly fail the MCP call - verbose_logger.error( - f"Guardrail blocked MCP tool call during result check: {str(e)}" - ) - raise e + return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj) ######################################################### # End of Methods that call the upstream MCP servers diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index 8541a691e88..09176f7253a 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -29,6 +29,16 @@ _DEFAULT_PORTS = {"http": 80, "https": 443} # subdomain. HTTPS only. _TRUSTED_REDIRECT_ORIGINS_ENV = "MCP_TRUSTED_REDIRECT_ORIGINS" +# Comma-separated private-use URI allowlist for native MCP clients. +# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g. +# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also +# match ``.../oauth/callback-2``. +_TRUSTED_NATIVE_REDIRECT_URIS_ENV = "MCP_TRUSTED_NATIVE_REDIRECT_URIS" + +# Default allowlist for trusted native redirect URIs. +_DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [ + "cursor://anysphere.cursor-mcp/oauth/callback", +] _warned_invalid_proxy_base_url: Optional[str] = None @@ -212,10 +222,82 @@ def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool: return netloc == entry +def _normalize_native_redirect_uri( + parsed, +) -> str: + """Lowercase scheme, netloc, and path for allowlist comparison.""" + return urlunparse( + ( + (parsed.scheme or "").lower(), + (parsed.netloc or "").lower(), + (parsed.path or "").lower(), + "", + "", + "", + ) + ) + + +def _parse_trusted_native_redirect_uris() -> List[str]: + """Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``.""" + entries: List[str] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS] + raw = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip() + if not raw: + return entries + for token in raw.split(","): + entry = token.strip().lower() + if entry and entry not in entries: + entries.append(entry) + return entries + + +def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool: + """Prefix match for ``entry*`` allowlist rows. + + When the prefix does not end with ``/``, only exact matches or + deeper path segments (``prefix/...``) are accepted — not siblings + like ``prefix-2``. + """ + if not normalized.startswith(prefix): + return False + suffix = normalized[len(prefix) :] + if not suffix: + return True + if prefix.endswith("/"): + return True + return suffix[0] == "/" + + +def _matches_trusted_native_redirect_uri(parsed) -> bool: + """Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients.""" + if parsed.fragment: + return False + # Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2). + # Rejecting them prevents allowlist bypass via ``.../callback?injected=...``. + if parsed.query: + return False + if not parsed.netloc: + return False + if parsed.username is not None or parsed.password is not None: + return False + if "\\" in parsed.netloc: + return False + + normalized = _normalize_native_redirect_uri(parsed) + for entry in _parse_trusted_native_redirect_uris(): + if entry.endswith("*"): + if _native_wildcard_prefix_matches(normalized, entry[:-1]): + return True + elif normalized == entry: + return True + return False + + def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: """Accept ``redirect_uri`` when it is (a) same-origin with the - proxy's own request origin, (b) loopback, or (c) listed in the - ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist. + proxy's own request origin, (b) loopback, (c) listed in the + ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in / + env-configured native MCP client callback (e.g. ``cursor://``). Same-origin is VERIA-57's threat-model-safe equivalent of loopback: an attacker who can host content on the proxy's own HTTPS origin @@ -239,6 +321,8 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: except ValueError: raise HTTPException(status_code=400, detail="invalid_request") if parsed.scheme not in ("http", "https"): + if _matches_trusted_native_redirect_uri(parsed): + return raise HTTPException(status_code=400, detail="invalid_request") if parsed.fragment: raise HTTPException(status_code=400, detail="invalid_request") @@ -310,9 +394,12 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: "Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r " "X-Forwarded-Port=%r Host=%r. " "Trusted-redirect-origins env=%r. " + "Trusted-native-redirect-uris env=%r. " "If this should be accepted, either align ingress X-Forwarded-* " "with the browser URL, set PROXY_BASE_URL to your public origin, " - "or add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS.", + "add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS, or " + "for native MCP clients (cursor://, etc.) add the full redirect_uri " + "to MCP_TRUSTED_NATIVE_REDIRECT_URIS.", redirect_uri, proxy_base, os.environ.get("PROXY_BASE_URL"), @@ -321,5 +408,6 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: request.headers.get("X-Forwarded-Port"), request.headers.get("Host"), os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV), + os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV), ) raise HTTPException(status_code=400, detail="invalid_request") diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 271517bb1e6..de70fe1331e 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -305,7 +305,7 @@ def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]: def _merge_openapi_tool_request_headers( - static_headers: Dict[str, str] + static_headers: Dict[str, str], ) -> Dict[str, str]: """Merge static closure headers with per-request ContextVar overrides. diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 829863d2dbb..7150dee10cf 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,6 +1,17 @@ import importlib from datetime import datetime -from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Set, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Optional, + Set, + Tuple, + Union, +) from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -231,11 +242,32 @@ if MCP_AVAILABLE: ) return mcp_auth_header, mcp_server_auth_headers, raw_headers + def _resolve_mcp_server_id_for_rest( + server_id: str, + allowed_server_ids: Union[Set[str], List[str]], + client_ip: Optional[str] = None, + ) -> str: + """ + Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id. + + tools/list already did this; tools/call must match so clients can pass + server names like ``order_status_mcp`` instead of only UUIDs. + """ + allowed = set(allowed_server_ids) + if server_id in allowed: + return server_id + by_name = global_mcp_server_manager.get_mcp_server_by_name( + server_id, client_ip=client_ip + ) + if by_name is not None and by_name.server_id in allowed: + return by_name.server_id + return server_id + async def _resolve_allowed_mcp_servers_with_ip_filter( request: Request, user_api_key_dict: UserAPIKeyAuth, server_id: str, - ) -> List[MCPServer]: + ) -> Tuple[List[MCPServer], str]: """ Resolve allowed MCP servers for a tool call with IP filtering. @@ -245,10 +277,10 @@ if MCP_AVAILABLE: server_id: The server ID to validate access for Returns: - List of allowed MCPServer objects + Tuple of (allowed MCPServer objects, canonical server_id) Raises: - HTTPException: If the server_id is not allowed + HTTPException: If the server_id is not allowed or not found """ # Get all auth contexts auth_contexts = await build_effective_auth_contexts(user_api_key_dict) @@ -268,8 +300,41 @@ if MCP_AVAILABLE: ) ) - # Check if the specified server_id is allowed - if server_id not in allowed_server_ids_set: + canonical_server_id = _resolve_mcp_server_id_for_rest( + server_id, allowed_server_ids_set, _rest_client_ip + ) + + if canonical_server_id not in allowed_server_ids_set: + _server = global_mcp_server_manager.get_mcp_server_by_id( + server_id + ) or global_mcp_server_manager.get_mcp_server_by_name(server_id) + if ( + _server is not None + and _rest_client_ip is not None + and not global_mcp_server_manager._is_server_accessible_from_ip( + _server, _rest_client_ip + ) + ): + raise HTTPException( + status_code=403, + detail={ + "error": "ip_filtering", + "message": ( + f"MCP server '{server_id}' is not accessible from your IP address " + f"({_rest_client_ip}). This server is restricted to internal " + "networks only. To make it externally accessible, set " + "'available_on_public_internet: true' in the server configuration." + ), + }, + ) + if _server is None: + raise HTTPException( + status_code=404, + detail={ + "error": "server_not_found", + "message": f"MCP server '{server_id}' was not found", + }, + ) raise HTTPException( status_code=403, detail={ @@ -285,7 +350,7 @@ if MCP_AVAILABLE: if server is not None: allowed_mcp_servers.append(server) - return allowed_mcp_servers + return allowed_mcp_servers, canonical_server_id async def _get_tools_for_single_server( server, @@ -301,6 +366,7 @@ if MCP_AVAILABLE: extra_headers=extra_headers, add_prefix=False, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) # Filter tools based on allowed_tools configuration @@ -753,7 +819,7 @@ if MCP_AVAILABLE: }, ) - tool_arguments = data.get("arguments") + tool_arguments = data.get("arguments") or {} proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) ( @@ -786,14 +852,18 @@ if MCP_AVAILABLE: data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"] # Resolve allowed MCP servers with IP filtering - allowed_mcp_servers = await _resolve_allowed_mcp_servers_with_ip_filter( + ( + allowed_mcp_servers, + canonical_server_id, + ) = await _resolve_allowed_mcp_servers_with_ip_filter( request, user_api_key_dict, server_id ) # Look up per-user OAuth headers for this server (mirrors list_tool_rest_api). user_oauth_extra_headers: Optional[Dict[str, str]] = None target_server = next( - (s for s in allowed_mcp_servers if s.server_id == server_id), None + (s for s in allowed_mcp_servers if s.server_id == canonical_server_id), + None, ) if target_server is not None: user_oauth_extra_headers = await _get_user_oauth_extra_headers( @@ -812,6 +882,7 @@ if MCP_AVAILABLE: oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"), raw_headers=data.get("raw_headers"), litellm_logging_obj=data.get("litellm_logging_obj"), + requested_server_id=canonical_server_id, ) return result except BlockedPiiEntityError as e: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 0a74a92f9ce..5676aaf0d22 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1368,6 +1368,7 @@ if MCP_AVAILABLE: extra_headers=extra_headers, add_prefix=True, # Always add server prefix raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) filtered_tools = filter_tools_by_allowed_tools(tools, server) @@ -2074,6 +2075,7 @@ if MCP_AVAILABLE: """ # Track resolved MCP server for both permission checks and dispatch mcp_server: Optional[MCPServer] = None + requested_server_id: Optional[str] = kwargs.get("requested_server_id") # If the client called with a display-name override (e.g. "Get Pet"), # translate it back to the original prefixed name before any routing. @@ -2082,14 +2084,55 @@ if MCP_AVAILABLE: # Remove prefix from tool name for logging and processing original_tool_name, server_name = split_server_prefix_from_name(name) + requested_server: Optional[MCPServer] = None + if requested_server_id: + requested_server = next( + (s for s in allowed_mcp_servers if s.server_id == requested_server_id), + None, + ) + # Resolve the actual MCP server up-front so the permission check uses # the canonical server.name even when the tool name is prefixed with a # short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the # server's display name directly. mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name) + if mcp_server is None and requested_server is not None: + # REST callers may pass the raw tool name (no prefix) plus a + # ``requested_server_id``. The mapping might only contain the + # prefixed form, so retry the lookup with every known prefix of + # the requested server before treating the tool as unresolved — + # otherwise the tool_server_mismatch guard below is silently + # bypassed. + for known_prefix in iter_known_server_prefixes(requested_server): + candidate = global_mcp_server_manager._get_mcp_server_from_tool_name( + add_server_prefix_to_name(name, known_prefix) + ) + if candidate is not None: + mcp_server = candidate + break if mcp_server is not None: server_name = mcp_server.name + # REST /mcp-rest/tools/call passes server_id — tool must belong to that server + if requested_server is not None: + if ( + mcp_server is not None + and mcp_server.server_id != requested_server.server_id + ): + raise HTTPException( + status_code=403, + detail={ + "error": "tool_server_mismatch", + "message": ( + f"Tool '{name}' belongs to MCP server '{mcp_server.name}' " + f"but request specified server_id for '{requested_server.name}'." + ), + }, + ) + if mcp_server is None: + mcp_server = requested_server + server_name = requested_server.name + # Only enforce server-level permissions when we can resolve a server if server_name: if not MCPRequestHandler.is_tool_allowed( diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404/index.html similarity index 100% rename from litellm/proxy/_experimental/out/404.html rename to litellm/proxy/_experimental/out/404/index.html diff --git a/litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js b/litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js index aafe9858009..87d6af3231a 100644 --- a/litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js +++ b/litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js @@ -1,11 +1,19 @@ +<<<<<<<< HEAD:litellm/proxy/_experimental/out/_next/static/chunks/0279e5299e9f6e98.js +(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,111790,758472,280881,e=>{"use strict";e.s([],111790);var t=e.i(843476),s=e.i(708347),r=e.i(750113),l=e.i(994388),a=e.i(197647),n=e.i(653824),i=e.i(881073),o=e.i(404206),c=e.i(723731),d=e.i(599724),m=e.i(629569),u=e.i(844444),x=e.i(869216),h=e.i(212931),p=e.i(199133),g=e.i(592968),f=e.i(898586),b=e.i(271645),j=e.i(500727),y=e.i(266027),v=e.i(912598),N=e.i(243652),_=e.i(764205),w=e.i(135214);let S=(0,N.createQueryKeys)("mcpServerHealth");var C=e.i(727749),T=e.i(988846),k=e.i(678784),A=e.i(995926),I=e.i(328196),P=e.i(302202),O=e.i(409797),M=e.i(54131),F=e.i(440987);let E=[{label:"Documentation",fields:[{key:"description",label:"Description",description:"Must have a non-empty description",check:e=>!!e.description?.trim()},{key:"alias",label:"Alias",description:"Must have a display alias",check:e=>!!e.alias?.trim()}]},{label:"Source",fields:[{key:"source_url",label:"GitHub / Source URL",description:"Must link to a source repository",check:e=>!!e.source_url?.trim()}]},{label:"Connection",fields:[{key:"url",label:"Server URL",description:"Must have a URL configured",check:e=>!!e.url?.trim()}]},{label:"Security",fields:[{key:"auth_type",label:"Auth configured",description:"Must use authentication (not 'none')",check:e=>!!e.auth_type&&"none"!==e.auth_type}]}],L=E.flatMap(e=>e.fields),R="mcp_required_fields",U={active:{label:"Active",bg:"bg-green-50",text:"text-green-700",dot:"bg-green-500"},pending_review:{label:"Pending Review",bg:"bg-yellow-50",text:"text-yellow-700",dot:"bg-yellow-500"},rejected:{label:"Rejected",bg:"bg-red-50",text:"text-red-700",dot:"bg-red-500"}};function z({label:e,value:s,color:r}){return(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg px-4 py-3",children:[(0,t.jsx)("div",{className:`text-2xl font-bold ${r}`,children:s}),(0,t.jsx)("div",{className:"text-xs text-gray-500 mt-0.5",children:e})]})}function B({action:e,serverName:s,isCurrentlyActive:r,onConfirm:l,onCancel:a}){let[n,i]=(0,b.useState)(""),o="approve"===e;return(0,t.jsx)("div",{className:"fixed inset-0 bg-black/30 flex items-center justify-center z-50",children:(0,t.jsxs)("div",{className:"bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4",children:[(0,t.jsx)("div",{className:`w-10 h-10 rounded-full flex items-center justify-center mb-4 ${o?"bg-green-100":"bg-red-100"}`,children:o?(0,t.jsx)(k.CheckIcon,{className:"h-5 w-5 text-green-600"}):(0,t.jsx)(I.AlertCircleIcon,{className:"h-5 w-5 text-red-600"})}),(0,t.jsx)("h3",{className:"text-base font-semibold text-gray-900 mb-1",children:o?"Approve MCP Server":"Reject MCP Server"}),(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-4",children:["Are you sure you want to ",e," ",(0,t.jsxs)("span",{className:"font-medium text-gray-700",children:['"',s,'"']}),"?"," ",o?"This will make it active and available for use.":r?"This server is currently live. Rejecting it will immediately remove it from the proxy runtime.":"This will mark the submission as rejected."]}),!o&&(0,t.jsx)("textarea",{placeholder:"Reason for rejection (optional)",value:n,onChange:e=>i(e.target.value),className:"w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 mb-4 resize-none",rows:3}),(0,t.jsxs)("div",{className:"flex gap-3",children:[(0,t.jsx)("button",{type:"button",onClick:a,className:"flex-1 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors",children:"Cancel"}),(0,t.jsx)("button",{type:"button",onClick:()=>l(o?void 0:n||void 0),className:`flex-1 text-white text-sm font-medium py-2 rounded-md transition-colors ${o?"bg-green-500 hover:bg-green-600":"bg-red-500 hover:bg-red-600"}`,children:o?"Approve":"Reject"})]})]})})}function q({requiredFields:e,onChange:s,onSave:r,isSaving:l}){let[a,n]=(0,b.useState)(!1),i=L.filter(t=>e.includes(t.key));return(0,t.jsxs)("div",{className:"mb-5 border border-gray-200 rounded-lg bg-white overflow-hidden",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-4 py-3 cursor-pointer select-none",onClick:()=>n(e=>!e),children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(F.SettingsIcon,{className:"h-4 w-4 text-gray-400"}),(0,t.jsx)("span",{className:"text-sm font-semibold text-gray-800",children:"Submission Rules"}),i.length>0?(0,t.jsxs)("span",{className:"text-xs text-gray-500",children:["(",i.length," required field",1!==i.length?"s":"",")"]}):(0,t.jsx)("span",{className:"text-xs text-gray-400 italic",children:"no rules set"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3",children:[!a&&i.length>0&&(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5 max-w-md",children:i.map(e=>(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-full",children:[(0,t.jsx)(k.CheckIcon,{className:"h-3 w-3"}),e.label]},e.key))}),a?(0,t.jsx)(M.ChevronUpIcon,{className:"h-4 w-4 text-gray-400"}):(0,t.jsx)(O.ChevronDownIcon,{className:"h-4 w-4 text-gray-400"})]})]}),a&&(0,t.jsxs)("div",{className:"border-t border-gray-100 px-4 pt-4 pb-4",children:[(0,t.jsx)("p",{className:"text-xs text-gray-500 mb-4",children:"Select which fields must be filled in before a submission is considered compliant. LiteLLM will show ✓ / ✗ for each rule on every submission card below."}),(0,t.jsx)("div",{className:"grid grid-cols-2 gap-x-8 gap-y-5",children:E.map(r=>(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2",children:r.label}),(0,t.jsx)("div",{className:"space-y-2",children:r.fields.map(r=>{let l=e.includes(r.key);return(0,t.jsxs)("label",{className:"flex items-start gap-2.5 cursor-pointer group",children:[(0,t.jsx)("input",{type:"checkbox",checked:l,onChange:()=>{var t;return t=r.key,void s(e.includes(t)?e.filter(e=>e!==t):[...e,t])},className:"mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"}),(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"text-sm font-medium text-gray-800 group-hover:text-blue-700 transition-colors",children:r.label}),(0,t.jsx)("div",{className:"text-xs text-gray-400",children:r.description})]})]},r.key)})})]},r.label))}),(0,t.jsxs)("div",{className:"mt-5 flex items-center gap-3",children:[(0,t.jsx)("button",{type:"button",disabled:l,onClick:async()=>{await r(),n(!1)},className:"px-4 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-md transition-colors",children:l?"Saving…":"Save Rules"}),(0,t.jsx)("button",{type:"button",onClick:()=>n(!1),className:"px-4 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors",children:"Cancel"})]})]})]})}function V({server:e,onApprove:s,onReject:r,requiredFields:l}){let a=e.approval_status??"active",n=U[a]??U.active,i=L.filter(e=>l.includes(e.key)).map(t=>({key:t.key,label:t.label,description:t.description,passed:t.check(e)})),o=i.filter(e=>e.passed).length,c=i.length-o,d=i.length>0&&0===c;return(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg overflow-hidden",children:[(0,t.jsx)("div",{className:"px-4 pt-4 pb-3",children:(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:"flex items-center gap-2 mb-1.5",children:(0,t.jsxs)("span",{className:`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${n.bg} ${n.text}`,children:[(0,t.jsx)("span",{className:`w-1.5 h-1.5 rounded-full ${n.dot}`}),n.label]})}),(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:e.alias??e.server_name??e.server_id}),e.description&&(0,t.jsx)("p",{className:"text-xs text-gray-500 mt-0.5 line-clamp-1",children:e.description}),e.url&&(0,t.jsxs)("div",{className:"flex items-center gap-1.5 mt-1.5",children:[(0,t.jsx)(P.ServerIcon,{className:"h-3.5 w-3.5 text-gray-400 flex-shrink-0"}),(0,t.jsx)("code",{className:"text-xs text-gray-500 font-mono truncate",children:e.url})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3 mt-1.5 text-xs text-gray-400",children:[(0,t.jsxs)("span",{children:["Transport: ",(0,t.jsx)("span",{className:"text-gray-600",children:e.transport??"sse"})]}),(0,t.jsx)("span",{children:"·"}),(0,t.jsxs)("span",{children:["Submitted by: ",(0,t.jsx)("span",{className:"text-gray-600",children:e.submitted_by??"—"})]}),(0,t.jsx)("span",{children:"·"}),(0,t.jsx)("span",{children:function(e){if(!e)return"—";try{let t=new Date(e);return isNaN(t.getTime())?e:t.toISOString().slice(0,10)}catch{return e}}(e.submitted_at)})]}),"rejected"===a&&e.review_notes&&(0,t.jsxs)("p",{className:"text-xs text-red-600 mt-1.5",children:["Rejection reason: ",e.review_notes]})]}),0===i.length&&"rejected"!==a&&(0,t.jsxs)("div",{className:"flex items-center gap-2 flex-shrink-0",children:["active"!==a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Approve"}),(0,t.jsx)("button",{type:"button",onClick:r,className:"text-xs border border-red-300 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-md transition-colors font-medium",children:"Reject"})]}),0===i.length&&"rejected"===a&&(0,t.jsx)("div",{className:"flex items-center gap-2 flex-shrink-0",children:(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Re-approve"})})]})}),i.length>0&&(0,t.jsxs)("div",{className:"border-t border-gray-200",children:[(0,t.jsxs)("div",{className:`flex items-center gap-3 px-4 py-3 ${d?"bg-green-50 border-b border-green-100":"bg-red-50 border-b border-red-100"}`,children:[(0,t.jsx)("div",{className:`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${d?"bg-green-500":"bg-red-500"}`,children:d?(0,t.jsx)(k.CheckIcon,{className:"h-4 w-4 text-white"}):(0,t.jsx)(A.XIcon,{className:"h-4 w-4 text-white"})}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:`text-sm font-semibold leading-tight ${d?"text-green-800":"text-red-800"}`,children:d?"All checks passed":`${c} check${1!==c?"s":""} failed`}),(0,t.jsxs)("div",{className:"text-xs text-gray-500 mt-0.5",children:[o," passing, ",c," failing"]})]}),(0,t.jsxs)("div",{className:"flex items-center gap-2 flex-shrink-0",children:["active"!==a&&"rejected"!==a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Approve"}),"rejected"===a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Re-approve"}),"rejected"!==a&&(0,t.jsx)("button",{type:"button",onClick:r,className:"text-xs border border-red-300 text-red-600 hover:bg-red-50 bg-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Reject"})]})]}),(0,t.jsx)("div",{className:"divide-y divide-gray-100",children:i.map(e=>(0,t.jsxs)("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[(0,t.jsx)("div",{className:`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${e.passed?"bg-green-100":"bg-red-100"}`,children:e.passed?(0,t.jsx)(k.CheckIcon,{className:"h-3 w-3 text-green-600"}):(0,t.jsx)(A.XIcon,{className:"h-3 w-3 text-red-600"})}),(0,t.jsx)("span",{className:`text-sm flex-1 ${e.passed?"text-gray-700":"text-gray-800"}`,children:e.label}),(0,t.jsx)("span",{className:`text-xs ${e.passed?"text-green-600":"text-red-500"}`,children:e.passed?"Passes":"Missing"})]},e.key))})]})]})}function $({accessToken:e}){let[s,r]=(0,b.useState)({total:0,pending_review:0,active:0,rejected:0,items:[]}),[l,a]=(0,b.useState)(""),[n,i]=(0,b.useState)("all"),[o,c]=(0,b.useState)(null),[d,m]=(0,b.useState)(!0),[u,x]=(0,b.useState)(null),[h,p]=(0,b.useState)([]),[g,f]=(0,b.useState)(!1),j=(0,b.useCallback)(async()=>{if(!e)return void m(!1);m(!0),x(null);try{let[t,s]=await Promise.all([(0,_.fetchMCPSubmissions)(e),(0,_.getGeneralSettingsCall)(e).catch(e=>(console.warn("MCPSubmissionsTab: failed to load general settings, compliance rules will be empty:",e),null))]);if(r(t),s?.data&&Array.isArray(s.data)){let e=s.data.find(e=>e.field_name===R);e&&Array.isArray(e.field_value)&&p(e.field_value)}}catch(e){x(e instanceof Error?e.message:"Failed to load submissions")}finally{m(!1)}},[e]);(0,b.useEffect)(()=>{j()},[j]);let y=async()=>{if(e){f(!0);try{await (0,_.updateConfigFieldSetting)(e,R,h),C.default.success("Submission rules saved")}catch{C.default.fromBackend("Failed to save submission rules")}finally{f(!1)}}},v=s.items.filter(e=>{if("all"!==n&&e.approval_status!==n)return!1;if(l.trim()){let t=l.toLowerCase(),s=(e.alias??e.server_name??e.server_id??"").toLowerCase(),r=(e.url??"").toLowerCase();return s.includes(t)||r.includes(t)}return!0});async function N(t,s){if(e)try{await (0,_.approveMCPServer)(e,t),await j(),C.default.success(`MCP server "${s}" approved`)}catch{C.default.fromBackend("Failed to approve MCP server")}finally{c(null)}}async function w(t,s,r){if(e)try{await (0,_.rejectMCPServer)(e,t,r),await j(),C.default.success(`MCP server "${s}" rejected`)}catch{C.default.fromBackend("Failed to reject MCP server")}finally{c(null)}}return(0,t.jsxs)("div",{className:"p-6",children:[(0,t.jsx)(q,{requiredFields:h,onChange:p,onSave:y,isSaving:g}),(0,t.jsxs)("div",{className:"grid grid-cols-4 gap-4 mb-6",children:[(0,t.jsx)(z,{label:"Total Submitted",value:s.total,color:"text-gray-900"}),(0,t.jsx)(z,{label:"Pending Review",value:s.pending_review,color:"text-yellow-600"}),(0,t.jsx)(z,{label:"Active",value:s.active,color:"text-green-600"}),(0,t.jsx)(z,{label:"Rejected",value:s.rejected,color:"text-red-600"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-5",children:[(0,t.jsxs)("div",{className:"relative flex-1 max-w-xs",children:[(0,t.jsx)(T.SearchIcon,{className:"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"}),(0,t.jsx)("input",{type:"text",placeholder:"Search MCP servers...",value:l,onChange:e=>a(e.target.value),className:"w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"})]}),(0,t.jsxs)("select",{value:n,onChange:e=>i(e.target.value),className:"border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white",children:[(0,t.jsx)("option",{value:"all",children:"All Status"}),(0,t.jsx)("option",{value:"pending_review",children:"Pending Review"}),(0,t.jsx)("option",{value:"active",children:"Active"}),(0,t.jsx)("option",{value:"rejected",children:"Rejected"})]})]}),(0,t.jsxs)("div",{className:"space-y-3",children:[d&&(0,t.jsx)("div",{className:"text-center py-12 text-gray-500 text-sm",children:"Loading submissions…"}),u&&(0,t.jsx)("div",{className:"text-center py-12 text-red-600 text-sm",children:u}),!d&&!u&&0===v.length&&(0,t.jsx)("div",{className:"text-center py-12 text-gray-400 text-sm",children:"No MCP server submissions match your filters."}),!d&&!u&&v.map(e=>(0,t.jsx)(V,{server:e,requiredFields:h,onApprove:()=>c({serverId:e.server_id,serverName:e.alias??e.server_name??e.server_id,action:"approve"}),onReject:()=>c({serverId:e.server_id,serverName:e.alias??e.server_name??e.server_id,action:"reject",isCurrentlyActive:"active"===e.approval_status})},e.server_id))]}),o&&(0,t.jsx)(B,{action:o.action,serverName:o.serverName,isCurrentlyActive:o.isCurrentlyActive,onConfirm:e=>"approve"===o.action?N(o.serverId,o.serverName):w(o.serverId,o.serverName,e),onCancel:()=>c(null)})]})}var D=e.i(808613),H=e.i(311451),K=e.i(998573),W=e.i(482725),J=e.i(988297),Y=e.i(797672),G=e.i(68155),Q=e.i(699857),Z=e.i(149121);let{Text:X}=f.Typography;function ee({serverId:e,serverName:s,accessToken:r,selectedTools:l,onToggle:a}){let[n,i]=(0,b.useState)([]),[o,c]=(0,b.useState)(!1),[d,m]=(0,b.useState)(!1),u=new Set(l.filter(t=>t.server_id===e).map(e=>e.tool_name)),x=(0,b.useCallback)(async()=>{if(r&&!(n.length>0)){c(!0);try{let t=await (0,_.listMCPTools)(r,e),s=Array.isArray(t)?t:t?.tools??[];i(s.map(e=>({name:e.name??e.tool_name??e,description:e.description??""})))}catch{i([])}finally{c(!1)}}},[r,e,n.length]);return(0,t.jsxs)("div",{className:"border border-gray-200 rounded-lg overflow-hidden",children:[(0,t.jsxs)("button",{type:"button",className:"w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors",onClick:()=>{d||x(),m(!d)},children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center gap-2",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"}),s,u.size>0&&(0,t.jsxs)("span",{className:"ml-1 text-xs text-purple-600 font-semibold",children:[u.size," selected"]})]}),(0,t.jsx)("span",{className:"text-gray-400 text-xs",children:d?"▲":"▼"})]}),d&&(0,t.jsx)("div",{className:"p-2",children:o?(0,t.jsx)("div",{className:"flex justify-center py-3",children:(0,t.jsx)(W.Spin,{size:"small"})}):0===n.length?(0,t.jsx)("p",{className:"text-xs text-gray-400 px-2 py-2",children:"No tools found for this server."}):(0,t.jsx)("div",{className:"flex flex-col gap-1",children:n.map(s=>{let r=u.has(s.name);return(0,t.jsxs)("button",{type:"button",onClick:()=>a({server_id:e,tool_name:s.name}),className:`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${r?"bg-purple-50 border border-purple-300":"bg-white border border-gray-100 hover:bg-gray-50"}`,children:[(0,t.jsxs)("div",{className:"min-w-0 flex-1",children:[(0,t.jsx)("p",{className:`text-sm font-medium leading-tight ${r?"text-purple-800":"text-gray-800"}`,children:s.name}),s.description&&(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2",children:s.description})]}),r&&(0,t.jsx)("span",{className:"text-purple-500 text-xs font-semibold ml-2 flex-shrink-0 mt-0.5",children:"✓"})]},s.name)})})})]})}function et({open:e,onClose:s,onSave:r,accessToken:a,initialToolset:n}){let[i]=D.Form.useForm(),[o,c]=(0,b.useState)(n?.tools||[]),[m,u]=(0,b.useState)(!1),[x,p]=(0,b.useState)(""),{data:g=[]}=(0,j.useMCPServers)();b.default.useEffect(()=>{e&&(i.setFieldsValue({toolset_name:n?.toolset_name||"",description:n?.description||""}),c(n?.tools||[]),p(""))},[e,n]);let f=e=>{c(t=>t.some(t=>t.server_id===e.server_id&&t.tool_name===e.tool_name)?t.filter(t=>t.server_id!==e.server_id||t.tool_name!==e.tool_name):[...t,e])},y=async()=>{let e=await i.validateFields();u(!0);try{await r(e.toolset_name,e.description,o),s()}finally{u(!1)}},v=g.filter(e=>{let t=x.toLowerCase();return!t||(e.alias||"").toLowerCase().includes(t)||(e.server_name||"").toLowerCase().includes(t)});return(0,t.jsxs)(h.Modal,{open:e,onCancel:s,title:n?"Edit Toolset":"New Toolset",width:960,footer:null,forceRender:!0,children:[(0,t.jsx)(D.Form,{form:i,layout:"vertical",className:"mt-2",children:(0,t.jsxs)("div",{className:"flex gap-4 mb-4",children:[(0,t.jsx)(D.Form.Item,{label:"Toolset Name",name:"toolset_name",rules:[{required:!0,message:"Please enter a toolset name"}],className:"flex-1 mb-0",children:(0,t.jsx)(H.Input,{placeholder:"e.g. github-linear-tools"})}),(0,t.jsx)(D.Form.Item,{label:"Description",name:"description",className:"flex-1 mb-0",children:(0,t.jsx)(H.Input,{placeholder:"Optional description"})})]})}),(0,t.jsxs)("div",{className:"flex gap-4 mt-2",style:{minHeight:360},children:[(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:"flex items-center justify-between mb-2",children:(0,t.jsx)(d.Text,{className:"text-sm font-semibold text-gray-700",children:"Available Tools"})}),(0,t.jsx)(H.Input,{placeholder:"Search MCP servers...",value:x,onChange:e=>p(e.target.value),className:"mb-2",allowClear:!0}),(0,t.jsx)("div",{className:"space-y-2 overflow-y-auto",style:{maxHeight:300},children:0===v.length?(0,t.jsx)(d.Text,{className:"text-gray-400 text-sm",children:0===g.length?"No MCP servers configured":"No servers match your search"}):v.map(e=>(0,t.jsx)(ee,{serverId:e.server_id,serverName:e.alias||e.server_name||e.server_id,accessToken:a,selectedTools:o,onToggle:f},e.server_id))})]}),(0,t.jsx)("div",{className:"w-px bg-gray-200 flex-shrink-0"}),(0,t.jsxs)("div",{className:"w-72 flex-shrink-0",children:[(0,t.jsxs)(d.Text,{className:"text-sm font-semibold text-gray-700 mb-2 block",children:["Your Toolset"," ",(0,t.jsxs)("span",{className:"text-xs font-normal text-gray-400",children:["(",o.length," tools)"]})]}),(0,t.jsx)("div",{className:"space-y-1 overflow-y-auto",style:{maxHeight:340},children:0===o.length?(0,t.jsx)(d.Text,{className:"text-gray-400 text-sm",children:"No tools added yet"}):o.map((e,s)=>(0,t.jsxs)("button",{type:"button",onClick:()=>f(e),className:"w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors",children:[(0,t.jsxs)("div",{className:"min-w-0 text-left",children:[(0,t.jsx)("span",{className:"text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block",children:e.tool_name}),(0,t.jsxs)("span",{className:"text-[10px] text-purple-400 truncate block",children:[e.server_id.slice(0,8),"…"]})]}),(0,t.jsx)("span",{className:"ml-2 text-purple-300 group-hover:text-red-400 text-xs flex-shrink-0",children:"✕"})]},s))})]})]}),(0,t.jsxs)("div",{className:"flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200",children:[(0,t.jsx)(l.Button,{variant:"secondary",onClick:s,children:"Cancel"}),(0,t.jsx)(l.Button,{onClick:y,loading:m,children:n?"Save Changes":"Create Toolset"})]})]})}function es(){let[e,s]=(0,b.useState)(!1),r=(0,_.getProxyBaseUrl)(),l=`{ +======== (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,111790,758472,280881,e=>{"use strict";e.s([],111790);var t=e.i(843476),s=e.i(708347),r=e.i(750113),l=e.i(994388),a=e.i(197647),n=e.i(653824),i=e.i(881073),o=e.i(404206),c=e.i(723731),d=e.i(599724),m=e.i(629569),u=e.i(844444),x=e.i(869216),h=e.i(212931),p=e.i(199133),g=e.i(592968),f=e.i(898586),b=e.i(271645),j=e.i(500727),y=e.i(266027),v=e.i(912598),N=e.i(243652),_=e.i(764205),w=e.i(135214);let S=(0,N.createQueryKeys)("mcpServerHealth");var C=e.i(727749),T=e.i(988846),k=e.i(678784),A=e.i(995926),I=e.i(328196),P=e.i(302202),O=e.i(409797),M=e.i(54131),F=e.i(440987);let E=[{label:"Documentation",fields:[{key:"description",label:"Description",description:"Must have a non-empty description",check:e=>!!e.description?.trim()},{key:"alias",label:"Alias",description:"Must have a display alias",check:e=>!!e.alias?.trim()}]},{label:"Source",fields:[{key:"source_url",label:"GitHub / Source URL",description:"Must link to a source repository",check:e=>!!e.source_url?.trim()}]},{label:"Connection",fields:[{key:"url",label:"Server URL",description:"Must have a URL configured",check:e=>!!e.url?.trim()}]},{label:"Security",fields:[{key:"auth_type",label:"Auth configured",description:"Must use authentication (not 'none')",check:e=>!!e.auth_type&&"none"!==e.auth_type}]}],L=E.flatMap(e=>e.fields),R="mcp_required_fields",z={active:{label:"Active",bg:"bg-green-50",text:"text-green-700",dot:"bg-green-500"},pending_review:{label:"Pending Review",bg:"bg-yellow-50",text:"text-yellow-700",dot:"bg-yellow-500"},rejected:{label:"Rejected",bg:"bg-red-50",text:"text-red-700",dot:"bg-red-500"}};function U({label:e,value:s,color:r}){return(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg px-4 py-3",children:[(0,t.jsx)("div",{className:`text-2xl font-bold ${r}`,children:s}),(0,t.jsx)("div",{className:"text-xs text-gray-500 mt-0.5",children:e})]})}function B({action:e,serverName:s,isCurrentlyActive:r,onConfirm:l,onCancel:a}){let[n,i]=(0,b.useState)(""),o="approve"===e;return(0,t.jsx)("div",{className:"fixed inset-0 bg-black/30 flex items-center justify-center z-50",children:(0,t.jsxs)("div",{className:"bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4",children:[(0,t.jsx)("div",{className:`w-10 h-10 rounded-full flex items-center justify-center mb-4 ${o?"bg-green-100":"bg-red-100"}`,children:o?(0,t.jsx)(k.CheckIcon,{className:"h-5 w-5 text-green-600"}):(0,t.jsx)(I.AlertCircleIcon,{className:"h-5 w-5 text-red-600"})}),(0,t.jsx)("h3",{className:"text-base font-semibold text-gray-900 mb-1",children:o?"Approve MCP Server":"Reject MCP Server"}),(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-4",children:["Are you sure you want to ",e," ",(0,t.jsxs)("span",{className:"font-medium text-gray-700",children:['"',s,'"']}),"?"," ",o?"This will make it active and available for use.":r?"This server is currently live. Rejecting it will immediately remove it from the proxy runtime.":"This will mark the submission as rejected."]}),!o&&(0,t.jsx)("textarea",{placeholder:"Reason for rejection (optional)",value:n,onChange:e=>i(e.target.value),className:"w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 mb-4 resize-none",rows:3}),(0,t.jsxs)("div",{className:"flex gap-3",children:[(0,t.jsx)("button",{type:"button",onClick:a,className:"flex-1 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors",children:"Cancel"}),(0,t.jsx)("button",{type:"button",onClick:()=>l(o?void 0:n||void 0),className:`flex-1 text-white text-sm font-medium py-2 rounded-md transition-colors ${o?"bg-green-500 hover:bg-green-600":"bg-red-500 hover:bg-red-600"}`,children:o?"Approve":"Reject"})]})]})})}function q({requiredFields:e,onChange:s,onSave:r,isSaving:l}){let[a,n]=(0,b.useState)(!1),i=L.filter(t=>e.includes(t.key));return(0,t.jsxs)("div",{className:"mb-5 border border-gray-200 rounded-lg bg-white overflow-hidden",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-4 py-3 cursor-pointer select-none",onClick:()=>n(e=>!e),children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(F.SettingsIcon,{className:"h-4 w-4 text-gray-400"}),(0,t.jsx)("span",{className:"text-sm font-semibold text-gray-800",children:"Submission Rules"}),i.length>0?(0,t.jsxs)("span",{className:"text-xs text-gray-500",children:["(",i.length," required field",1!==i.length?"s":"",")"]}):(0,t.jsx)("span",{className:"text-xs text-gray-400 italic",children:"no rules set"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3",children:[!a&&i.length>0&&(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5 max-w-md",children:i.map(e=>(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-full",children:[(0,t.jsx)(k.CheckIcon,{className:"h-3 w-3"}),e.label]},e.key))}),a?(0,t.jsx)(M.ChevronUpIcon,{className:"h-4 w-4 text-gray-400"}):(0,t.jsx)(O.ChevronDownIcon,{className:"h-4 w-4 text-gray-400"})]})]}),a&&(0,t.jsxs)("div",{className:"border-t border-gray-100 px-4 pt-4 pb-4",children:[(0,t.jsx)("p",{className:"text-xs text-gray-500 mb-4",children:"Select which fields must be filled in before a submission is considered compliant. LiteLLM will show ✓ / ✗ for each rule on every submission card below."}),(0,t.jsx)("div",{className:"grid grid-cols-2 gap-x-8 gap-y-5",children:E.map(r=>(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2",children:r.label}),(0,t.jsx)("div",{className:"space-y-2",children:r.fields.map(r=>{let l=e.includes(r.key);return(0,t.jsxs)("label",{className:"flex items-start gap-2.5 cursor-pointer group",children:[(0,t.jsx)("input",{type:"checkbox",checked:l,onChange:()=>{var t;return t=r.key,void s(e.includes(t)?e.filter(e=>e!==t):[...e,t])},className:"mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"}),(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"text-sm font-medium text-gray-800 group-hover:text-blue-700 transition-colors",children:r.label}),(0,t.jsx)("div",{className:"text-xs text-gray-400",children:r.description})]})]},r.key)})})]},r.label))}),(0,t.jsxs)("div",{className:"mt-5 flex items-center gap-3",children:[(0,t.jsx)("button",{type:"button",disabled:l,onClick:async()=>{await r(),n(!1)},className:"px-4 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-md transition-colors",children:l?"Saving…":"Save Rules"}),(0,t.jsx)("button",{type:"button",onClick:()=>n(!1),className:"px-4 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors",children:"Cancel"})]})]})]})}function V({server:e,onApprove:s,onReject:r,requiredFields:l}){let a=e.approval_status??"active",n=z[a]??z.active,i=L.filter(e=>l.includes(e.key)).map(t=>({key:t.key,label:t.label,description:t.description,passed:t.check(e)})),o=i.filter(e=>e.passed).length,c=i.length-o,d=i.length>0&&0===c;return(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg overflow-hidden",children:[(0,t.jsx)("div",{className:"px-4 pt-4 pb-3",children:(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:"flex items-center gap-2 mb-1.5",children:(0,t.jsxs)("span",{className:`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${n.bg} ${n.text}`,children:[(0,t.jsx)("span",{className:`w-1.5 h-1.5 rounded-full ${n.dot}`}),n.label]})}),(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:e.alias??e.server_name??e.server_id}),e.description&&(0,t.jsx)("p",{className:"text-xs text-gray-500 mt-0.5 line-clamp-1",children:e.description}),e.url&&(0,t.jsxs)("div",{className:"flex items-center gap-1.5 mt-1.5",children:[(0,t.jsx)(P.ServerIcon,{className:"h-3.5 w-3.5 text-gray-400 flex-shrink-0"}),(0,t.jsx)("code",{className:"text-xs text-gray-500 font-mono truncate",children:e.url})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3 mt-1.5 text-xs text-gray-400",children:[(0,t.jsxs)("span",{children:["Transport: ",(0,t.jsx)("span",{className:"text-gray-600",children:e.transport??"sse"})]}),(0,t.jsx)("span",{children:"·"}),(0,t.jsxs)("span",{children:["Submitted by: ",(0,t.jsx)("span",{className:"text-gray-600",children:e.submitted_by??"—"})]}),(0,t.jsx)("span",{children:"·"}),(0,t.jsx)("span",{children:function(e){if(!e)return"—";try{let t=new Date(e);return isNaN(t.getTime())?e:t.toISOString().slice(0,10)}catch{return e}}(e.submitted_at)})]}),"rejected"===a&&e.review_notes&&(0,t.jsxs)("p",{className:"text-xs text-red-600 mt-1.5",children:["Rejection reason: ",e.review_notes]})]}),0===i.length&&"rejected"!==a&&(0,t.jsxs)("div",{className:"flex items-center gap-2 flex-shrink-0",children:["active"!==a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Approve"}),(0,t.jsx)("button",{type:"button",onClick:r,className:"text-xs border border-red-300 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-md transition-colors font-medium",children:"Reject"})]}),0===i.length&&"rejected"===a&&(0,t.jsx)("div",{className:"flex items-center gap-2 flex-shrink-0",children:(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Re-approve"})})]})}),i.length>0&&(0,t.jsxs)("div",{className:"border-t border-gray-200",children:[(0,t.jsxs)("div",{className:`flex items-center gap-3 px-4 py-3 ${d?"bg-green-50 border-b border-green-100":"bg-red-50 border-b border-red-100"}`,children:[(0,t.jsx)("div",{className:`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${d?"bg-green-500":"bg-red-500"}`,children:d?(0,t.jsx)(k.CheckIcon,{className:"h-4 w-4 text-white"}):(0,t.jsx)(A.XIcon,{className:"h-4 w-4 text-white"})}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:`text-sm font-semibold leading-tight ${d?"text-green-800":"text-red-800"}`,children:d?"All checks passed":`${c} check${1!==c?"s":""} failed`}),(0,t.jsxs)("div",{className:"text-xs text-gray-500 mt-0.5",children:[o," passing, ",c," failing"]})]}),(0,t.jsxs)("div",{className:"flex items-center gap-2 flex-shrink-0",children:["active"!==a&&"rejected"!==a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Approve"}),"rejected"===a&&(0,t.jsx)("button",{type:"button",onClick:s,className:"text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Re-approve"}),"rejected"!==a&&(0,t.jsx)("button",{type:"button",onClick:r,className:"text-xs border border-red-300 text-red-600 hover:bg-red-50 bg-white px-3 py-1.5 rounded-md transition-colors font-medium",children:"Reject"})]})]}),(0,t.jsx)("div",{className:"divide-y divide-gray-100",children:i.map(e=>(0,t.jsxs)("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[(0,t.jsx)("div",{className:`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${e.passed?"bg-green-100":"bg-red-100"}`,children:e.passed?(0,t.jsx)(k.CheckIcon,{className:"h-3 w-3 text-green-600"}):(0,t.jsx)(A.XIcon,{className:"h-3 w-3 text-red-600"})}),(0,t.jsx)("span",{className:`text-sm flex-1 ${e.passed?"text-gray-700":"text-gray-800"}`,children:e.label}),(0,t.jsx)("span",{className:`text-xs ${e.passed?"text-green-600":"text-red-500"}`,children:e.passed?"Passes":"Missing"})]},e.key))})]})]})}function $({accessToken:e}){let[s,r]=(0,b.useState)({total:0,pending_review:0,active:0,rejected:0,items:[]}),[l,a]=(0,b.useState)(""),[n,i]=(0,b.useState)("all"),[o,c]=(0,b.useState)(null),[d,m]=(0,b.useState)(!0),[u,x]=(0,b.useState)(null),[h,p]=(0,b.useState)([]),[g,f]=(0,b.useState)(!1),j=(0,b.useCallback)(async()=>{if(!e)return void m(!1);m(!0),x(null);try{let[t,s]=await Promise.all([(0,_.fetchMCPSubmissions)(e),(0,_.getGeneralSettingsCall)(e).catch(e=>(console.warn("MCPSubmissionsTab: failed to load general settings, compliance rules will be empty:",e),null))]);if(r(t),s?.data&&Array.isArray(s.data)){let e=s.data.find(e=>e.field_name===R);e&&Array.isArray(e.field_value)&&p(e.field_value)}}catch(e){x(e instanceof Error?e.message:"Failed to load submissions")}finally{m(!1)}},[e]);(0,b.useEffect)(()=>{j()},[j]);let y=async()=>{if(e){f(!0);try{await (0,_.updateConfigFieldSetting)(e,R,h),C.default.success("Submission rules saved")}catch{C.default.fromBackend("Failed to save submission rules")}finally{f(!1)}}},v=s.items.filter(e=>{if("all"!==n&&e.approval_status!==n)return!1;if(l.trim()){let t=l.toLowerCase(),s=(e.alias??e.server_name??e.server_id??"").toLowerCase(),r=(e.url??"").toLowerCase();return s.includes(t)||r.includes(t)}return!0});async function N(t,s){if(e)try{await (0,_.approveMCPServer)(e,t),await j(),C.default.success(`MCP server "${s}" approved`)}catch{C.default.fromBackend("Failed to approve MCP server")}finally{c(null)}}async function w(t,s,r){if(e)try{await (0,_.rejectMCPServer)(e,t,r),await j(),C.default.success(`MCP server "${s}" rejected`)}catch{C.default.fromBackend("Failed to reject MCP server")}finally{c(null)}}return(0,t.jsxs)("div",{className:"p-6",children:[(0,t.jsx)(q,{requiredFields:h,onChange:p,onSave:y,isSaving:g}),(0,t.jsxs)("div",{className:"grid grid-cols-4 gap-4 mb-6",children:[(0,t.jsx)(U,{label:"Total Submitted",value:s.total,color:"text-gray-900"}),(0,t.jsx)(U,{label:"Pending Review",value:s.pending_review,color:"text-yellow-600"}),(0,t.jsx)(U,{label:"Active",value:s.active,color:"text-green-600"}),(0,t.jsx)(U,{label:"Rejected",value:s.rejected,color:"text-red-600"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-5",children:[(0,t.jsxs)("div",{className:"relative flex-1 max-w-xs",children:[(0,t.jsx)(T.SearchIcon,{className:"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"}),(0,t.jsx)("input",{type:"text",placeholder:"Search MCP servers...",value:l,onChange:e=>a(e.target.value),className:"w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"})]}),(0,t.jsxs)("select",{value:n,onChange:e=>i(e.target.value),className:"border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white",children:[(0,t.jsx)("option",{value:"all",children:"All Status"}),(0,t.jsx)("option",{value:"pending_review",children:"Pending Review"}),(0,t.jsx)("option",{value:"active",children:"Active"}),(0,t.jsx)("option",{value:"rejected",children:"Rejected"})]})]}),(0,t.jsxs)("div",{className:"space-y-3",children:[d&&(0,t.jsx)("div",{className:"text-center py-12 text-gray-500 text-sm",children:"Loading submissions…"}),u&&(0,t.jsx)("div",{className:"text-center py-12 text-red-600 text-sm",children:u}),!d&&!u&&0===v.length&&(0,t.jsx)("div",{className:"text-center py-12 text-gray-400 text-sm",children:"No MCP server submissions match your filters."}),!d&&!u&&v.map(e=>(0,t.jsx)(V,{server:e,requiredFields:h,onApprove:()=>c({serverId:e.server_id,serverName:e.alias??e.server_name??e.server_id,action:"approve"}),onReject:()=>c({serverId:e.server_id,serverName:e.alias??e.server_name??e.server_id,action:"reject",isCurrentlyActive:"active"===e.approval_status})},e.server_id))]}),o&&(0,t.jsx)(B,{action:o.action,serverName:o.serverName,isCurrentlyActive:o.isCurrentlyActive,onConfirm:e=>"approve"===o.action?N(o.serverId,o.serverName):w(o.serverId,o.serverName,e),onCancel:()=>c(null)})]})}var D=e.i(808613),H=e.i(311451),K=e.i(998573),W=e.i(482725),J=e.i(988297),Y=e.i(797672),G=e.i(68155),Q=e.i(699857),Z=e.i(149121);let{Text:X}=f.Typography;function ee({serverId:e,serverName:s,accessToken:r,selectedTools:l,onToggle:a}){let[n,i]=(0,b.useState)([]),[o,c]=(0,b.useState)(!1),[d,m]=(0,b.useState)(!1),u=new Set(l.filter(t=>t.server_id===e).map(e=>e.tool_name)),x=(0,b.useCallback)(async()=>{if(r&&!(n.length>0)){c(!0);try{let t=await (0,_.listMCPTools)(r,e),s=Array.isArray(t)?t:t?.tools??[];i(s.map(e=>({name:e.name??e.tool_name??e,description:e.description??""})))}catch{i([])}finally{c(!1)}}},[r,e,n.length]);return(0,t.jsxs)("div",{className:"border border-gray-200 rounded-lg overflow-hidden",children:[(0,t.jsxs)("button",{type:"button",className:"w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors",onClick:()=>{d||x(),m(!d)},children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center gap-2",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"}),s,u.size>0&&(0,t.jsxs)("span",{className:"ml-1 text-xs text-purple-600 font-semibold",children:[u.size," selected"]})]}),(0,t.jsx)("span",{className:"text-gray-400 text-xs",children:d?"▲":"▼"})]}),d&&(0,t.jsx)("div",{className:"p-2",children:o?(0,t.jsx)("div",{className:"flex justify-center py-3",children:(0,t.jsx)(W.Spin,{size:"small"})}):0===n.length?(0,t.jsx)("p",{className:"text-xs text-gray-400 px-2 py-2",children:"No tools found for this server."}):(0,t.jsx)("div",{className:"flex flex-col gap-1",children:n.map(s=>{let r=u.has(s.name);return(0,t.jsxs)("button",{type:"button",onClick:()=>a({server_id:e,tool_name:s.name}),className:`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${r?"bg-purple-50 border border-purple-300":"bg-white border border-gray-100 hover:bg-gray-50"}`,children:[(0,t.jsxs)("div",{className:"min-w-0 flex-1",children:[(0,t.jsx)("p",{className:`text-sm font-medium leading-tight ${r?"text-purple-800":"text-gray-800"}`,children:s.name}),s.description&&(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2",children:s.description})]}),r&&(0,t.jsx)("span",{className:"text-purple-500 text-xs font-semibold ml-2 flex-shrink-0 mt-0.5",children:"✓"})]},s.name)})})})]})}function et({open:e,onClose:s,onSave:r,accessToken:a,initialToolset:n}){let[i]=D.Form.useForm(),[o,c]=(0,b.useState)(n?.tools||[]),[m,u]=(0,b.useState)(!1),[x,p]=(0,b.useState)(""),{data:g=[]}=(0,j.useMCPServers)();b.default.useEffect(()=>{e&&(i.setFieldsValue({toolset_name:n?.toolset_name||"",description:n?.description||""}),c(n?.tools||[]),p(""))},[e,n]);let f=e=>{c(t=>t.some(t=>t.server_id===e.server_id&&t.tool_name===e.tool_name)?t.filter(t=>t.server_id!==e.server_id||t.tool_name!==e.tool_name):[...t,e])},y=async()=>{let e=await i.validateFields();u(!0);try{await r(e.toolset_name,e.description,o),s()}finally{u(!1)}},v=g.filter(e=>{let t=x.toLowerCase();return!t||(e.alias||"").toLowerCase().includes(t)||(e.server_name||"").toLowerCase().includes(t)});return(0,t.jsxs)(h.Modal,{open:e,onCancel:s,title:n?"Edit Toolset":"New Toolset",width:960,footer:null,forceRender:!0,children:[(0,t.jsx)(D.Form,{form:i,layout:"vertical",className:"mt-2",children:(0,t.jsxs)("div",{className:"flex gap-4 mb-4",children:[(0,t.jsx)(D.Form.Item,{label:"Toolset Name",name:"toolset_name",rules:[{required:!0,message:"Please enter a toolset name"}],className:"flex-1 mb-0",children:(0,t.jsx)(H.Input,{placeholder:"e.g. github-linear-tools"})}),(0,t.jsx)(D.Form.Item,{label:"Description",name:"description",className:"flex-1 mb-0",children:(0,t.jsx)(H.Input,{placeholder:"Optional description"})})]})}),(0,t.jsxs)("div",{className:"flex gap-4 mt-2",style:{minHeight:360},children:[(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("div",{className:"flex items-center justify-between mb-2",children:(0,t.jsx)(d.Text,{className:"text-sm font-semibold text-gray-700",children:"Available Tools"})}),(0,t.jsx)(H.Input,{placeholder:"Search MCP servers...",value:x,onChange:e=>p(e.target.value),className:"mb-2",allowClear:!0}),(0,t.jsx)("div",{className:"space-y-2 overflow-y-auto",style:{maxHeight:300},children:0===v.length?(0,t.jsx)(d.Text,{className:"text-gray-400 text-sm",children:0===g.length?"No MCP servers configured":"No servers match your search"}):v.map(e=>(0,t.jsx)(ee,{serverId:e.server_id,serverName:e.alias||e.server_name||e.server_id,accessToken:a,selectedTools:o,onToggle:f},e.server_id))})]}),(0,t.jsx)("div",{className:"w-px bg-gray-200 flex-shrink-0"}),(0,t.jsxs)("div",{className:"w-72 flex-shrink-0",children:[(0,t.jsxs)(d.Text,{className:"text-sm font-semibold text-gray-700 mb-2 block",children:["Your Toolset"," ",(0,t.jsxs)("span",{className:"text-xs font-normal text-gray-400",children:["(",o.length," tools)"]})]}),(0,t.jsx)("div",{className:"space-y-1 overflow-y-auto",style:{maxHeight:340},children:0===o.length?(0,t.jsx)(d.Text,{className:"text-gray-400 text-sm",children:"No tools added yet"}):o.map((e,s)=>(0,t.jsxs)("button",{type:"button",onClick:()=>f(e),className:"w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors",children:[(0,t.jsxs)("div",{className:"min-w-0 text-left",children:[(0,t.jsx)("span",{className:"text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block",children:e.tool_name}),(0,t.jsxs)("span",{className:"text-[10px] text-purple-400 truncate block",children:[e.server_id.slice(0,8),"…"]})]}),(0,t.jsx)("span",{className:"ml-2 text-purple-300 group-hover:text-red-400 text-xs flex-shrink-0",children:"✕"})]},s))})]})]}),(0,t.jsxs)("div",{className:"flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200",children:[(0,t.jsx)(l.Button,{variant:"secondary",onClick:s,children:"Cancel"}),(0,t.jsx)(l.Button,{onClick:y,loading:m,children:n?"Save Changes":"Create Toolset"})]})]})}function es(){let[e,s]=(0,b.useState)(!1),r=(0,_.getProxyBaseUrl)(),l=`{ +>>>>>>>> origin/litellm_internal_staging:litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js "mcpServers": { "my-toolset": { "url": "${r}/toolset//mcp", "headers": { "x-litellm-api-key": "Bearer " } } } +<<<<<<<< HEAD:litellm/proxy/_experimental/out/_next/static/chunks/0279e5299e9f6e98.js +}`,a=async()=>{try{await navigator.clipboard.writeText(l),s(!0),setTimeout(()=>s(!1),1500)}catch{}};return(0,t.jsxs)("div",{className:"mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4",children:[(0,t.jsx)("p",{className:"text-sm font-medium text-gray-700 mb-1",children:"How toolsets work"}),(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-3",children:["Create a toolset, assign it to a key via ",(0,t.jsx)("span",{className:"font-medium text-gray-700",children:"API Keys → Edit Key → MCP Servers"}),", then point your MCP client at the toolset URL. The client only sees the tools you picked."]}),(0,t.jsx)("div",{className:"text-xs text-gray-400 mb-1",children:"Claude Code / Cursor config"}),(0,t.jsxs)("div",{className:"relative",children:[(0,t.jsx)("pre",{className:"bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14",children:l}),(0,t.jsx)("button",{type:"button",onClick:a,className:"absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors",children:e?"✓":"copy"})]})]})}function er({accessToken:e,userRole:s}){let r=(0,v.useQueryClient)(),{data:a=[],isLoading:n}=(0,Q.useMCPToolsets)(),[i,o]=(0,b.useState)(!1),[c,u]=(0,b.useState)(null),[x,p]=(0,b.useState)(null),[g,f]=(0,b.useState)(!1),j="Admin"===s||"proxy_admin"===s,y=async(t,s,l)=>{e&&(await (0,_.createMCPToolset)(e,{toolset_name:t,description:s,tools:l}),K.message.success("Toolset created"),r.invalidateQueries({queryKey:["mcpToolsets"]}))},N=async(t,s,l)=>{e&&c&&(await (0,_.updateMCPToolset)(e,{toolset_id:c.toolset_id,toolset_name:t,description:s,tools:l}),K.message.success("Toolset updated"),r.invalidateQueries({queryKey:["mcpToolsets"]}),u(null))},w=async()=>{if(e&&x){f(!0);try{await (0,_.deleteMCPToolset)(e,x),K.message.success("Toolset deleted"),r.invalidateQueries({queryKey:["mcpToolsets"]}),p(null)}finally{f(!1)}}},S=(0,_.getProxyBaseUrl)(),C=[{header:"Toolset ID",accessorKey:"toolset_id",cell:({row:e})=>(0,t.jsxs)("span",{className:"font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600",children:[e.original.toolset_id.slice(0,8),"…"]})},{header:"Name",accessorKey:"toolset_name",cell:({row:e})=>{let s=`${S}/toolset/${e.original.toolset_name}/mcp`;return(0,t.jsxs)("div",{className:"flex flex-col gap-0.5",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 rounded-full bg-purple-500 flex-shrink-0"}),(0,t.jsx)("span",{className:"font-medium text-gray-900",children:e.original.toolset_name})]}),(0,t.jsx)("button",{type:"button",className:"text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors",onClick:()=>navigator.clipboard.writeText(s),title:"Click to copy endpoint URL",children:s})]})}},{header:"Description",accessorKey:"description",cell:({row:e})=>(0,t.jsx)("span",{className:"text-sm text-gray-500",children:e.original.description||"—"})},{header:"Tools",accessorKey:"tools",cell:({row:e})=>{let s=e.original.tools;return(0,t.jsxs)("div",{className:"flex flex-wrap gap-1 max-w-xs",children:[s.slice(0,4).map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs",children:e.tool_name},s)),s.length>4&&(0,t.jsxs)("span",{className:"text-xs text-gray-400 self-center",children:["+",s.length-4," more"]})]})}},{header:"Created",accessorKey:"created_at",cell:({row:e})=>(0,t.jsx)("span",{className:"text-xs text-gray-500",children:e.original.created_at?new Date(e.original.created_at).toLocaleDateString():"—"})},...j?[{header:"",id:"actions",cell:({row:e})=>(0,t.jsxs)("div",{className:"flex items-center gap-1 justify-end",children:[(0,t.jsx)("button",{type:"button",className:"p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors",onClick:()=>u(e.original),children:(0,t.jsx)(Y.PencilIcon,{className:"h-4 w-4"})}),(0,t.jsx)("button",{type:"button",className:"p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors",onClick:()=>p(e.original.toolset_id),children:(0,t.jsx)(G.TrashIcon,{className:"h-4 w-4"})})]})}]:[]];return(0,t.jsxs)("div",{className:"mt-4",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(m.Title,{children:"MCP Toolsets"}),(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm",children:"Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown."})]}),j&&(0,t.jsx)(l.Button,{icon:J.PlusIcon,onClick:()=>o(!0),children:"New Toolset"})]}),(0,t.jsx)(es,{}),(0,t.jsx)(Z.DataTable,{data:a,columns:C,renderSubComponent:()=>(0,t.jsx)("div",{}),getRowCanExpand:()=>!1,isLoading:n,noDataMessage:"No toolsets yet. Click 'New Toolset' to create one.",loadingMessage:"Loading toolsets...",enableSorting:!0}),(0,t.jsx)(et,{open:i,onClose:()=>o(!1),onSave:y,accessToken:e}),c&&(0,t.jsx)(et,{open:!!c,onClose:()=>u(null),onSave:N,accessToken:e,initialToolset:c}),(0,t.jsx)(h.Modal,{open:!!x,onCancel:()=>p(null),onOk:w,okText:"Delete",okButtonProps:{danger:!0,loading:g},title:"Delete Toolset",children:(0,t.jsx)("p",{children:"Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools."})})]})}var el=e.i(790848),ea=e.i(362024),en=e.i(827252),ei=e.i(779241),eo=e.i(292335),ec=e.i(28651);let ed="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",em=({label:e,tooltip:s})=>(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:[e,(0,t.jsx)(g.Tooltip,{title:s,children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),eu=({isM2M:e,isEditing:s=!1,oauthFlow:r,initialFlowType:a,docsUrl:n})=>{let i=s?" (leave blank to keep existing)":"";return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"OAuth Flow Type",tooltip:"Choose how the proxy authenticates with this MCP server. M2M is for server-to-server communication using client credentials. Interactive (PKCE) is for user-facing flows that require browser-based authorization."}),name:"oauth_flow_type",...a?{initialValue:a}:{},children:(0,t.jsxs)(p.Select,{className:"rounded-lg",size:"large",children:[(0,t.jsx)(p.Select.Option,{value:eo.OAUTH_FLOW.M2M,children:(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"font-medium",children:"Machine-to-Machine (M2M)"}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-2",children:"server-to-server, no user interaction"})]})}),(0,t.jsx)(p.Select.Option,{value:eo.OAUTH_FLOW.INTERACTIVE,children:(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"font-medium",children:"Interactive (PKCE)"}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-2",children:"browser-based user authorization"})]})})]})}),e?(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client ID",tooltip:"OAuth2 client ID for the client_credentials grant."}),name:["credentials","client_id"],rules:[{required:!0,message:"Client ID is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter OAuth client ID${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client Secret",tooltip:"OAuth2 client secret for the client_credentials grant."}),name:["credentials","client_secret"],rules:[{required:!0,message:"Client Secret is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter OAuth client secret${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token URL",tooltip:"Token endpoint URL for the client_credentials grant."}),name:"token_url",rules:[{required:!0,message:"Token URL is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{placeholder:"https://auth.example.com/oauth/token",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Scopes (optional)",tooltip:"Optional scopes to request with the client_credentials grant."}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})})]}):(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"flex items-center justify-between w-full",children:[(0,t.jsx)(em,{label:"Client ID (optional)",tooltip:"Provide only if your MCP server cannot handle dynamic client registration."}),n&&(0,t.jsx)("a",{href:n,target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-500 hover:text-blue-700 ml-2 font-normal",onClick:e=>e.stopPropagation(),children:"Create OAuth App →"})]}),name:["credentials","client_id"],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter client ID${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client Secret (optional)",tooltip:"Provide only if your MCP server cannot handle dynamic client registration."}),name:["credentials","client_secret"],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter client secret${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Scopes (optional)",tooltip:"Optional scopes requested during token exchange. Separate multiple scopes with enter or commas."}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Authorization URL (optional)",tooltip:"Optional override for the authorization endpoint."}),name:"authorization_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/authorize",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token URL (optional)",tooltip:"Optional override for the token endpoint."}),name:"token_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/token",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Registration URL (optional)",tooltip:"Optional override for the dynamic client registration endpoint."}),name:"registration_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/register",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token Validation Rules (optional)",tooltip:'JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.'}),name:"token_validation_json",rules:[{validator:(e,t)=>{if(!t||""===t.trim())return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject(Error("Must be valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:'{\n "organization": "my-org",\n "team.id": "123"\n}',rows:4,className:"font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token Storage TTL (seconds, optional)",tooltip:"How long to cache each user's OAuth access token in Redis before evicting it (regardless of the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default."}),name:"token_storage_ttl_seconds",children:(0,t.jsx)(ec.InputNumber,{min:1,placeholder:"e.g. 3600",className:"w-full rounded-lg",style:{width:"100%"}})}),r&&(0,t.jsxs)("div",{className:"rounded-lg border border-dashed border-gray-300 p-4 space-y-2",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value."}),(0,t.jsx)(l.Button,{variant:"secondary",onClick:r.startOAuthFlow,disabled:"authorizing"===r.status||"exchanging"===r.status,children:"authorizing"===r.status?"Waiting for authorization...":"exchanging"===r.status?"Exchanging authorization code...":"Authorize & Fetch Token"}),r.error&&(0,t.jsx)("p",{className:"text-sm text-red-500",children:r.error}),"success"===r.status&&r.tokenResponse?.access_token&&(0,t.jsxs)("p",{className:"text-sm text-green-600",children:["Token fetched. Expires in ",r.tokenResponse.expires_in??"?"," seconds."]})]})]})]})};var ex=e.i(906579),eh=e.i(458505),ep=e.i(366308),eg=e.i(304967);let ef=({value:e={},onChange:s,tools:r=[],disabled:l=!1})=>(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-6",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-4",children:[(0,t.jsx)(eh.DollarOutlined,{className:"text-green-600"}),(0,t.jsx)(m.Title,{children:"Cost Configuration"}),(0,t.jsx)(g.Tooltip,{title:"Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-gray-400"})})]}),(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("label",{className:"block text-sm font-medium text-gray-700 mb-2",children:["Default Cost per Query ($)",(0,t.jsx)(g.Tooltip,{title:"Default cost charged for each tool call to this server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-1 text-gray-400"})})]}),(0,t.jsx)(ec.InputNumber,{min:0,step:1e-4,precision:4,placeholder:"0.0000",value:e.default_cost_per_query,onChange:t=>{let r={...e,default_cost_per_query:t};s?.(r)},disabled:l,style:{width:"200px"},addonBefore:"$"}),(0,t.jsx)(d.Text,{className:"block mt-1 text-gray-500 text-sm",children:"Set a default cost for all tool calls to this server"})]}),r.length>0&&(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("label",{className:"block text-sm font-medium text-gray-700",children:["Tool-Specific Costs ($)",(0,t.jsx)(g.Tooltip,{title:"Override the default cost for specific tools. Leave blank to use the default rate.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-1 text-gray-400"})})]}),(0,t.jsx)(ea.Collapse,{items:[{key:"1",label:(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsx)(ep.ToolOutlined,{className:"mr-2 text-blue-500"}),(0,t.jsx)("span",{className:"font-medium",children:"Available Tools"}),(0,t.jsx)(ex.Badge,{count:r.length,style:{backgroundColor:"#52c41a",marginLeft:"8px"}})]}),children:(0,t.jsx)("div",{className:"space-y-3 max-h-64 overflow-y-auto",children:r.map((r,a)=>(0,t.jsxs)("div",{className:"flex items-center justify-between p-3 bg-gray-50 rounded-lg",children:[(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsx)(d.Text,{className:"font-medium text-gray-900",children:r.name}),r.description&&(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm block mt-1",children:r.description})]}),(0,t.jsx)("div",{className:"ml-4",children:(0,t.jsx)(ec.InputNumber,{min:0,step:1e-4,precision:4,placeholder:"Use default",value:e.tool_name_to_cost_per_query?.[r.name],onChange:t=>{var l;let a;return l=r.name,a={...e,tool_name_to_cost_per_query:{...e.tool_name_to_cost_per_query,[l]:t}},void s?.(a)},disabled:l,style:{width:"120px"},addonBefore:"$"})})]},a))})}]})]})]}),(e.default_cost_per_query||e.tool_name_to_cost_per_query&&Object.keys(e.tool_name_to_cost_per_query).length>0)&&(0,t.jsxs)("div",{className:"mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"text-blue-800 font-medium",children:"Cost Summary:"}),(0,t.jsxs)("div",{className:"mt-2 space-y-1",children:[e.default_cost_per_query&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• Default cost: $",e.default_cost_per_query.toFixed(4)," per query"]}),e.tool_name_to_cost_per_query&&Object.entries(e.tool_name_to_cost_per_query).map(([e,s])=>null!=s&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• ",e,": $",s.toFixed(4)," per query"]},e))]})]})]})});var eb=e.i(464571),ej=e.i(560445),ey=e.i(245704),ev=e.i(270377),eN=e.i(91979);let e_=({formValues:e,tools:s,isLoadingTools:r,toolsError:l,toolsErrorStackTrace:a,canFetchTools:n,fetchTools:i})=>n||e.url||e.spec_path?(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-blue-600"}),(0,t.jsx)(m.Title,{children:"Connection Status"})]}),!n&&(e.url||e.spec_path)&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"Complete required fields to test connection"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Fill in URL, Transport, and Authentication to test MCP server connection"})]}),n&&(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-gray-700 font-medium",children:r?"Testing connection to MCP server...":s.length>0?"Connection successful":l?"Connection failed":"Ready to test connection"}),(0,t.jsx)("br",{}),(0,t.jsxs)(d.Text,{className:"text-gray-500 text-sm",children:["Server: ",e.url||e.spec_path]})]}),r&&(0,t.jsxs)("div",{className:"flex items-center text-blue-600",children:[(0,t.jsx)(W.Spin,{size:"small",className:"mr-2"}),(0,t.jsx)(d.Text,{className:"text-blue-600",children:"Connecting..."})]}),!r&&!l&&s.length>0&&(0,t.jsxs)("div",{className:"flex items-center text-green-600",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"mr-1"}),(0,t.jsx)(d.Text,{className:"text-green-600 font-medium",children:"Connected"})]}),l&&(0,t.jsxs)("div",{className:"flex items-center text-red-600",children:[(0,t.jsx)(ev.ExclamationCircleOutlined,{className:"mr-1"}),(0,t.jsx)(d.Text,{className:"text-red-600 font-medium",children:"Failed"})]})]}),r&&(0,t.jsxs)("div",{className:"flex items-center justify-center py-6",children:[(0,t.jsx)(W.Spin,{size:"large"}),(0,t.jsx)(d.Text,{className:"ml-3",children:"Testing connection and loading tools..."})]}),l&&(0,t.jsx)(ej.Alert,{message:"Connection Failed",description:(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{children:l}),a&&(0,t.jsx)(ea.Collapse,{items:[{key:"stack-trace",label:"Stack Trace",children:(0,t.jsx)("pre",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",fontSize:"12px",fontFamily:"monospace",margin:0,padding:"8px",backgroundColor:"#f5f5f5",borderRadius:"4px",maxHeight:"400px",overflow:"auto"},children:a})}],style:{marginTop:"12px"}})]}),type:"error",showIcon:!0,action:(0,t.jsx)(eb.Button,{icon:(0,t.jsx)(eN.ReloadOutlined,{}),onClick:i,size:"small",children:"Retry"})}),!r&&0===s.length&&!l&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-500 border rounded-lg border-dashed",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-2xl mb-2 text-green-500"}),(0,t.jsx)(d.Text,{className:"text-green-600 font-medium",children:"Connection successful!"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-gray-500",children:"No tools found for this MCP server"})]})]})]})}):null;var ew=e.i(928685),eS=e.i(751904),eC=e.i(536916),eT=e.i(91739);let ek=({accessToken:e,oauthAccessToken:t,formValues:s,enabled:r=!0})=>{let[l,a]=(0,b.useState)([]),[n,i]=(0,b.useState)(!1),[o,c]=(0,b.useState)(null),[d,m]=(0,b.useState)(null),[u,x]=(0,b.useState)(!1),h=s.auth_type===eo.AUTH_TYPE.OAUTH2&&s.oauth_flow_type===eo.OAUTH_FLOW.M2M,p=s.auth_type===eo.AUTH_TYPE.OAUTH2&&!h,g=s.transport===eo.TRANSPORT.OPENAPI,f=g?!!s.spec_path:!!s.url,j=g?!!(f&&e):!!(f&&s.transport&&s.auth_type&&e&&(!p||t)),y=JSON.stringify(s.static_headers??{}),v=JSON.stringify(s.credentials??{}),N=async()=>{if(e&&(s.url||s.spec_path)&&(!p||t||g)){i(!0),c(null);try{let r=Array.isArray(s.static_headers)?s.static_headers.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value!=null?String(t.value):""),e},{}):!Array.isArray(s.static_headers)&&s.static_headers&&"object"==typeof s.static_headers?Object.entries(s.static_headers).reduce((e,[t,s])=>(t&&(e[t]=null!=s?String(s):""),e),{}):{},l=s.credentials&&"object"==typeof s.credentials?Object.entries(s.credentials).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,n=s.transport===eo.TRANSPORT.OPENAPI?"http":s.transport,i={server_id:s.server_id||"",server_name:s.server_name||"",url:s.url,spec_path:s.spec_path,transport:n,auth_type:s.auth_type,authorization_url:s.authorization_url,token_url:s.token_url,registration_url:s.registration_url,mcp_info:s.mcp_info,static_headers:r};l&&Object.keys(l).length>0&&(i.credentials=l);let o=await (0,_.testMCPToolsListRequest)(e,i,t);if(o.tools&&!o.error)a(o.tools),c(null),m(null),o.tools.length>0&&!u&&x(!0);else{let e=o.message||"Failed to retrieve tools list";c(e),m(o.stack_trace||null),a([]),x(!1)}}catch(e){console.error("Tools fetch error:",e),c(e instanceof Error?e.message:String(e)),m(null),a([]),x(!1)}finally{i(!1)}}},w=()=>{a([]),c(null),m(null),x(!1)};return(0,b.useEffect)(()=>{r&&(j?N():w())},[s.url,s.spec_path,s.transport,s.auth_type,e,r,t,j,y,v]),{tools:l,isLoadingTools:n,toolsError:o,toolsErrorStackTrace:d,hasShownSuccessMessage:u,canFetchTools:j,fetchTools:N,clearTools:w}};var eA=e.i(531516);let eI=({tool:e,isEnabled:s,isEditExpanded:r,toolNameToDisplayName:l,toolNameToDescription:a,onToggle:n,onToggleExpand:i,onDisplayNameChange:o,onDescriptionChange:c})=>(0,t.jsxs)("div",{className:`rounded-lg border transition-colors ${s?"bg-blue-50 border-blue-300 hover:border-blue-400":"bg-gray-50 border-gray-200 hover:border-gray-300"}`,children:[(0,t.jsx)("div",{className:"p-4 cursor-pointer",onClick:()=>n(e.name),children:(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t.jsx)(eC.Checkbox,{checked:s,onChange:()=>n(e.name)}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"font-medium text-gray-900",children:l[e.name]||e.name}),(0,t.jsx)("span",{className:`px-2 py-0.5 text-xs rounded-full font-medium ${s?"bg-green-100 text-green-800":"bg-red-100 text-red-800"}`,children:s?"Enabled":"Disabled"}),l[e.name]&&(0,t.jsx)("span",{className:"px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800",children:"Custom name"})]}),(a[e.name]||e.description)&&(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm block mt-1",children:a[e.name]||e.description}),(0,t.jsx)(d.Text,{className:"text-gray-400 text-xs block mt-1",children:s?"✓ Users can call this tool":"✗ Users cannot call this tool"})]}),(0,t.jsx)("button",{type:"button",onClick:t=>i(e.name,t),className:`p-1.5 rounded-md transition-colors ${r?"bg-blue-100 text-blue-600":"text-gray-400 hover:text-gray-600 hover:bg-gray-100"}`,title:"Edit display name and description",children:(0,t.jsx)(eS.EditOutlined,{})})]})}),r&&(0,t.jsxs)("div",{className:"px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg",onClick:e=>e.stopPropagation(),children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-600 mb-1 block",children:"Display Name"}),(0,t.jsx)(H.Input,{placeholder:e.name,value:l[e.name]||"",onChange:t=>o(e.name,t.target.value)}),(0,t.jsx)(d.Text,{className:"text-xs text-gray-400 mt-1 block",children:"Override how this tool's name appears to users. Leave blank to use original."})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-600 mb-1 block",children:"Description"}),(0,t.jsx)(H.Input.TextArea,{placeholder:e.description||"No description",value:a[e.name]||"",onChange:t=>c(e.name,t.target.value),rows:2}),(0,t.jsx)(d.Text,{className:"text-xs text-gray-400 mt-1 block",children:"Override the tool description shown to users. Leave blank to use original."})]})]})]}),eP=({accessToken:e,oauthAccessToken:s,formValues:r,allowedTools:l,existingAllowedTools:a,onAllowedToolsChange:n,toolNameToDisplayName:i,toolNameToDescription:o,onToolNameToDisplayNameChange:c,onToolNameToDescriptionChange:u,keyTools:x,externalTools:h,externalIsLoading:p,externalError:g,externalCanFetch:f})=>{let j=(0,b.useRef)([]),[y,v]=(0,b.useState)(""),[N,_]=(0,b.useState)("crud"),w=(0,b.useRef)(!1),S=(0,b.useRef)(""),[C,T]=(0,b.useState)(new Set),k=void 0!==h,A=ek({accessToken:e,oauthAccessToken:s,formValues:r,enabled:!k}),I=k?h:A.tools,P=k?p??!1:A.isLoadingTools,O=k?g??null:A.toolsError,M=k?f??!1:A.canFetchTools,F=(0,b.useMemo)(()=>{if(!x||0===x.length||0===I.length)return[];let e=new Set,t=[];for(let s of x){let r=s.name.split("_").map(e=>e.toLowerCase()).filter(e=>e.length>1);if(0===r.length)continue;let l=e=>e.toLowerCase().replace(/[-_/]/g," "),a=I.find(t=>{if(e.has(t.name))return!1;let s=l(t.name);return r.every(e=>s.includes(e))});if(!a){let t=r.find(e=>e.length>3)??r[r.length-1];a=I.find(s=>!e.has(s.name)&&l(s.name).includes(t))}a&&(t.push(a),e.add(a.name))}return t},[x,I]),E=(0,b.useMemo)(()=>new Set(F.map(e=>e.name)),[F]),L=(0,b.useMemo)(()=>I.filter(e=>{let t=y.toLowerCase();return e.name.toLowerCase().includes(t)||e.description&&e.description.toLowerCase().includes(t)}),[I,y]),R=(0,b.useMemo)(()=>L.filter(e=>E.has(e.name)),[L,E]),U=(0,b.useMemo)(()=>L.filter(e=>!E.has(e.name)),[L,E]);(0,b.useEffect)(()=>{let e=I.map(e=>e.name).sort().join(","),t=j.current.map(e=>e.name).sort().join(","),s=F.map(e=>e.name).sort().join(",");if(s!==S.current&&(S.current=s,""!==s&&(w.current=!1)),I.length>0&&e!==t){let e=I.map(e=>e.name);w.current?n(l.filter(t=>e.includes(t))):(w.current=!0,a&&a.length>0?n(a.filter(t=>e.includes(t))):F.length>0?n(F.map(e=>e.name).filter(t=>e.includes(t))):n(e))}j.current=I},[I,l,a,n,F]);let z=e=>{l.includes(e)?n(l.filter(t=>t!==e)):n([...l,e])},B=(e,t)=>{t.stopPropagation(),T(t=>{let s=new Set(t);return s.has(e)?s.delete(e):s.add(e),s})},q=(e,t)=>{let s={...i};t?s[e]=t:delete s[e],c(s)},V=(e,t)=>{let s={...o};t?s[e]=t:delete s[e],u(s)};return M||r.url||r.spec_path?(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-blue-600"}),(0,t.jsx)(m.Title,{children:"Tool Configuration"}),I.length>0&&(0,t.jsx)(ex.Badge,{count:I.length,style:{backgroundColor:"#52c41a"}})]}),I.length>0&&(0,t.jsx)(eT.Radio.Group,{value:N,onChange:e=>_(e.target.value),size:"small",optionType:"button",buttonStyle:"solid",options:[{label:"Risk Groups",value:"crud"},{label:"Flat List",value:"flat"}]})]}),(0,t.jsx)("div",{className:"bg-blue-50 border border-blue-200 rounded-lg p-3",children:(0,t.jsxs)(d.Text,{className:"text-blue-800 text-sm",children:[(0,t.jsx)("strong",{children:"Select which tools users can call:"})," Only checked tools will be available for users to invoke. Unchecked tools will be blocked from execution."]})}),P&&(0,t.jsxs)("div",{className:"flex items-center justify-center py-6",children:[(0,t.jsx)(W.Spin,{size:"large"}),(0,t.jsx)(d.Text,{className:"ml-3",children:"Loading tools from spec..."})]}),O&&!P&&(0,t.jsxs)("div",{className:"text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{className:"text-red-600 font-medium",children:"Unable to load tools"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm text-red-500",children:O})]}),!P&&!O&&0===I.length&&M&&(x&&x.length>0?(0,t.jsxs)("div",{className:"text-center py-4 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"No tools loaded from spec"}),(0,t.jsxs)(d.Text,{className:"text-sm block mt-1",children:["Expected tools: ",x.map(e=>e.name).join(", ")]})]}):(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"No tools available for configuration"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Connect to an MCP server with tools to configure them"})]})),!M&&(r.url||r.spec_path)&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"Complete required fields to configure tools"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Fill in URL, Transport, and Authentication to load available tools"})]}),!P&&!O&&I.length>0&&(0,t.jsxs)("div",{className:"space-y-3",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-green-600"}),(0,t.jsxs)(d.Text,{className:"text-green-700 font-medium",children:[l.length," of ",I.length," ",1===I.length?"tool":"tools"," enabled for user access"]})]}),(0,t.jsx)(H.Input,{placeholder:"Search tools by name or description...",prefix:(0,t.jsx)(ew.SearchOutlined,{className:"text-gray-400"}),value:y,onChange:e=>v(e.target.value),allowClear:!0,className:"rounded-lg",size:"large"}),"crud"===N&&(0,t.jsx)(eA.default,{tools:I,searchFilter:y,value:0===l.length?void 0:l,onChange:e=>n(e)}),"flat"===N&&(0,t.jsx)(t.Fragment,{children:0===L.length?(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ew.SearchOutlined,{className:"text-2xl mb-2"}),(0,t.jsxs)(d.Text,{children:['No tools found matching "',y,'"']})]}):(0,t.jsxs)("div",{className:"space-y-2",children:[R.length>0&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-1",children:[(0,t.jsx)("p",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wide",children:"Suggested tools"}),(0,t.jsxs)("div",{className:"flex gap-2",children:[(0,t.jsx)("button",{type:"button",onClick:()=>{let e=F.map(e=>e.name);n([...l.filter(e=>!E.has(e)),...e])},className:"text-xs text-blue-600 hover:text-blue-700",children:"Enable all"}),(0,t.jsx)("button",{type:"button",onClick:()=>{n(l.filter(e=>!E.has(e)))},className:"text-xs text-gray-500 hover:text-gray-700",children:"Disable all"})]})]}),R.map(e=>(0,t.jsx)(eI,{tool:e,isEnabled:l.includes(e.name),isEditExpanded:C.has(e.name),toolNameToDisplayName:i,toolNameToDescription:o,onToggle:z,onToggleExpand:B,onDisplayNameChange:q,onDescriptionChange:V},e.name))]}),U.length>0&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-1 pt-2",children:[(0,t.jsx)("p",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wide",children:R.length>0?"All tools":"Tools"}),(0,t.jsxs)("div",{className:"flex gap-2",children:[(0,t.jsx)("button",{type:"button",onClick:()=>{let e=I.filter(e=>!E.has(e.name)).map(e=>e.name),t=new Set(l);n([...l,...e.filter(e=>!t.has(e))])},className:"text-xs text-blue-600 hover:text-blue-700",children:"Enable all"}),(0,t.jsx)("button",{type:"button",onClick:()=>{n(l.filter(e=>E.has(e)))},className:"text-xs text-gray-500 hover:text-gray-700",children:"Disable all"})]})]}),U.map(e=>(0,t.jsx)(eI,{tool:e,isEnabled:l.includes(e.name),isEditExpanded:C.has(e.name),toolNameToDisplayName:i,toolNameToDescription:o,onToggle:z,onToggleExpand:B,onDisplayNameChange:q,onDescriptionChange:V},e.name))]})]})})]})]})}):null},eO=({isVisible:e,required:s=!0})=>e?(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Stdio Configuration (JSON)",(0,t.jsx)(g.Tooltip,{title:"Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"stdio_config",rules:[...s?[{required:!0,message:"Please enter stdio configuration"}]:[],{validator:(e,t)=>{if(!t)return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject("Please enter valid JSON")}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:`{ +======== }`,a=async()=>{try{await navigator.clipboard.writeText(l),s(!0),setTimeout(()=>s(!1),1500)}catch{}};return(0,t.jsxs)("div",{className:"mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4",children:[(0,t.jsx)("p",{className:"text-sm font-medium text-gray-700 mb-1",children:"How toolsets work"}),(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-3",children:["Create a toolset, assign it to a key via ",(0,t.jsx)("span",{className:"font-medium text-gray-700",children:"API Keys → Edit Key → MCP Servers"}),", then point your MCP client at the toolset URL. The client only sees the tools you picked."]}),(0,t.jsx)("div",{className:"text-xs text-gray-400 mb-1",children:"Claude Code / Cursor config"}),(0,t.jsxs)("div",{className:"relative",children:[(0,t.jsx)("pre",{className:"bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14",children:l}),(0,t.jsx)("button",{type:"button",onClick:a,className:"absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors",children:e?"✓":"copy"})]})]})}function er({accessToken:e,userRole:s}){let r=(0,v.useQueryClient)(),{data:a=[],isLoading:n}=(0,Q.useMCPToolsets)(),[i,o]=(0,b.useState)(!1),[c,u]=(0,b.useState)(null),[x,p]=(0,b.useState)(null),[g,f]=(0,b.useState)(!1),j="Admin"===s||"proxy_admin"===s,y=async(t,s,l)=>{e&&(await (0,_.createMCPToolset)(e,{toolset_name:t,description:s,tools:l}),K.message.success("Toolset created"),r.invalidateQueries({queryKey:["mcpToolsets"]}))},N=async(t,s,l)=>{e&&c&&(await (0,_.updateMCPToolset)(e,{toolset_id:c.toolset_id,toolset_name:t,description:s,tools:l}),K.message.success("Toolset updated"),r.invalidateQueries({queryKey:["mcpToolsets"]}),u(null))},w=async()=>{if(e&&x){f(!0);try{await (0,_.deleteMCPToolset)(e,x),K.message.success("Toolset deleted"),r.invalidateQueries({queryKey:["mcpToolsets"]}),p(null)}finally{f(!1)}}},S=(0,_.getProxyBaseUrl)(),C=[{header:"Toolset ID",accessorKey:"toolset_id",cell:({row:e})=>(0,t.jsxs)("span",{className:"font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600",children:[e.original.toolset_id.slice(0,8),"…"]})},{header:"Name",accessorKey:"toolset_name",cell:({row:e})=>{let s=`${S}/toolset/${e.original.toolset_name}/mcp`;return(0,t.jsxs)("div",{className:"flex flex-col gap-0.5",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 rounded-full bg-purple-500 flex-shrink-0"}),(0,t.jsx)("span",{className:"font-medium text-gray-900",children:e.original.toolset_name})]}),(0,t.jsx)("button",{type:"button",className:"text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors",onClick:()=>navigator.clipboard.writeText(s),title:"Click to copy endpoint URL",children:s})]})}},{header:"Description",accessorKey:"description",cell:({row:e})=>(0,t.jsx)("span",{className:"text-sm text-gray-500",children:e.original.description||"—"})},{header:"Tools",accessorKey:"tools",cell:({row:e})=>{let s=e.original.tools;return(0,t.jsxs)("div",{className:"flex flex-wrap gap-1 max-w-xs",children:[s.slice(0,4).map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs",children:e.tool_name},s)),s.length>4&&(0,t.jsxs)("span",{className:"text-xs text-gray-400 self-center",children:["+",s.length-4," more"]})]})}},{header:"Created",accessorKey:"created_at",cell:({row:e})=>(0,t.jsx)("span",{className:"text-xs text-gray-500",children:e.original.created_at?new Date(e.original.created_at).toLocaleDateString():"—"})},...j?[{header:"",id:"actions",cell:({row:e})=>(0,t.jsxs)("div",{className:"flex items-center gap-1 justify-end",children:[(0,t.jsx)("button",{type:"button",className:"p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors",onClick:()=>u(e.original),children:(0,t.jsx)(Y.PencilIcon,{className:"h-4 w-4"})}),(0,t.jsx)("button",{type:"button",className:"p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors",onClick:()=>p(e.original.toolset_id),children:(0,t.jsx)(G.TrashIcon,{className:"h-4 w-4"})})]})}]:[]];return(0,t.jsxs)("div",{className:"mt-4",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(m.Title,{children:"MCP Toolsets"}),(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm",children:"Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown."})]}),j&&(0,t.jsx)(l.Button,{icon:J.PlusIcon,onClick:()=>o(!0),children:"New Toolset"})]}),(0,t.jsx)(es,{}),(0,t.jsx)(Z.DataTable,{data:a,columns:C,renderSubComponent:()=>(0,t.jsx)("div",{}),getRowCanExpand:()=>!1,isLoading:n,noDataMessage:"No toolsets yet. Click 'New Toolset' to create one.",loadingMessage:"Loading toolsets...",enableSorting:!0}),(0,t.jsx)(et,{open:i,onClose:()=>o(!1),onSave:y,accessToken:e}),c&&(0,t.jsx)(et,{open:!!c,onClose:()=>u(null),onSave:N,accessToken:e,initialToolset:c}),(0,t.jsx)(h.Modal,{open:!!x,onCancel:()=>p(null),onOk:w,okText:"Delete",okButtonProps:{danger:!0,loading:g},title:"Delete Toolset",children:(0,t.jsx)("p",{children:"Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools."})})]})}var el=e.i(790848),ea=e.i(362024),en=e.i(827252),ei=e.i(779241),eo=e.i(292335),ec=e.i(28651);let ed="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",em=({label:e,tooltip:s})=>(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:[e,(0,t.jsx)(g.Tooltip,{title:s,children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),eu=({isM2M:e,isEditing:s=!1,oauthFlow:r,initialFlowType:a,docsUrl:n})=>{let i=s?" (leave blank to keep existing)":"";return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"OAuth Flow Type",tooltip:"Choose how the proxy authenticates with this MCP server. M2M is for server-to-server communication using client credentials. Interactive (PKCE) is for user-facing flows that require browser-based authorization."}),name:"oauth_flow_type",...a?{initialValue:a}:{},children:(0,t.jsxs)(p.Select,{className:"rounded-lg",size:"large",children:[(0,t.jsx)(p.Select.Option,{value:eo.OAUTH_FLOW.M2M,children:(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"font-medium",children:"Machine-to-Machine (M2M)"}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-2",children:"server-to-server, no user interaction"})]})}),(0,t.jsx)(p.Select.Option,{value:eo.OAUTH_FLOW.INTERACTIVE,children:(0,t.jsxs)("div",{children:[(0,t.jsx)("span",{className:"font-medium",children:"Interactive (PKCE)"}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-2",children:"browser-based user authorization"})]})})]})}),e?(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client ID",tooltip:"OAuth2 client ID for the client_credentials grant."}),name:["credentials","client_id"],rules:[{required:!0,message:"Client ID is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter OAuth client ID${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client Secret",tooltip:"OAuth2 client secret for the client_credentials grant."}),name:["credentials","client_secret"],rules:[{required:!0,message:"Client Secret is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter OAuth client secret${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token URL",tooltip:"Token endpoint URL for the client_credentials grant."}),name:"token_url",rules:[{required:!0,message:"Token URL is required for M2M OAuth"}],children:(0,t.jsx)(ei.TextInput,{placeholder:"https://auth.example.com/oauth/token",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Scopes (optional)",tooltip:"Optional scopes to request with the client_credentials grant."}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})})]}):(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"flex items-center justify-between w-full",children:[(0,t.jsx)(em,{label:"Client ID (optional)",tooltip:"Provide only if your MCP server cannot handle dynamic client registration."}),n&&(0,t.jsx)("a",{href:n,target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-500 hover:text-blue-700 ml-2 font-normal",onClick:e=>e.stopPropagation(),children:"Create OAuth App →"})]}),name:["credentials","client_id"],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter client ID${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Client Secret (optional)",tooltip:"Provide only if your MCP server cannot handle dynamic client registration."}),name:["credentials","client_secret"],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:`Enter client secret${i}`,className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Scopes (optional)",tooltip:"Optional scopes requested during token exchange. Separate multiple scopes with enter or commas."}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Authorization URL (optional)",tooltip:"Optional override for the authorization endpoint."}),name:"authorization_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/authorize",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token URL (optional)",tooltip:"Optional override for the token endpoint."}),name:"token_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/token",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Registration URL (optional)",tooltip:"Optional override for the dynamic client registration endpoint."}),name:"registration_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://example.com/oauth/register",className:ed})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token Validation Rules (optional)",tooltip:'JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.'}),name:"token_validation_json",rules:[{validator:(e,t)=>{if(!t||""===t.trim())return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject(Error("Must be valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:'{\n "organization": "my-org",\n "team.id": "123"\n}',rows:4,className:"font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)(em,{label:"Token Storage TTL (seconds, optional)",tooltip:"How long to cache each user's OAuth access token in Redis before evicting it (regardless of the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default."}),name:"token_storage_ttl_seconds",children:(0,t.jsx)(ec.InputNumber,{min:1,placeholder:"e.g. 3600",className:"w-full rounded-lg",style:{width:"100%"}})}),r&&(0,t.jsxs)("div",{className:"rounded-lg border border-dashed border-gray-300 p-4 space-y-2",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value."}),(0,t.jsx)(l.Button,{variant:"secondary",onClick:r.startOAuthFlow,disabled:"authorizing"===r.status||"exchanging"===r.status,children:"authorizing"===r.status?"Waiting for authorization...":"exchanging"===r.status?"Exchanging authorization code...":"Authorize & Fetch Token"}),r.error&&(0,t.jsx)("p",{className:"text-sm text-red-500",children:r.error}),"success"===r.status&&r.tokenResponse?.access_token&&(0,t.jsxs)("p",{className:"text-sm text-green-600",children:["Token fetched. Expires in ",r.tokenResponse.expires_in??"?"," seconds."]})]})]})]})};var ex=e.i(906579),eh=e.i(458505),ep=e.i(366308),eg=e.i(304967);let ef=({value:e={},onChange:s,tools:r=[],disabled:l=!1})=>(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-6",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-4",children:[(0,t.jsx)(eh.DollarOutlined,{className:"text-green-600"}),(0,t.jsx)(m.Title,{children:"Cost Configuration"}),(0,t.jsx)(g.Tooltip,{title:"Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-gray-400"})})]}),(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("label",{className:"block text-sm font-medium text-gray-700 mb-2",children:["Default Cost per Query ($)",(0,t.jsx)(g.Tooltip,{title:"Default cost charged for each tool call to this server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-1 text-gray-400"})})]}),(0,t.jsx)(ec.InputNumber,{min:0,step:1e-4,precision:4,placeholder:"0.0000",value:e.default_cost_per_query,onChange:t=>{let r={...e,default_cost_per_query:t};s?.(r)},disabled:l,style:{width:"200px"},addonBefore:"$"}),(0,t.jsx)(d.Text,{className:"block mt-1 text-gray-500 text-sm",children:"Set a default cost for all tool calls to this server"})]}),r.length>0&&(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("label",{className:"block text-sm font-medium text-gray-700",children:["Tool-Specific Costs ($)",(0,t.jsx)(g.Tooltip,{title:"Override the default cost for specific tools. Leave blank to use the default rate.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-1 text-gray-400"})})]}),(0,t.jsx)(ea.Collapse,{items:[{key:"1",label:(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsx)(ep.ToolOutlined,{className:"mr-2 text-blue-500"}),(0,t.jsx)("span",{className:"font-medium",children:"Available Tools"}),(0,t.jsx)(ex.Badge,{count:r.length,style:{backgroundColor:"#52c41a",marginLeft:"8px"}})]}),children:(0,t.jsx)("div",{className:"space-y-3 max-h-64 overflow-y-auto",children:r.map((r,a)=>(0,t.jsxs)("div",{className:"flex items-center justify-between p-3 bg-gray-50 rounded-lg",children:[(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsx)(d.Text,{className:"font-medium text-gray-900",children:r.name}),r.description&&(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm block mt-1",children:r.description})]}),(0,t.jsx)("div",{className:"ml-4",children:(0,t.jsx)(ec.InputNumber,{min:0,step:1e-4,precision:4,placeholder:"Use default",value:e.tool_name_to_cost_per_query?.[r.name],onChange:t=>{var l;let a;return l=r.name,a={...e,tool_name_to_cost_per_query:{...e.tool_name_to_cost_per_query,[l]:t}},void s?.(a)},disabled:l,style:{width:"120px"},addonBefore:"$"})})]},a))})}]})]})]}),(e.default_cost_per_query||e.tool_name_to_cost_per_query&&Object.keys(e.tool_name_to_cost_per_query).length>0)&&(0,t.jsxs)("div",{className:"mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"text-blue-800 font-medium",children:"Cost Summary:"}),(0,t.jsxs)("div",{className:"mt-2 space-y-1",children:[e.default_cost_per_query&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• Default cost: $",e.default_cost_per_query.toFixed(4)," per query"]}),e.tool_name_to_cost_per_query&&Object.entries(e.tool_name_to_cost_per_query).map(([e,s])=>null!=s&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• ",e,": $",s.toFixed(4)," per query"]},e))]})]})]})});var eb=e.i(464571),ej=e.i(560445),ey=e.i(245704),ev=e.i(270377),eN=e.i(91979);let e_=({formValues:e,tools:s,isLoadingTools:r,toolsError:l,toolsErrorStackTrace:a,canFetchTools:n,fetchTools:i})=>n||e.url||e.spec_path?(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-blue-600"}),(0,t.jsx)(m.Title,{children:"Connection Status"})]}),!n&&(e.url||e.spec_path)&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"Complete required fields to test connection"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Fill in URL, Transport, and Authentication to test MCP server connection"})]}),n&&(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-gray-700 font-medium",children:r?"Testing connection to MCP server...":s.length>0?"Connection successful":l?"Connection failed":"Ready to test connection"}),(0,t.jsx)("br",{}),(0,t.jsxs)(d.Text,{className:"text-gray-500 text-sm",children:["Server: ",e.url||e.spec_path]})]}),r&&(0,t.jsxs)("div",{className:"flex items-center text-blue-600",children:[(0,t.jsx)(W.Spin,{size:"small",className:"mr-2"}),(0,t.jsx)(d.Text,{className:"text-blue-600",children:"Connecting..."})]}),!r&&!l&&s.length>0&&(0,t.jsxs)("div",{className:"flex items-center text-green-600",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"mr-1"}),(0,t.jsx)(d.Text,{className:"text-green-600 font-medium",children:"Connected"})]}),l&&(0,t.jsxs)("div",{className:"flex items-center text-red-600",children:[(0,t.jsx)(ev.ExclamationCircleOutlined,{className:"mr-1"}),(0,t.jsx)(d.Text,{className:"text-red-600 font-medium",children:"Failed"})]})]}),r&&(0,t.jsxs)("div",{className:"flex items-center justify-center py-6",children:[(0,t.jsx)(W.Spin,{size:"large"}),(0,t.jsx)(d.Text,{className:"ml-3",children:"Testing connection and loading tools..."})]}),l&&(0,t.jsx)(ej.Alert,{message:"Connection Failed",description:(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{children:l}),a&&(0,t.jsx)(ea.Collapse,{items:[{key:"stack-trace",label:"Stack Trace",children:(0,t.jsx)("pre",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",fontSize:"12px",fontFamily:"monospace",margin:0,padding:"8px",backgroundColor:"#f5f5f5",borderRadius:"4px",maxHeight:"400px",overflow:"auto"},children:a})}],style:{marginTop:"12px"}})]}),type:"error",showIcon:!0,action:(0,t.jsx)(eb.Button,{icon:(0,t.jsx)(eN.ReloadOutlined,{}),onClick:i,size:"small",children:"Retry"})}),!r&&0===s.length&&!l&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-500 border rounded-lg border-dashed",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-2xl mb-2 text-green-500"}),(0,t.jsx)(d.Text,{className:"text-green-600 font-medium",children:"Connection successful!"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-gray-500",children:"No tools found for this MCP server"})]})]})]})}):null;var ew=e.i(928685),eS=e.i(751904),eC=e.i(536916),eT=e.i(91739);let ek=({accessToken:e,oauthAccessToken:t,formValues:s,enabled:r=!0})=>{let[l,a]=(0,b.useState)([]),[n,i]=(0,b.useState)(!1),[o,c]=(0,b.useState)(null),[d,m]=(0,b.useState)(null),[u,x]=(0,b.useState)(!1),h=s.auth_type===eo.AUTH_TYPE.OAUTH2&&s.oauth_flow_type===eo.OAUTH_FLOW.M2M,p=s.auth_type===eo.AUTH_TYPE.OAUTH2&&!h,g=s.transport===eo.TRANSPORT.OPENAPI,f=g?!!s.spec_path:!!s.url,j=g?!!(f&&e):!!(f&&s.transport&&s.auth_type&&e&&(!p||t)),y=JSON.stringify(s.static_headers??{}),v=JSON.stringify(s.credentials??{}),N=async()=>{if(e&&(s.url||s.spec_path)&&(!p||t||g)){i(!0),c(null);try{let r=Array.isArray(s.static_headers)?s.static_headers.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value!=null?String(t.value):""),e},{}):!Array.isArray(s.static_headers)&&s.static_headers&&"object"==typeof s.static_headers?Object.entries(s.static_headers).reduce((e,[t,s])=>(t&&(e[t]=null!=s?String(s):""),e),{}):{},l=s.credentials&&"object"==typeof s.credentials?Object.entries(s.credentials).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,n=s.transport===eo.TRANSPORT.OPENAPI?"http":s.transport,i={server_id:s.server_id||"",server_name:s.server_name||"",url:s.url,spec_path:s.spec_path,transport:n,auth_type:s.auth_type,authorization_url:s.authorization_url,token_url:s.token_url,registration_url:s.registration_url,mcp_info:s.mcp_info,static_headers:r};l&&Object.keys(l).length>0&&(i.credentials=l);let o=await (0,_.testMCPToolsListRequest)(e,i,t);if(o.tools&&!o.error)a(o.tools),c(null),m(null),o.tools.length>0&&!u&&x(!0);else{let e=o.message||"Failed to retrieve tools list";c(e),m(o.stack_trace||null),a([]),x(!1)}}catch(e){console.error("Tools fetch error:",e),c(e instanceof Error?e.message:String(e)),m(null),a([]),x(!1)}finally{i(!1)}}},w=()=>{a([]),c(null),m(null),x(!1)};return(0,b.useEffect)(()=>{r&&(j?N():w())},[s.url,s.spec_path,s.transport,s.auth_type,e,r,t,j,y,v]),{tools:l,isLoadingTools:n,toolsError:o,toolsErrorStackTrace:d,hasShownSuccessMessage:u,canFetchTools:j,fetchTools:N,clearTools:w}};var eA=e.i(531516);let eI=({tool:e,isEnabled:s,isEditExpanded:r,toolNameToDisplayName:l,toolNameToDescription:a,onToggle:n,onToggleExpand:i,onDisplayNameChange:o,onDescriptionChange:c})=>(0,t.jsxs)("div",{className:`rounded-lg border transition-colors ${s?"bg-blue-50 border-blue-300 hover:border-blue-400":"bg-gray-50 border-gray-200 hover:border-gray-300"}`,children:[(0,t.jsx)("div",{className:"p-4 cursor-pointer",onClick:()=>n(e.name),children:(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t.jsx)(eC.Checkbox,{checked:s,onChange:()=>n(e.name)}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"font-medium text-gray-900",children:l[e.name]||e.name}),(0,t.jsx)("span",{className:`px-2 py-0.5 text-xs rounded-full font-medium ${s?"bg-green-100 text-green-800":"bg-red-100 text-red-800"}`,children:s?"Enabled":"Disabled"}),l[e.name]&&(0,t.jsx)("span",{className:"px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800",children:"Custom name"})]}),(a[e.name]||e.description)&&(0,t.jsx)(d.Text,{className:"text-gray-500 text-sm block mt-1",children:a[e.name]||e.description}),(0,t.jsx)(d.Text,{className:"text-gray-400 text-xs block mt-1",children:s?"✓ Users can call this tool":"✗ Users cannot call this tool"})]}),(0,t.jsx)("button",{type:"button",onClick:t=>i(e.name,t),className:`p-1.5 rounded-md transition-colors ${r?"bg-blue-100 text-blue-600":"text-gray-400 hover:text-gray-600 hover:bg-gray-100"}`,title:"Edit display name and description",children:(0,t.jsx)(eS.EditOutlined,{})})]})}),r&&(0,t.jsxs)("div",{className:"px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg",onClick:e=>e.stopPropagation(),children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-600 mb-1 block",children:"Display Name"}),(0,t.jsx)(H.Input,{placeholder:e.name,value:l[e.name]||"",onChange:t=>o(e.name,t.target.value)}),(0,t.jsx)(d.Text,{className:"text-xs text-gray-400 mt-1 block",children:"Override how this tool's name appears to users. Leave blank to use original."})]}),(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-600 mb-1 block",children:"Description"}),(0,t.jsx)(H.Input.TextArea,{placeholder:e.description||"No description",value:a[e.name]||"",onChange:t=>c(e.name,t.target.value),rows:2}),(0,t.jsx)(d.Text,{className:"text-xs text-gray-400 mt-1 block",children:"Override the tool description shown to users. Leave blank to use original."})]})]})]}),eP=({accessToken:e,oauthAccessToken:s,formValues:r,allowedTools:l,existingAllowedTools:a,onAllowedToolsChange:n,toolNameToDisplayName:i,toolNameToDescription:o,onToolNameToDisplayNameChange:c,onToolNameToDescriptionChange:u,keyTools:x,externalTools:h,externalIsLoading:p,externalError:g,externalCanFetch:f})=>{let j=(0,b.useRef)([]),[y,v]=(0,b.useState)(""),[N,_]=(0,b.useState)("crud"),w=(0,b.useRef)(!1),S=(0,b.useRef)(""),[C,T]=(0,b.useState)(new Set),k=void 0!==h,A=ek({accessToken:e,oauthAccessToken:s,formValues:r,enabled:!k}),I=k?h:A.tools,P=k?p??!1:A.isLoadingTools,O=k?g??null:A.toolsError,M=k?f??!1:A.canFetchTools,F=(0,b.useMemo)(()=>{if(!x||0===x.length||0===I.length)return[];let e=new Set,t=[];for(let s of x){let r=s.name.split("_").map(e=>e.toLowerCase()).filter(e=>e.length>1);if(0===r.length)continue;let l=e=>e.toLowerCase().replace(/[-_/]/g," "),a=I.find(t=>{if(e.has(t.name))return!1;let s=l(t.name);return r.every(e=>s.includes(e))});if(!a){let t=r.find(e=>e.length>3)??r[r.length-1];a=I.find(s=>!e.has(s.name)&&l(s.name).includes(t))}a&&(t.push(a),e.add(a.name))}return t},[x,I]),E=(0,b.useMemo)(()=>new Set(F.map(e=>e.name)),[F]),L=(0,b.useMemo)(()=>I.filter(e=>{let t=y.toLowerCase();return e.name.toLowerCase().includes(t)||e.description&&e.description.toLowerCase().includes(t)}),[I,y]),R=(0,b.useMemo)(()=>L.filter(e=>E.has(e.name)),[L,E]),z=(0,b.useMemo)(()=>L.filter(e=>!E.has(e.name)),[L,E]);(0,b.useEffect)(()=>{let e=I.map(e=>e.name).sort().join(","),t=j.current.map(e=>e.name).sort().join(","),s=F.map(e=>e.name).sort().join(",");if(s!==S.current&&(S.current=s,""!==s&&(w.current=!1)),I.length>0&&e!==t){let e=I.map(e=>e.name);w.current?n(l.filter(t=>e.includes(t))):(w.current=!0,a&&a.length>0?n(a.filter(t=>e.includes(t))):F.length>0?n(F.map(e=>e.name).filter(t=>e.includes(t))):n(e))}j.current=I},[I,l,a,n,F]);let U=e=>{l.includes(e)?n(l.filter(t=>t!==e)):n([...l,e])},B=(e,t)=>{t.stopPropagation(),T(t=>{let s=new Set(t);return s.has(e)?s.delete(e):s.add(e),s})},q=(e,t)=>{let s={...i};t?s[e]=t:delete s[e],c(s)},V=(e,t)=>{let s={...o};t?s[e]=t:delete s[e],u(s)};return M||r.url||r.spec_path?(0,t.jsx)(eg.Card,{children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-blue-600"}),(0,t.jsx)(m.Title,{children:"Tool Configuration"}),I.length>0&&(0,t.jsx)(ex.Badge,{count:I.length,style:{backgroundColor:"#52c41a"}})]}),I.length>0&&(0,t.jsx)(eT.Radio.Group,{value:N,onChange:e=>_(e.target.value),size:"small",optionType:"button",buttonStyle:"solid",options:[{label:"Risk Groups",value:"crud"},{label:"Flat List",value:"flat"}]})]}),(0,t.jsx)("div",{className:"bg-blue-50 border border-blue-200 rounded-lg p-3",children:(0,t.jsxs)(d.Text,{className:"text-blue-800 text-sm",children:[(0,t.jsx)("strong",{children:"Select which tools users can call:"})," Only checked tools will be available for users to invoke. Unchecked tools will be blocked from execution."]})}),P&&(0,t.jsxs)("div",{className:"flex items-center justify-center py-6",children:[(0,t.jsx)(W.Spin,{size:"large"}),(0,t.jsx)(d.Text,{className:"ml-3",children:"Loading tools from spec..."})]}),O&&!P&&(0,t.jsxs)("div",{className:"text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{className:"text-red-600 font-medium",children:"Unable to load tools"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm text-red-500",children:O})]}),!P&&!O&&0===I.length&&M&&(x&&x.length>0?(0,t.jsxs)("div",{className:"text-center py-4 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"No tools loaded from spec"}),(0,t.jsxs)(d.Text,{className:"text-sm block mt-1",children:["Expected tools: ",x.map(e=>e.name).join(", ")]})]}):(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"No tools available for configuration"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Connect to an MCP server with tools to configure them"})]})),!M&&(r.url||r.spec_path)&&(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ep.ToolOutlined,{className:"text-2xl mb-2"}),(0,t.jsx)(d.Text,{children:"Complete required fields to configure tools"}),(0,t.jsx)("br",{}),(0,t.jsx)(d.Text,{className:"text-sm",children:"Fill in URL, Transport, and Authentication to load available tools"})]}),!P&&!O&&I.length>0&&(0,t.jsxs)("div",{className:"space-y-3",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200",children:[(0,t.jsx)(ey.CheckCircleOutlined,{className:"text-green-600"}),(0,t.jsxs)(d.Text,{className:"text-green-700 font-medium",children:[l.length," of ",I.length," ",1===I.length?"tool":"tools"," enabled for user access"]})]}),(0,t.jsx)(H.Input,{placeholder:"Search tools by name or description...",prefix:(0,t.jsx)(ew.SearchOutlined,{className:"text-gray-400"}),value:y,onChange:e=>v(e.target.value),allowClear:!0,className:"rounded-lg",size:"large"}),"crud"===N&&(0,t.jsx)(eA.default,{tools:I,searchFilter:y,value:0===l.length?void 0:l,onChange:e=>n(e)}),"flat"===N&&(0,t.jsx)(t.Fragment,{children:0===L.length?(0,t.jsxs)("div",{className:"text-center py-6 text-gray-400 border rounded-lg border-dashed",children:[(0,t.jsx)(ew.SearchOutlined,{className:"text-2xl mb-2"}),(0,t.jsxs)(d.Text,{children:['No tools found matching "',y,'"']})]}):(0,t.jsxs)("div",{className:"space-y-2",children:[R.length>0&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-1",children:[(0,t.jsx)("p",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wide",children:"Suggested tools"}),(0,t.jsxs)("div",{className:"flex gap-2",children:[(0,t.jsx)("button",{type:"button",onClick:()=>{let e=F.map(e=>e.name);n([...l.filter(e=>!E.has(e)),...e])},className:"text-xs text-blue-600 hover:text-blue-700",children:"Enable all"}),(0,t.jsx)("button",{type:"button",onClick:()=>{n(l.filter(e=>!E.has(e)))},className:"text-xs text-gray-500 hover:text-gray-700",children:"Disable all"})]})]}),R.map(e=>(0,t.jsx)(eI,{tool:e,isEnabled:l.includes(e.name),isEditExpanded:C.has(e.name),toolNameToDisplayName:i,toolNameToDescription:o,onToggle:U,onToggleExpand:B,onDisplayNameChange:q,onDescriptionChange:V},e.name))]}),z.length>0&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("div",{className:"flex items-center justify-between px-1 pt-2",children:[(0,t.jsx)("p",{className:"text-xs font-semibold text-gray-500 uppercase tracking-wide",children:R.length>0?"All tools":"Tools"}),(0,t.jsxs)("div",{className:"flex gap-2",children:[(0,t.jsx)("button",{type:"button",onClick:()=>{let e=I.filter(e=>!E.has(e.name)).map(e=>e.name),t=new Set(l);n([...l,...e.filter(e=>!t.has(e))])},className:"text-xs text-blue-600 hover:text-blue-700",children:"Enable all"}),(0,t.jsx)("button",{type:"button",onClick:()=>{n(l.filter(e=>E.has(e)))},className:"text-xs text-gray-500 hover:text-gray-700",children:"Disable all"})]})]}),z.map(e=>(0,t.jsx)(eI,{tool:e,isEnabled:l.includes(e.name),isEditExpanded:C.has(e.name),toolNameToDisplayName:i,toolNameToDescription:o,onToggle:U,onToggleExpand:B,onDisplayNameChange:q,onDescriptionChange:V},e.name))]})]})})]})]})}):null},eO=({isVisible:e,required:s=!0})=>e?(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Stdio Configuration (JSON)",(0,t.jsx)(g.Tooltip,{title:"Paste your stdio MCP server configuration in JSON format. You can use the full mcpServers structure from config.yaml or just the inner server configuration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"stdio_config",rules:[...s?[{required:!0,message:"Please enter stdio configuration"}]:[],{validator:(e,t)=>{if(!t)return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject("Please enter valid JSON")}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:`{ +>>>>>>>> origin/litellm_internal_staging:litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js "mcpServers": { "circleci-mcp-server": { "command": "npx", @@ -16,9 +24,15 @@ } } } +<<<<<<<< HEAD:litellm/proxy/_experimental/out/_next/static/chunks/0279e5299e9f6e98.js +}`,rows:12,className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"})}):null;var eM=e.i(770914),eF=e.i(564897),eE=e.i(646563);let{Panel:eL}=ea.Collapse,eR=({availableAccessGroups:e,mcpServer:s,searchValue:r,setSearchValue:l,getAccessGroupOptions:a})=>{let n=D.Form.useFormInstance(),i=D.Form.useWatch("auth_type",n)===eo.AUTH_TYPE.OAUTH2;return(0,b.useEffect)(()=>{if(s){if(s.static_headers){let e=Object.entries(s.static_headers).map(([e,t])=>({header:e,value:null!=t?String(t):""}));n.setFieldValue("static_headers",e)}"boolean"==typeof s.allow_all_keys&&n.setFieldValue("allow_all_keys",s.allow_all_keys),"boolean"==typeof s.available_on_public_internet&&n.setFieldValue("available_on_public_internet",s.available_on_public_internet),"boolean"==typeof s.delegate_auth_to_upstream&&n.setFieldValue("delegate_auth_to_upstream",s.delegate_auth_to_upstream)}else n.setFieldValue("allow_all_keys",!1),n.setFieldValue("available_on_public_internet",!0),n.setFieldValue("delegate_auth_to_upstream",!1)},[s,n]),(0,b.useEffect)(()=>{i||n.setFieldValue("delegate_auth_to_upstream",!1)},[i,n]),(0,t.jsx)(ea.Collapse,{className:"bg-gray-50 border border-gray-200 rounded-lg",expandIconPosition:"end",ghost:!1,children:(0,t.jsx)(eL,{header:(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("h3",{className:"text-lg font-semibold text-gray-900",children:"Permission Management / Access Control"})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 ml-4",children:"Configure access permissions and security settings (Optional)"})]}),className:"border-0",forceRender:!0,children:(0,t.jsxs)("div",{className:"space-y-6 pt-4",children:[(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Allow All LiteLLM Keys",(0,t.jsx)(g.Tooltip,{title:"When enabled, every API key can access this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:'Enable if this server should be "public" to all keys.'})]}),(0,t.jsx)(D.Form.Item,{name:"allow_all_keys",valuePropName:"checked",initialValue:s?.allow_all_keys??!1,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Internal network only",(0,t.jsx)(g.Tooltip,{title:"When on, only requests from within your internal network are accepted. Turn off to allow external clients (other clusters, ChatGPT, etc). API key authentication is always required regardless of this setting.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:"Turn on to restrict access to callers within your internal network only."})]}),(0,t.jsx)(D.Form.Item,{name:"available_on_public_internet",valuePropName:"checked",getValueProps:e=>({checked:!e}),getValueFromEvent:e=>!e,initialValue:!0,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),i&&(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Delegate auth to upstream (PKCE passthrough)",(0,t.jsx)(g.Tooltip,{title:"When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:"Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server."})]}),(0,t.jsx)(D.Form.Item,{name:"delegate_auth_to_upstream",valuePropName:"checked",initialValue:s?.delegate_auth_to_upstream??!1,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["MCP Access Groups",(0,t.jsx)(g.Tooltip,{title:"Specify access groups for this MCP server. Users must be in at least one of these groups to access the server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"mcp_access_groups",className:"mb-4",children:(0,t.jsx)(p.Select,{mode:"tags",showSearch:!0,placeholder:"Select existing groups or type to create new ones",optionFilterProp:"value",filterOption:(e,t)=>(t?.value??"").toLowerCase().includes(e.toLowerCase()),onSearch:e=>l(e),tokenSeparators:[","],options:a(),maxTagCount:"responsive",allowClear:!0})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Extra Headers",(0,t.jsx)(g.Tooltip,{title:"Forward custom headers from incoming requests to this MCP server (e.g., Authorization, X-Custom-Header, User-Agent)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})}),s?.extra_headers&&s.extra_headers.length>0&&(0,t.jsxs)("span",{className:"ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full",children:[s.extra_headers.length," configured"]})]}),name:"extra_headers",children:(0,t.jsx)(p.Select,{mode:"tags",placeholder:s?.extra_headers&&s.extra_headers.length>0?`Currently: ${s.extra_headers.join(", ")}`:"Enter header names (e.g., Authorization, X-Custom-Header)",className:"rounded-lg",size:"large",tokenSeparators:[","],allowClear:!0})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Static Headers",(0,t.jsx)(g.Tooltip,{title:"Send these key-value headers with every request to this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),required:!1,children:(0,t.jsx)(D.Form.List,{name:"static_headers",children:(e,{add:s,remove:r})=>(0,t.jsxs)("div",{className:"space-y-3",children:[e.map(({key:e,name:s,...l})=>(0,t.jsxs)(eM.Space,{className:"flex w-full",align:"baseline",size:"middle",children:[(0,t.jsx)(D.Form.Item,{...l,name:[s,"header"],className:"flex-1",rules:[{required:!0,message:"Header name is required"}],children:(0,t.jsx)(H.Input,{size:"large",allowClear:!0,className:"rounded-lg",placeholder:"Header name (e.g., X-API-Key)"})}),(0,t.jsx)(D.Form.Item,{...l,name:[s,"value"],className:"flex-1",rules:[{required:!0,message:"Header value is required"}],children:(0,t.jsx)(H.Input,{size:"large",allowClear:!0,className:"rounded-lg",placeholder:"Header value"})}),(0,t.jsx)(eF.MinusCircleOutlined,{onClick:()=>r(s),className:"text-gray-500 hover:text-red-500 cursor-pointer"})]},e)),(0,t.jsx)(eb.Button,{type:"dashed",onClick:()=>s(),icon:(0,t.jsx)(eE.PlusOutlined,{}),block:!0,children:"Add Static Header"})]})})})]})},"permissions")})},eU=({accessToken:e,selectedName:s,onSelect:r})=>{let[l,a]=(0,b.useState)([]),[n,i]=(0,b.useState)(!1),[o,c]=(0,b.useState)(new Set);return((0,b.useEffect)(()=>{e&&(i(!0),(0,_.fetchOpenAPIRegistry)(e).then(e=>a(e.apis??[])).catch(()=>a([])).finally(()=>i(!1)))},[e]),n)?(0,t.jsxs)("div",{className:"mb-4",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Popular APIs"}),(0,t.jsx)("div",{className:"flex justify-center py-6",children:(0,t.jsx)(W.Spin,{size:"small"})})]}):0===l.length?null:(0,t.jsxs)("div",{className:"mb-4",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700 block mb-2",children:"Popular APIs"}),(0,t.jsx)("div",{className:"grid grid-cols-5 gap-2",children:l.map(e=>{let l=s===e.name,a=o.has(e.name);return(0,t.jsxs)("button",{type:"button",title:e.description,onClick:()=>r(e),className:`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer + ${l?"border-blue-500 bg-blue-50 shadow-sm":"border-gray-200 hover:border-blue-300 hover:bg-gray-50"}`,children:[a?(0,t.jsx)("span",{className:"w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600",children:e.title.charAt(0)}):(0,t.jsx)("img",{src:e.icon_url,alt:e.title,className:"w-7 h-7 object-contain",onError:()=>{var t;return t=e.name,void c(e=>new Set(e).add(t))}}),(0,t.jsx)("span",{className:"text-xs text-gray-600 text-center leading-tight font-medium",children:e.title})]},e.name)})}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-2",children:"Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below."})]})},ez=({form:e,accessToken:s,onValuesChange:r,onKeyToolsChange:l,onLogoUrlChange:a,onOAuthDocsUrlChange:n})=>{let[i,o]=(0,b.useState)(null);return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(eU,{accessToken:s,selectedName:i,onSelect:t=>{o(t.name),l?.(t.key_tools??[]),a?.(t.icon_url||void 0);let s={spec_path:t.spec_url};t.oauth?(s.auth_type=eo.AUTH_TYPE.OAUTH2,s.oauth_flow_type=eo.OAUTH_FLOW.INTERACTIVE,s.authorization_url=t.oauth.authorization_url,s.token_url=t.oauth.token_url,e.setFieldsValue(s),n?.(t.oauth.docs_url??null)):(e.resetFields(["auth_type","authorization_url","token_url"]),e.setFieldsValue(s),n?.(null)),r(s)}}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OpenAPI Spec URL",(0,t.jsx)(g.Tooltip,{title:"URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"spec_path",rules:[{required:!0,message:"Please enter an OpenAPI spec URL"}],children:(0,t.jsx)(H.Input,{placeholder:"https://petstore3.swagger.io/api/v3/openapi.json",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",onChange:()=>{o(null),l?.([]),n?.(null)}})})]})};var eB=e.i(596239);let eq="/ui/assets/logos/",eV=[{name:"GitHub",url:`${eq}github.svg`},{name:"Slack",url:`${eq}slack.svg`},{name:"Notion",url:`${eq}notion.svg`},{name:"Linear",url:`${eq}linear.svg`},{name:"Jira",url:`${eq}jira.svg`},{name:"Figma",url:`${eq}figma.svg`},{name:"Gmail",url:`${eq}gmail.svg`},{name:"Google Drive",url:`${eq}google_drive.svg`},{name:"Stripe",url:`${eq}stripe.svg`},{name:"Shopify",url:`${eq}shopify.svg`},{name:"Salesforce",url:`${eq}salesforce.svg`},{name:"HubSpot",url:`${eq}hubspot.svg`},{name:"Twilio",url:`${eq}twilio.svg`},{name:"Cloudflare",url:`${eq}cloudflare.svg`},{name:"Sentry",url:`${eq}sentry.svg`},{name:"PostgreSQL",url:`${eq}postgresql.svg`},{name:"Snowflake",url:`${eq}snowflake.svg`},{name:"Zapier",url:`${eq}zapier.svg`},{name:"Google",url:`${eq}google.svg`},{name:"GitLab",url:`${eq}gitlab.svg`}],e$=({value:e,onChange:s})=>{let[r,l]=(0,b.useState)(new Set);return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Logo"}),(0,t.jsx)(g.Tooltip,{title:"Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-blue-400 hover:text-blue-600 cursor-help"})})]}),e&&(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200",children:[(0,t.jsx)("img",{src:e,alt:"Selected logo",className:"w-10 h-10 object-contain rounded",onError:e=>{e.target.style.display="none"}}),(0,t.jsx)("div",{className:"flex-1 min-w-0",children:(0,t.jsx)("div",{className:"text-xs text-gray-500 truncate",children:e})}),(0,t.jsx)("button",{type:"button",onClick:()=>s?.(void 0),className:"text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none",children:"✕"})]}),(0,t.jsx)("div",{className:"grid grid-cols-10 gap-1.5 mb-3",children:eV.map(a=>{let n=e===a.url;return r.has(a.url)?null:(0,t.jsx)(g.Tooltip,{title:a.name,children:(0,t.jsx)("button",{type:"button",onClick:()=>{var t;return t=a.url,void s?.(e===t?void 0:t)},className:`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer + ${n?"border-blue-500 bg-blue-50 shadow-sm":"border-gray-200 hover:border-blue-300 hover:bg-gray-50"}`,style:{width:40,height:40},children:(0,t.jsx)("img",{src:a.url,alt:a.name,className:"w-5 h-5 object-contain",onError:()=>{var e;return e=a.url,void l(t=>new Set(t).add(e))}})})},a.name)})}),(0,t.jsx)(H.Input,{prefix:(0,t.jsx)(eB.LinkOutlined,{className:"text-gray-400"}),placeholder:"Or paste a custom logo URL...",value:e&&!eV.some(t=>t.url===e)?e:"",onChange:e=>{let t=e.target.value.trim();s?.(t||void 0)},className:"rounded-lg",size:"small"})]})},eD=e=>{try{let t=e.indexOf("/mcp/");if(-1===t)return{token:null,baseUrl:e};let s=e.split("/mcp/");if(2!==s.length)return{token:null,baseUrl:e};let r=s[0]+"/mcp/",l=s[1];if(!l)return{token:null,baseUrl:e};return{token:l,baseUrl:r}}catch(t){return console.error("Error parsing MCP URL:",t),{token:null,baseUrl:e}}},eH=e=>{let{token:t}=eD(e);return{maskedUrl:(e=>{let{token:t,baseUrl:s}=eD(e);return t?s+"...":e})(e),hasToken:!!t}},eK=e=>e?/^https?:\/\/[^\s/$.?#].[^\s]*$/i.test(e)?Promise.resolve():Promise.reject("Please enter a valid URL (e.g., http://service-name.domain:1234/path or https://example.com)"):Promise.resolve(),eW=e=>e&&(e.includes("-")||e.includes(" "))?Promise.reject("Cannot contain '-' (hyphen) or spaces. Please use '_' (underscore) instead."):Promise.resolve();var eJ=e.i(122520),eY=e.i(165615),eG=e.i(434166);let eQ=({accessToken:e,getCredentials:t,getTemporaryPayload:s,onTokenReceived:r,onBeforeRedirect:l})=>{let[a,n]=(0,b.useState)("idle"),[i,o]=(0,b.useState)(null),[c,d]=(0,b.useState)(null),m=(0,b.useRef)(!1),u="litellm-mcp-oauth-flow-state",x="litellm-mcp-oauth-result",h="litellm-mcp-oauth-return-url",p=(e,t)=>{(0,eG.setSecureItem)(e,t)},g=e=>{try{return(0,eG.getSecureItem)(e)}catch(t){return console.warn(`Failed to get storage item ${e}`,t),null}},f=()=>{try{window.sessionStorage.removeItem(u),window.sessionStorage.removeItem(x),window.sessionStorage.removeItem(h),window.localStorage.removeItem(u),window.localStorage.removeItem(x),window.localStorage.removeItem(h)}catch(e){console.warn("Failed to clear OAuth storage",e)}},j=()=>{let e,t,s;return s=((t=(e=window.location.pathname||"").indexOf("/ui"))>=0?e.slice(0,t+3):"").replace(/\/+$/,""),`${window.location.origin}${s}/mcp/oauth/callback`},y=(0,b.useCallback)(async()=>{let r=t()||{};if(!e){o("Missing admin token"),C.default.error("Access token missing. Please re-authenticate and try again.");return}let a=s();if(!a||!a.url||!a.transport){let e="Please complete server URL and transport before starting OAuth.";o(e),C.default.error(e);return}try{n("authorizing"),o(null);let t=await (0,_.cacheTemporaryMcpServer)(e,a),s=t?.server_id?.trim();if(!s)throw Error("Temporary MCP server identifier missing. Please retry.");let i={};if(!(a.credentials?.client_id&&a.credentials?.client_secret)){let t=await (0,_.registerMcpOAuthClient)(e,s,{client_name:a.alias||a.server_name||s,grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:a.credentials&&a.credentials.client_secret?"client_secret_post":"none"});i={clientId:t?.client_id,clientSecret:t?.client_secret}}let c=(0,eY.generateCodeVerifier)(),d=await (0,eY.generateCodeChallenge)(c),m=crypto.randomUUID(),x=i.clientId||r.client_id,g=Array.isArray(r.scopes)?r.scopes.filter(e=>e&&e.trim().length>0).join(" "):void 0,f=(0,_.buildMcpOAuthAuthorizeUrl)({serverId:s,clientId:x,redirectUri:j(),state:m,codeChallenge:d,scope:g}),b={state:m,codeVerifier:c,clientId:x,clientSecret:i.clientSecret||r.client_secret,serverId:s,redirectUri:j()};if(l)try{l()}catch(e){console.error("Failed to prepare for OAuth redirect",e)}try{p(u,JSON.stringify(b)),p(h,window.location.href)}catch(e){throw Error("Unable to access browser storage for OAuth. Please enable storage and retry.")}window.location.href=f}catch(t){console.error("Failed to start OAuth flow",t),n("error");let e=(0,eJ.extractErrorMessage)(t);o(e),C.default.error(e)}},[e,t,s,l]),v=(0,b.useCallback)(async()=>{if(m.current)return;let t=null,s=null;try{let e=g(x);if(!e)return;m.current=!0,t=JSON.parse(e);let r=g(u);s=r?JSON.parse(r):null}catch(e){f(),m.current=!1,o("Failed to resume OAuth flow. Please retry."),n("error"),C.default.error("Failed to resume OAuth flow. Please retry.");return}if(!t){m.current=!1;return}try{window.sessionStorage.removeItem(x),window.localStorage.removeItem(x)}catch(e){}try{if(!s||!s.state||!s.codeVerifier||!s.serverId)throw Error("OAuth session state was lost. This can happen if you have strict browser privacy settings. Please try again and ensure cookies/storage is enabled.");if(!t.state||t.state!==s.state)throw Error("OAuth state mismatch. Please retry.");if(t.error)throw Error(t.error_description||t.error);if(!t.code)throw Error("Authorization code missing in callback.");n("exchanging");let l=await (0,_.exchangeMcpOAuthToken)({serverId:s.serverId,code:t.code,clientId:s.clientId,clientSecret:s.clientSecret,codeVerifier:s.codeVerifier,redirectUri:s.redirectUri,accessToken:e});r(l),d(l),n("success"),o(null),C.default.success("OAuth token retrieved successfully")}catch(t){let e=(0,eJ.extractErrorMessage)(t);o(e),n("error"),C.default.error(e)}finally{f(),setTimeout(()=>{m.current=!1},1e3)}},[r]);return(0,b.useEffect)(()=>{v()},[v]),{startOAuthFlow:y,status:a,error:i,tokenResponse:c}},eZ="../ui/assets/logos/mcp_logo.png",eX=[eo.AUTH_TYPE.API_KEY,eo.AUTH_TYPE.BEARER_TOKEN,eo.AUTH_TYPE.TOKEN,eo.AUTH_TYPE.BASIC],e0=[...eX,eo.AUTH_TYPE.OAUTH2,eo.AUTH_TYPE.AWS_SIGV4],e2="litellm-mcp-oauth-create-state",e1=e=>Array.isArray(e)?e.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{},e5=({userRole:e,accessToken:r,onCreateSuccess:a,isModalVisible:n,setModalVisible:i,availableAccessGroups:o,prefillData:c,onBackToDiscovery:d})=>{let[m]=D.Form.useForm(),[u,x]=(0,b.useState)(!1),[f,j]=(0,b.useState)({}),[y,v]=(0,b.useState)({}),[N,w]=(0,b.useState)(null),[S,T]=(0,b.useState)(!1),[k,A]=(0,b.useState)([]),[I,P]=(0,b.useState)({}),[O,M]=(0,b.useState)({}),[F,E]=(0,b.useState)(""),[L,R]=(0,b.useState)([]),[U,z]=(0,b.useState)(""),[B,q]=(0,b.useState)(null),[V,$]=(0,b.useState)(void 0),[K,W]=(0,b.useState)(null),{tools:J,isLoadingTools:Y,toolsError:G,toolsErrorStackTrace:Q,canFetchTools:Z,fetchTools:X,clearTools:ee}=ek({accessToken:r,oauthAccessToken:B,formValues:y,enabled:!0}),et=y.auth_type,es=!!et&&eX.includes(et),er=et===eo.AUTH_TYPE.OAUTH2,ec=et===eo.AUTH_TYPE.AWS_SIGV4,ed=er&&y.oauth_flow_type===eo.OAUTH_FLOW.M2M,{startOAuthFlow:em,status:ex,error:eh,tokenResponse:ep}=eQ({accessToken:r,getCredentials:()=>m.getFieldValue("credentials"),getTemporaryPayload:()=>{let e=m.getFieldsValue(!0),t=e.transport||F,s=e.url||(t===eo.TRANSPORT.OPENAPI?e.spec_path:void 0);if(!s||!t)return null;let r=e1(e.static_headers);return{server_id:void 0,server_name:e.server_name,alias:e.alias,description:e.description,url:s,transport:t===eo.TRANSPORT.OPENAPI?"http":t,auth_type:eo.AUTH_TYPE.OAUTH2,credentials:e.credentials,authorization_url:e.authorization_url,token_url:e.token_url,registration_url:e.registration_url,mcp_access_groups:e.mcp_access_groups,static_headers:r,command:e.command,args:e.args,env:e.env}},onTokenReceived:e=>{if(q(e?.access_token??null),e?.access_token){let t={access_token:e.access_token,...e.refresh_token&&{refresh_token:e.refresh_token},...e.expires_in&&{expires_in:e.expires_in},...e.scope&&{scope:e.scope}};m.setFieldsValue({credentials:t}),C.default.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.")}},onBeforeRedirect:()=>{try{let e=m.getFieldsValue(!0);(0,eG.setSecureItem)(e2,JSON.stringify({modalVisible:n,formValues:e,transportType:F,costConfig:f,allowedTools:k,searchValue:U,aliasManuallyEdited:S,logoUrl:V}))}catch(e){console.warn("Failed to persist MCP create state",e)}}});b.default.useEffect(()=>{let e=(0,eG.getSecureItem)(e2);if(e)try{let t=JSON.parse(e);t.modalVisible&&i(!0);let s=t.formValues?.transport||t.transportType||"";s&&E(s),t.formValues&&w({values:t.formValues,transport:s}),t.costConfig&&j(t.costConfig),t.allowedTools&&A(t.allowedTools),t.searchValue&&z(t.searchValue),"boolean"==typeof t.aliasManuallyEdited&&T(t.aliasManuallyEdited),t.logoUrl&&$(t.logoUrl)}catch(e){console.error("Failed to restore MCP create state",e)}finally{window.sessionStorage.removeItem(e2)}},[m,i]),b.default.useEffect(()=>{N&&(F||N.transport,(!N.transport||F)&&(m.setFieldsValue(N.values),v(N.values),w(null)))},[N,m,F]),b.default.useEffect(()=>{if(!n||!c)return;let e=(c.name||"").replace(/[^a-zA-Z0-9_]/g,"_").replace(/_+/g,"_").replace(/^_|_$/g,""),t=c.transport||"";E(t);let s={server_name:e,alias:e,description:c.description||"",transport:t};if("stdio"===t){let e={};if(c.command&&(e.command=c.command),c.args&&c.args.length>0&&(e.args=c.args),c.env_vars&&c.env_vars.length>0){let t={};for(let e of c.env_vars)t[e.name]=e.description?`<${e.description}>`:"";e.env=t}Object.keys(e).length>0&&(s.stdio_config=JSON.stringify(e,null,2))}else c.url&&(s.url=c.url);m.setFieldsValue(s),v(s),T(!1)},[n,c,m]);let eg=async e=>{x(!0);try{let{static_headers:t,stdio_config:s,credentials:l,allow_all_keys:n,available_on_public_internet:o,delegate_auth_to_upstream:c,token_validation_json:d,...u}=e,h=u.mcp_access_groups,p=e1(t),g=l&&"object"==typeof l?Object.entries(l).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,b={};if(s&&"stdio"===F)try{let e=JSON.parse(s),t=e;if(e.mcpServers&&"object"==typeof e.mcpServers){let s=Object.keys(e.mcpServers);if(s.length>0){let r=s[0];t=e.mcpServers[r],u.server_name||(u.server_name=r.replace(/-/g,"_"))}}b={command:t.command,args:t.args,env:t.env},console.log("Parsed stdio config:",b)}catch(e){C.default.fromBackend("Invalid JSON in stdio configuration");return}u.transport===eo.TRANSPORT.OPENAPI&&(u.transport="http");let y=null;if(d&&""!==d.trim())try{y=JSON.parse(d)}catch{C.default.fromBackend("Invalid JSON in Token Validation Rules"),x(!1);return}let v={...u,...b,stdio_config:void 0,mcp_info:{server_name:u.server_name||u.url,description:u.description,logo_url:V||void 0,mcp_server_cost_info:Object.keys(f).length>0?f:null},mcp_access_groups:h,alias:u.alias,allowed_tools:k.length>0?k:null,tool_name_to_display_name:Object.keys(I).length>0?I:null,tool_name_to_description:Object.keys(O).length>0?O:null,allow_all_keys:!!n,available_on_public_internet:!!o,delegate_auth_to_upstream:!!c,static_headers:p,...null!==y&&{token_validation:y}};if(v.static_headers=p,u.auth_type&&e0.includes(u.auth_type)&&g&&Object.keys(g).length>0&&(v.credentials=g),console.log(`Payload: ${JSON.stringify(v)}`),null!=r){let e=ej?await (0,_.createMCPServer)(r,v):await (0,_.registerMCPServer)(r,v);C.default.success(ej?"MCP Server created successfully":"MCP Server submitted for admin review"),m.resetFields(),j({}),ee(),A([]),T(!1),$(void 0),i(!1),a(e)}}catch(t){let e=t instanceof Error?t.message:String(t);C.default.fromBackend(ej?`Error creating MCP Server: ${e}`:`Error submitting MCP Server: ${e}`)}finally{x(!1)}},eb=()=>{m.resetFields(),j({}),ee(),A([]),T(!1),$(void 0),i(!1)};b.default.useEffect(()=>{if(!S&&y.server_name){let e=y.server_name.replace(/\s+/g,"_");m.setFieldsValue({alias:e}),v(t=>({...t,alias:e}))}},[y.server_name]),b.default.useEffect(()=>{n||v({})},[n]);let ej=(0,s.isAdminRole)(e);return(0,t.jsx)(h.Modal,{title:(0,t.jsxs)("div",{className:"flex items-center pb-4 border-b border-gray-100",style:{gap:12},children:[d&&(0,t.jsx)("button",{onClick:d,className:"text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none",style:{flexShrink:0},children:"←"}),(0,t.jsx)("img",{src:eZ,alt:"MCP Logo",className:"w-8 h-8 object-contain",style:{height:"20px",width:"20px",objectFit:"contain"}}),(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:ej?"Add New MCP Server":"Submit MCP Server for Review"})]}),open:n,width:1e3,onCancel:eb,footer:null,forceRender:!0,className:"top-8",styles:{body:{padding:"24px"},header:{padding:"24px 24px 0 24px",border:"none"}},children:(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsxs)(D.Form,{form:m,onFinish:eg,onValuesChange:(e,t)=>v(t),layout:"vertical",className:"space-y-6",children:[!ej&&(0,t.jsxs)("div",{className:"rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800",children:["Your submission will be sent for admin review before it becomes active."," ","Note: the request must be made with a team-scoped API key."]}),(0,t.jsxs)("div",{className:"grid grid-cols-1 gap-6",children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["MCP Server Name",(0,t.jsx)(g.Tooltip,{title:"Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Cannot contain spaces or hyphens; use underscores instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"server_name",rules:[{required:!1,message:"Please enter a server name"},{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(ei.TextInput,{placeholder:"e.g., GitHub_MCP, Zapier_MCP, etc.",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Alias",(0,t.jsx)(g.Tooltip,{title:"A short, unique identifier for this server. Defaults to the server name if not provided. Cannot contain spaces or hyphens; use underscores instead.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"alias",rules:[{required:!1},{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(ei.TextInput,{placeholder:"e.g., GitHub_MCP, Zapier_MCP, etc.",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",onChange:()=>T(!0)})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Description"}),name:"description",rules:[{required:!1,message:"Please enter a server description"}],children:(0,t.jsx)(ei.TextInput,{placeholder:"Brief description of what this server does",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(e$,{value:V,onChange:$}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"GitHub / Source URL"}),name:"source_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://github.com/org/mcp-server",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Transport Type"}),name:"transport",rules:[{required:!0,message:"Please select a transport type"}],children:(0,t.jsxs)(p.Select,{placeholder:"Select transport",className:"rounded-lg",size:"large",onChange:e=>{E(e),"stdio"===e?m.setFieldsValue({url:void 0,spec_path:void 0,auth_type:void 0,credentials:void 0}):e===eo.TRANSPORT.OPENAPI?m.setFieldsValue({url:void 0,command:void 0,args:void 0,env:void 0}):m.setFieldsValue({spec_path:void 0,command:void 0,args:void 0,env:void 0})},value:F,children:[(0,t.jsx)(p.Select.Option,{value:"http",children:"Streamable HTTP (Recommended)"}),(0,t.jsx)(p.Select.Option,{value:"sse",children:"Server-Sent Events (SSE)"}),(0,t.jsx)(p.Select.Option,{value:"stdio",children:"Standard Input/Output (stdio)"}),(0,t.jsx)(p.Select.Option,{value:eo.TRANSPORT.OPENAPI,children:"OpenAPI Spec"})]})}),("http"===F||"sse"===F)&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"MCP Server URL"}),name:"url",rules:[{required:!0,message:"Please enter a server URL"},{validator:(e,t)=>eK(t)}],children:(0,t.jsx)(H.Input,{placeholder:"https://your-mcp-server.com",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),F===eo.TRANSPORT.OPENAPI&&(0,t.jsx)(ez,{form:m,accessToken:n?r:null,onValuesChange:e=>v(t=>({...t,...e})),onKeyToolsChange:R,onLogoUrlChange:$,onOAuthDocsUrlChange:W}),F===eo.TRANSPORT.OPENAPI&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center gap-2",children:["BYOK (Bring Your Own Key)",(0,t.jsx)(g.Tooltip,{title:"When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"is_byok",valuePropName:"checked",children:(0,t.jsx)(el.Switch,{})}),(0,t.jsx)(D.Form.Item,{noStyle:!0,shouldUpdate:(e,t)=>e.is_byok!==t.is_byok||e.auth_type!==t.auth_type,children:({getFieldValue:e})=>e("is_byok")?(0,t.jsxs)(t.Fragment,{children:[e("auth_type")&&"none"!==e("auth_type")&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2",children:[(0,t.jsx)(en.InfoCircleOutlined,{className:"mt-0.5 flex-shrink-0"}),(0,t.jsxs)("span",{children:["User keys will be sent as:"," ",(0,t.jsxs)("code",{className:"font-mono bg-blue-100 px-1 rounded",children:["bearer_token"===e("auth_type")&&"Authorization: Bearer {key}","token"===e("auth_type")&&"Authorization: token {key}","api_key"===e("auth_type")&&"x-api-key: {key}","basic"===e("auth_type")&&"Authorization: Basic {key}","authorization"===e("auth_type")&&"Authorization: {key}"]}),!e("auth_type")&&"Set Authentication Type below to specify the format."]})]}),!e("auth_type")&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2",children:[(0,t.jsx)(en.InfoCircleOutlined,{className:"mt-0.5 flex-shrink-0"}),(0,t.jsxs)("span",{children:["Set the ",(0,t.jsx)("strong",{children:"Authentication Type"})," below to specify how user keys are sent (e.g., Bearer Token, API Key header)."]})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["Access Description",(0,t.jsx)(g.Tooltip,{title:"List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"byok_description",children:(0,t.jsx)(p.Select,{mode:"tags",placeholder:"Add access description items (press Enter after each)",className:"w-full",tokenSeparators:[","]})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["API Key Help URL",(0,t.jsx)(g.Tooltip,{title:"Optional link shown to users to help them find their API key",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"byok_api_key_help_url",children:(0,t.jsx)(H.Input,{placeholder:"https://docs.example.com/api-keys"})})]}):null})]}),"stdio"!==F&&""!==F&&(0,t.jsx)(ea.Collapse,{defaultActiveKey:["auth"],className:"mb-4",items:[{key:"auth",label:(0,t.jsx)("span",{className:"text-sm font-semibold text-gray-700",children:"Authentication"}),children:(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{name:"auth_type",rules:[{required:!0,message:"Please select an auth type"}],children:(0,t.jsxs)(p.Select,{placeholder:"Select auth type",className:"rounded-lg",size:"large",children:[(0,t.jsx)(p.Select.Option,{value:"none",children:"None"}),(0,t.jsx)(p.Select.Option,{value:"api_key",children:"API Key"}),(0,t.jsx)(p.Select.Option,{value:"bearer_token",children:"Bearer Token"}),(0,t.jsx)(p.Select.Option,{value:"token",children:"Token"}),(0,t.jsx)(p.Select.Option,{value:"basic",children:"Basic Auth"}),(0,t.jsx)(p.Select.Option,{value:"oauth2",children:"OAuth"}),(0,t.jsx)(p.Select.Option,{value:"aws_sigv4",children:"AWS SigV4 (Bedrock AgentCore MCPs)"})]})}),es&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authentication Value",(0,t.jsx)(g.Tooltip,{title:"Token, password, or header value to send with each request for the selected auth type.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","auth_value"],rules:[{validator:(e,t)=>t&&"string"==typeof t&&""===t.trim()?Promise.reject(Error("Authentication value cannot be empty whitespace")):Promise.resolve()}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:"Enter token or secret",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),er&&(0,t.jsx)(eu,{isM2M:ed,initialFlowType:eo.OAUTH_FLOW.INTERACTIVE,docsUrl:K,oauthFlow:{startOAuthFlow:em,status:ex,error:eh,tokenResponse:ep}})]})}]}),"stdio"!==F&&""!==F&&ec&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-2",children:["For MCP servers hosted on AWS Bedrock AgentCore."," ",(0,t.jsx)("a",{href:"https://docs.litellm.ai/docs/mcp_aws_sigv4",target:"_blank",rel:"noopener noreferrer",className:"text-blue-500 hover:text-blue-700",children:"View docs →"})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Region",(0,t.jsx)(g.Tooltip,{title:"AWS region for SigV4 signing (e.g., us-east-1)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_region_name"],rules:[{required:!0,message:"AWS region is required for SigV4 auth"}],children:(0,t.jsx)(H.Input,{placeholder:"us-east-1",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Service Name",(0,t.jsx)(g.Tooltip,{title:"AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_service_name"],children:(0,t.jsx)(H.Input,{placeholder:"bedrock-agentcore",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Access Key ID",(0,t.jsx)(g.Tooltip,{title:"Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_access_key_id"],dependencies:[["credentials","aws_secret_access_key"]],rules:[({getFieldValue:e})=>({validator:(t,s)=>e(["credentials","aws_secret_access_key"])&&!s?Promise.reject(Error("Access Key ID is required when Secret Access Key is provided")):Promise.resolve()})],children:(0,t.jsx)(H.Input.Password,{placeholder:"AKIA... (optional — uses IAM role if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Secret Access Key",(0,t.jsx)(g.Tooltip,{title:"Optional. Required if AWS Access Key ID is provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_secret_access_key"],dependencies:[["credentials","aws_access_key_id"]],rules:[({getFieldValue:e})=>({validator:(t,s)=>e(["credentials","aws_access_key_id"])&&!s?Promise.reject(Error("Secret Access Key is required when Access Key ID is provided")):Promise.resolve()})],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter secret key (optional — uses IAM role if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Token",(0,t.jsx)(g.Tooltip,{title:"Optional. Only needed for temporary STS credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_token"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter session token (optional)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Role ARN",(0,t.jsx)(g.Tooltip,{title:"Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_role_name"],children:(0,t.jsx)(H.Input,{placeholder:"arn:aws:iam::123456789012:role/MyRole (optional)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Name",(0,t.jsx)(g.Tooltip,{title:"Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_name"],children:(0,t.jsx)(H.Input,{placeholder:"litellm-prod (optional, auto-generated if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})]}),(0,t.jsx)(eO,{isVisible:"stdio"===F})]}),(0,t.jsx)("div",{className:"mt-8",children:(0,t.jsx)(eR,{availableAccessGroups:o,mcpServer:null,searchValue:U,setSearchValue:z,getAccessGroupOptions:()=>{let e=o.map(e=>({value:e,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-green-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:e})]})}));return U&&!o.some(e=>e.toLowerCase().includes(U.toLowerCase()))&&e.push({value:U,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:U}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-1",children:"create new group"})]})}),e}})}),(0,t.jsx)("div",{className:"mt-8 pt-6 border-t border-gray-200",children:(0,t.jsx)(e_,{formValues:y,tools:J,isLoadingTools:Y,toolsError:G,toolsErrorStackTrace:Q,canFetchTools:Z,fetchTools:X})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eP,{accessToken:r,oauthAccessToken:B,formValues:y,allowedTools:k,existingAllowedTools:null,onAllowedToolsChange:A,toolNameToDisplayName:I,toolNameToDescription:O,onToolNameToDisplayNameChange:P,onToolNameToDescriptionChange:M,keyTools:L,externalTools:J,externalIsLoading:Y,externalError:G,externalCanFetch:Z})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(ef,{value:f,onChange:j,tools:J.filter(e=>k.includes(e.name)),disabled:!1})}),(0,t.jsxs)("div",{className:"flex items-center justify-end space-x-3 pt-6 border-t border-gray-100",children:[(0,t.jsx)(l.Button,{variant:"secondary",onClick:eb,children:"Cancel"}),(0,t.jsx)(l.Button,{variant:"primary",loading:u,children:u?"Creating...":"Add MCP Server"})]})]})})})};var e4=e.i(175712),e6=e.i(118366),e3=e.i(475254);let e7=(0,e3.default)("code",[["path",{d:"m16 18 6-6-6-6",key:"eg8j8"}],["path",{d:"m8 6-6 6 6 6",key:"ppft3o"}]]);e.s(["Code",()=>e7],758472);let e8=(0,e3.default)("terminal",[["path",{d:"M12 19h8",key:"baeox8"}],["path",{d:"m4 17 6-6-6-6",key:"1yngyt"}]]),e9=(0,e3.default)("globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);var te=e.i(634831),tt=e.i(438100);let ts=(0,e3.default)("zap",[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]]);var tr=e.i(500330);let{Title:tl,Text:ta}=f.Typography,{Panel:tn}=ea.Collapse,ti=({icon:e,title:s,description:r,children:l,serverName:a,accessGroups:n=["dev-group"]})=>{let[i,o]=(0,b.useState)(!1);return(0,t.jsxs)(e4.Card,{className:"border border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)("span",{className:"p-2 rounded-lg bg-gray-50",children:e}),(0,t.jsxs)("div",{children:[(0,t.jsx)(tl,{level:5,className:"mb-0",children:s}),(0,t.jsx)(ta,{className:"text-gray-600",children:r})]})]}),a&&("Implementation Example"===s||"Configuration"===s)&&(0,t.jsxs)(D.Form.Item,{className:"mb-4",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)(el.Switch,{size:"small",checked:i,onChange:o}),(0,t.jsxs)(ta,{className:"text-sm",children:["Limit tools to specific MCP servers or MCP groups by passing the ",(0,t.jsx)("code",{children:"x-mcp-servers"})," header"]})]}),i&&(0,t.jsx)(ej.Alert,{className:"mt-2",type:"info",showIcon:!0,message:"Two Options",description:(0,t.jsxs)("div",{children:[(0,t.jsxs)("p",{children:[(0,t.jsx)("strong",{children:"Option 1:"})," Get a specific server: ",(0,t.jsxs)("code",{children:['"',a.replace(/\s+/g,"_"),'"']})]}),(0,t.jsxs)("p",{children:[(0,t.jsx)("strong",{children:"Option 2:"})," Get a group of MCPs: ",(0,t.jsx)("code",{children:'"dev-group"'})]}),(0,t.jsxs)("p",{className:"mt-2 text-sm text-gray-600",children:["You can also mix both: ",(0,t.jsx)("code",{children:'"Server1,dev-group"'})]})]})})]}),b.default.Children.map(l,e=>{if(b.default.isValidElement(e)&&e.props.hasOwnProperty("code")&&e.props.hasOwnProperty("copyKey")){let t=e.props.code;if(t&&t.includes('"headers":'))return b.default.cloneElement(e,{code:t.replace(/"headers":\s*{[^}]*}/,`"headers": ${JSON.stringify((()=>{let e={"x-litellm-api-key":"Bearer YOUR_LITELLM_API_KEY"};if(i&&a){let t=[a.replace(/\s+/g,"_"),...n].join(",");e["x-mcp-servers"]=t}return e})(),null,8)}`)})}return e})]})},to=({currentServerAccessGroups:e=[]})=>{let s=(0,_.getProxyBaseUrl)(),[r,l]=(0,b.useState)({}),[u,x]=(0,b.useState)({openai:[],litellm:[],cursor:[],http:[]}),[h]=(0,b.useState)("Zapier_MCP"),p=async(e,t)=>{await (0,tr.copyToClipboard)(e)&&(l(e=>({...e,[t]:!0})),setTimeout(()=>{l(e=>({...e,[t]:!1}))},2e3))},g=({code:e,copyKey:s,title:l,className:a=""})=>(0,t.jsxs)("div",{className:"relative group",children:[l&&(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)(e7,{size:16,className:"text-blue-600"}),(0,t.jsx)(ta,{strong:!0,className:"text-gray-700",children:l})]}),(0,t.jsxs)(e4.Card,{className:`bg-gray-50 border border-gray-200 relative ${a}`,children:[(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:r[s]?(0,t.jsx)(k.CheckIcon,{size:12}):(0,t.jsx)(e6.CopyIcon,{size:12}),onClick:()=>p(e,s),className:`absolute top-2 right-2 z-10 transition-all duration-200 ${r[s]?"text-green-600 bg-green-50 border-green-200":"text-gray-500 hover:text-gray-700 hover:bg-gray-100"}`}),(0,t.jsx)("pre",{className:"text-sm overflow-x-auto pr-10 text-gray-800 font-mono leading-relaxed",children:e})]})]}),f=({step:e,title:s,children:r})=>(0,t.jsxs)("div",{className:"flex gap-4",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("div",{className:"w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold",children:e})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsx)(ta,{strong:!0,className:"text-gray-800 block mb-2",children:s}),r]})]});return(0,t.jsx)("div",{children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(m.Title,{className:"text-3xl font-bold text-gray-900 mb-3",children:"Connect to your MCP client"}),(0,t.jsx)(d.Text,{className:"text-lg text-gray-600",children:"Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world tasks through a simple, secure connection."})]}),(0,t.jsxs)(n.TabGroup,{className:"w-full",children:[(0,t.jsx)(i.TabList,{className:"flex justify-start mt-8 mb-6",children:(0,t.jsxs)("div",{className:"flex bg-gray-100 p-1 rounded-lg",children:[(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e7,{size:18}),"OpenAI API"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(ts,{size:18}),"LiteLLM Proxy"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e8,{size:18}),"Cursor"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e9,{size:18}),"Streamable HTTP"]})})]})}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsx)(o.TabPanel,{className:"mt-6",children:(0,t.jsx)(()=>(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{className:"bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-100",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)(e7,{className:"text-blue-600",size:24}),(0,t.jsx)(tl,{level:4,className:"mb-0 text-blue-900",children:"OpenAI Responses API Integration"})]}),(0,t.jsx)(ta,{className:"text-blue-700",children:"Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration"})]}),(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsx)(ti,{icon:(0,t.jsx)(tt.KeyIcon,{className:"text-blue-600",size:16}),title:"API Key Setup",description:"Configure your OpenAI API key for authentication",children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"middle",className:"w-full",children:[(0,t.jsx)("div",{children:(0,t.jsxs)(ta,{children:["Get your API key from the"," ",(0,t.jsxs)("a",{href:"https://platform.openai.com/api-keys",target:"_blank",rel:"noopener noreferrer",className:"text-blue-600 hover:text-blue-700 inline-flex items-center gap-1",children:["OpenAI platform ",(0,t.jsx)(te.ExternalLinkIcon,{size:12})]})]})}),(0,t.jsx)(g,{title:"Environment Variable",code:'export OPENAI_API_KEY="sk-..."',copyKey:"openai-env"})]})}),(0,t.jsx)(ti,{icon:(0,t.jsx)(P.ServerIcon,{className:"text-blue-600",size:16}),title:"MCP Server Information",description:"Connection details for your LiteLLM MCP server",children:(0,t.jsx)(g,{title:"Server URL",code:`${s}/mcp`,copyKey:"openai-server-url"})}),(0,t.jsx)(ti,{icon:(0,t.jsx)(e7,{className:"text-blue-600",size:16}),title:"Implementation Example",description:"Complete cURL example for using the Responses API",serverName:"Zapier Gmail",accessGroups:["dev-group"],children:(0,t.jsx)(g,{code:`curl --location 'https://api.openai.com/v1/responses' \\ +======== }`,rows:12,className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm"})}):null;var eM=e.i(770914),eF=e.i(564897),eE=e.i(646563);let{Panel:eL}=ea.Collapse,eR=({availableAccessGroups:e,mcpServer:s,searchValue:r,setSearchValue:l,getAccessGroupOptions:a})=>{let n=D.Form.useFormInstance(),i=D.Form.useWatch("auth_type",n)===eo.AUTH_TYPE.OAUTH2,o=D.Form.useWatch("delegate_auth_to_upstream",n),c=D.Form.useWatch("available_on_public_internet",n),d=i&&!0===o&&!1===c;return(0,b.useEffect)(()=>{if(s){if(s.static_headers){let e=Object.entries(s.static_headers).map(([e,t])=>({header:e,value:null!=t?String(t):""}));n.setFieldValue("static_headers",e)}"boolean"==typeof s.allow_all_keys&&n.setFieldValue("allow_all_keys",s.allow_all_keys),"boolean"==typeof s.available_on_public_internet&&n.setFieldValue("available_on_public_internet",s.available_on_public_internet),"boolean"==typeof s.delegate_auth_to_upstream&&n.setFieldValue("delegate_auth_to_upstream",s.delegate_auth_to_upstream)}else n.setFieldValue("allow_all_keys",!1),n.setFieldValue("available_on_public_internet",!0),n.setFieldValue("delegate_auth_to_upstream",!1)},[s,n]),(0,b.useEffect)(()=>{i||n.setFieldValue("delegate_auth_to_upstream",!1)},[i,n]),(0,t.jsx)(ea.Collapse,{className:"bg-gray-50 border border-gray-200 rounded-lg",expandIconPosition:"end",ghost:!1,children:(0,t.jsx)(eL,{header:(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("h3",{className:"text-lg font-semibold text-gray-900",children:"Permission Management / Access Control"})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 ml-4",children:"Configure access permissions and security settings (Optional)"})]}),className:"border-0",forceRender:!0,children:(0,t.jsxs)("div",{className:"space-y-6 pt-4",children:[(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Allow All LiteLLM Keys",(0,t.jsx)(g.Tooltip,{title:"When enabled, every API key can access this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:'Enable if this server should be "public" to all keys.'})]}),(0,t.jsx)(D.Form.Item,{name:"allow_all_keys",valuePropName:"checked",initialValue:s?.allow_all_keys??!1,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Internal network only",(0,t.jsx)(g.Tooltip,{title:"When on, only requests from within your internal network are accepted. Turn off to allow external clients (other clusters, ChatGPT, etc). API key authentication is always required regardless of this setting.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:"Turn on to restrict access to callers within your internal network only."})]}),(0,t.jsx)(D.Form.Item,{name:"available_on_public_internet",valuePropName:"checked",getValueProps:e=>({checked:!e}),getValueFromEvent:e=>!e,initialValue:!0,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),i&&(0,t.jsxs)("div",{className:"flex items-start justify-between gap-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Delegate auth to upstream (PKCE passthrough)",(0,t.jsx)(g.Tooltip,{title:"When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),(0,t.jsx)("p",{className:"text-sm text-gray-600 mt-1",children:"Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server."})]}),(0,t.jsx)(D.Form.Item,{name:"delegate_auth_to_upstream",valuePropName:"checked",initialValue:s?.delegate_auth_to_upstream??!1,className:"mb-0",children:(0,t.jsx)(el.Switch,{})})]}),d&&(0,t.jsx)(ej.Alert,{type:"warning",showIcon:!0,className:"mb-2",message:"Internal server with upstream OAuth delegation",description:"This MCP server is configured as internal-only but delegates auth to upstream. Anonymous users will be able to reach the upstream OAuth2 /authorize flow without a LiteLLM session. Ensure your upstream provider and network enforce access controls."}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["MCP Access Groups",(0,t.jsx)(g.Tooltip,{title:"Specify access groups for this MCP server. Users must be in at least one of these groups to access the server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"mcp_access_groups",className:"mb-4",children:(0,t.jsx)(p.Select,{mode:"tags",showSearch:!0,placeholder:"Select existing groups or type to create new ones",optionFilterProp:"value",filterOption:(e,t)=>(t?.value??"").toLowerCase().includes(e.toLowerCase()),onSearch:e=>l(e),tokenSeparators:[","],options:a(),maxTagCount:"responsive",allowClear:!0})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Extra Headers",(0,t.jsx)(g.Tooltip,{title:"Forward custom headers from incoming requests to this MCP server (e.g., Authorization, X-Custom-Header, User-Agent)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})}),s?.extra_headers&&s.extra_headers.length>0&&(0,t.jsxs)("span",{className:"ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full",children:[s.extra_headers.length," configured"]})]}),name:"extra_headers",children:(0,t.jsx)(p.Select,{mode:"tags",placeholder:s?.extra_headers&&s.extra_headers.length>0?`Currently: ${s.extra_headers.join(", ")}`:"Enter header names (e.g., Authorization, X-Custom-Header)",className:"rounded-lg",size:"large",tokenSeparators:[","],allowClear:!0})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Static Headers",(0,t.jsx)(g.Tooltip,{title:"Send these key-value headers with every request to this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),required:!1,children:(0,t.jsx)(D.Form.List,{name:"static_headers",children:(e,{add:s,remove:r})=>(0,t.jsxs)("div",{className:"space-y-3",children:[e.map(({key:e,name:s,...l})=>(0,t.jsxs)(eM.Space,{className:"flex w-full",align:"baseline",size:"middle",children:[(0,t.jsx)(D.Form.Item,{...l,name:[s,"header"],className:"flex-1",rules:[{required:!0,message:"Header name is required"}],children:(0,t.jsx)(H.Input,{size:"large",allowClear:!0,className:"rounded-lg",placeholder:"Header name (e.g., X-API-Key)"})}),(0,t.jsx)(D.Form.Item,{...l,name:[s,"value"],className:"flex-1",rules:[{required:!0,message:"Header value is required"}],children:(0,t.jsx)(H.Input,{size:"large",allowClear:!0,className:"rounded-lg",placeholder:"Header value"})}),(0,t.jsx)(eF.MinusCircleOutlined,{onClick:()=>r(s),className:"text-gray-500 hover:text-red-500 cursor-pointer"})]},e)),(0,t.jsx)(eb.Button,{type:"dashed",onClick:()=>s(),icon:(0,t.jsx)(eE.PlusOutlined,{}),block:!0,children:"Add Static Header"})]})})})]})},"permissions")})},ez=({accessToken:e,selectedName:s,onSelect:r})=>{let[l,a]=(0,b.useState)([]),[n,i]=(0,b.useState)(!1),[o,c]=(0,b.useState)(new Set);return((0,b.useEffect)(()=>{e&&(i(!0),(0,_.fetchOpenAPIRegistry)(e).then(e=>a(e.apis??[])).catch(()=>a([])).finally(()=>i(!1)))},[e]),n)?(0,t.jsxs)("div",{className:"mb-4",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Popular APIs"}),(0,t.jsx)("div",{className:"flex justify-center py-6",children:(0,t.jsx)(W.Spin,{size:"small"})})]}):0===l.length?null:(0,t.jsxs)("div",{className:"mb-4",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700 block mb-2",children:"Popular APIs"}),(0,t.jsx)("div",{className:"grid grid-cols-5 gap-2",children:l.map(e=>{let l=s===e.name,a=o.has(e.name);return(0,t.jsxs)("button",{type:"button",title:e.description,onClick:()=>r(e),className:`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer ${l?"border-blue-500 bg-blue-50 shadow-sm":"border-gray-200 hover:border-blue-300 hover:bg-gray-50"}`,children:[a?(0,t.jsx)("span",{className:"w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600",children:e.title.charAt(0)}):(0,t.jsx)("img",{src:e.icon_url,alt:e.title,className:"w-7 h-7 object-contain",onError:()=>{var t;return t=e.name,void c(e=>new Set(e).add(t))}}),(0,t.jsx)("span",{className:"text-xs text-gray-600 text-center leading-tight font-medium",children:e.title})]},e.name)})}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-2",children:"Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below."})]})},eU=({form:e,accessToken:s,onValuesChange:r,onKeyToolsChange:l,onLogoUrlChange:a,onOAuthDocsUrlChange:n})=>{let[i,o]=(0,b.useState)(null);return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(ez,{accessToken:s,selectedName:i,onSelect:t=>{o(t.name),l?.(t.key_tools??[]),a?.(t.icon_url||void 0);let s={spec_path:t.spec_url};t.oauth?(s.auth_type=eo.AUTH_TYPE.OAUTH2,s.oauth_flow_type=eo.OAUTH_FLOW.INTERACTIVE,s.authorization_url=t.oauth.authorization_url,s.token_url=t.oauth.token_url,e.setFieldsValue(s),n?.(t.oauth.docs_url??null)):(e.resetFields(["auth_type","authorization_url","token_url"]),e.setFieldsValue(s),n?.(null)),r(s)}}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OpenAPI Spec URL",(0,t.jsx)(g.Tooltip,{title:"URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"spec_path",rules:[{required:!0,message:"Please enter an OpenAPI spec URL"}],children:(0,t.jsx)(H.Input,{placeholder:"https://petstore3.swagger.io/api/v3/openapi.json",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",onChange:()=>{o(null),l?.([]),n?.(null)}})})]})};var eB=e.i(596239);let eq="/ui/assets/logos/",eV=[{name:"GitHub",url:`${eq}github.svg`},{name:"Slack",url:`${eq}slack.svg`},{name:"Notion",url:`${eq}notion.svg`},{name:"Linear",url:`${eq}linear.svg`},{name:"Jira",url:`${eq}jira.svg`},{name:"Figma",url:`${eq}figma.svg`},{name:"Gmail",url:`${eq}gmail.svg`},{name:"Google Drive",url:`${eq}google_drive.svg`},{name:"Stripe",url:`${eq}stripe.svg`},{name:"Shopify",url:`${eq}shopify.svg`},{name:"Salesforce",url:`${eq}salesforce.svg`},{name:"HubSpot",url:`${eq}hubspot.svg`},{name:"Twilio",url:`${eq}twilio.svg`},{name:"Cloudflare",url:`${eq}cloudflare.svg`},{name:"Sentry",url:`${eq}sentry.svg`},{name:"PostgreSQL",url:`${eq}postgresql.svg`},{name:"Snowflake",url:`${eq}snowflake.svg`},{name:"Zapier",url:`${eq}zapier.svg`},{name:"Google",url:`${eq}google.svg`},{name:"GitLab",url:`${eq}gitlab.svg`}],e$=({value:e,onChange:s})=>{let[r,l]=(0,b.useState)(new Set);return(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Logo"}),(0,t.jsx)(g.Tooltip,{title:"Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-blue-400 hover:text-blue-600 cursor-help"})})]}),e&&(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200",children:[(0,t.jsx)("img",{src:e,alt:"Selected logo",className:"w-10 h-10 object-contain rounded",onError:e=>{e.target.style.display="none"}}),(0,t.jsx)("div",{className:"flex-1 min-w-0",children:(0,t.jsx)("div",{className:"text-xs text-gray-500 truncate",children:e})}),(0,t.jsx)("button",{type:"button",onClick:()=>s?.(void 0),className:"text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none",children:"✕"})]}),(0,t.jsx)("div",{className:"grid grid-cols-10 gap-1.5 mb-3",children:eV.map(a=>{let n=e===a.url;return r.has(a.url)?null:(0,t.jsx)(g.Tooltip,{title:a.name,children:(0,t.jsx)("button",{type:"button",onClick:()=>{var t;return t=a.url,void s?.(e===t?void 0:t)},className:`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer ${n?"border-blue-500 bg-blue-50 shadow-sm":"border-gray-200 hover:border-blue-300 hover:bg-gray-50"}`,style:{width:40,height:40},children:(0,t.jsx)("img",{src:a.url,alt:a.name,className:"w-5 h-5 object-contain",onError:()=>{var e;return e=a.url,void l(t=>new Set(t).add(e))}})})},a.name)})}),(0,t.jsx)(H.Input,{prefix:(0,t.jsx)(eB.LinkOutlined,{className:"text-gray-400"}),placeholder:"Or paste a custom logo URL...",value:e&&!eV.some(t=>t.url===e)?e:"",onChange:e=>{let t=e.target.value.trim();s?.(t||void 0)},className:"rounded-lg",size:"small"})]})},eD=e=>{try{let t=e.indexOf("/mcp/");if(-1===t)return{token:null,baseUrl:e};let s=e.split("/mcp/");if(2!==s.length)return{token:null,baseUrl:e};let r=s[0]+"/mcp/",l=s[1];if(!l)return{token:null,baseUrl:e};return{token:l,baseUrl:r}}catch(t){return console.error("Error parsing MCP URL:",t),{token:null,baseUrl:e}}},eH=e=>{let{token:t}=eD(e);return{maskedUrl:(e=>{let{token:t,baseUrl:s}=eD(e);return t?s+"...":e})(e),hasToken:!!t}},eK=e=>e?/^https?:\/\/[^\s/$.?#].[^\s]*$/i.test(e)?Promise.resolve():Promise.reject("Please enter a valid URL (e.g., http://service-name.domain:1234/path or https://example.com)"):Promise.resolve(),eW=e=>e&&(e.includes("-")||e.includes(" "))?Promise.reject("Cannot contain '-' (hyphen) or spaces. Please use '_' (underscore) instead."):Promise.resolve();var eJ=e.i(122520),eY=e.i(165615),eG=e.i(434166);let eQ=({accessToken:e,getCredentials:t,getTemporaryPayload:s,onTokenReceived:r,onBeforeRedirect:l})=>{let[a,n]=(0,b.useState)("idle"),[i,o]=(0,b.useState)(null),[c,d]=(0,b.useState)(null),m=(0,b.useRef)(!1),u="litellm-mcp-oauth-flow-state",x="litellm-mcp-oauth-result",h="litellm-mcp-oauth-return-url",p=(e,t)=>{(0,eG.setSecureItem)(e,t)},g=e=>{try{return(0,eG.getSecureItem)(e)}catch(t){return console.warn(`Failed to get storage item ${e}`,t),null}},f=()=>{try{window.sessionStorage.removeItem(u),window.sessionStorage.removeItem(x),window.sessionStorage.removeItem(h),window.localStorage.removeItem(u),window.localStorage.removeItem(x),window.localStorage.removeItem(h)}catch(e){console.warn("Failed to clear OAuth storage",e)}},j=()=>{let e,t,s;return s=((t=(e=window.location.pathname||"").indexOf("/ui"))>=0?e.slice(0,t+3):"").replace(/\/+$/,""),`${window.location.origin}${s}/mcp/oauth/callback`},y=(0,b.useCallback)(async()=>{let r=t()||{};if(!e){o("Missing admin token"),C.default.error("Access token missing. Please re-authenticate and try again.");return}let a=s();if(!a||!a.url||!a.transport){let e="Please complete server URL and transport before starting OAuth.";o(e),C.default.error(e);return}try{n("authorizing"),o(null);let t=await (0,_.cacheTemporaryMcpServer)(e,a),s=t?.server_id?.trim();if(!s)throw Error("Temporary MCP server identifier missing. Please retry.");let i={};if(!(a.credentials?.client_id&&a.credentials?.client_secret)){let t=await (0,_.registerMcpOAuthClient)(e,s,{client_name:a.alias||a.server_name||s,grant_types:["authorization_code","refresh_token"],response_types:["code"],token_endpoint_auth_method:a.credentials&&a.credentials.client_secret?"client_secret_post":"none"});i={clientId:t?.client_id,clientSecret:t?.client_secret}}let c=(0,eY.generateCodeVerifier)(),d=await (0,eY.generateCodeChallenge)(c),m=crypto.randomUUID(),x=i.clientId||r.client_id,g=Array.isArray(r.scopes)?r.scopes.filter(e=>e&&e.trim().length>0).join(" "):void 0,f=(0,_.buildMcpOAuthAuthorizeUrl)({serverId:s,clientId:x,redirectUri:j(),state:m,codeChallenge:d,scope:g}),b={state:m,codeVerifier:c,clientId:x,clientSecret:i.clientSecret||r.client_secret,serverId:s,redirectUri:j()};if(l)try{l()}catch(e){console.error("Failed to prepare for OAuth redirect",e)}try{p(u,JSON.stringify(b)),p(h,window.location.href)}catch(e){throw Error("Unable to access browser storage for OAuth. Please enable storage and retry.")}window.location.href=f}catch(t){console.error("Failed to start OAuth flow",t),n("error");let e=(0,eJ.extractErrorMessage)(t);o(e),C.default.error(e)}},[e,t,s,l]),v=(0,b.useCallback)(async()=>{if(m.current)return;let t=null,s=null;try{let e=g(x);if(!e)return;m.current=!0,t=JSON.parse(e);let r=g(u);s=r?JSON.parse(r):null}catch(e){f(),m.current=!1,o("Failed to resume OAuth flow. Please retry."),n("error"),C.default.error("Failed to resume OAuth flow. Please retry.");return}if(!t){m.current=!1;return}try{window.sessionStorage.removeItem(x),window.localStorage.removeItem(x)}catch(e){}try{if(!s||!s.state||!s.codeVerifier||!s.serverId)throw Error("OAuth session state was lost. This can happen if you have strict browser privacy settings. Please try again and ensure cookies/storage is enabled.");if(!t.state||t.state!==s.state)throw Error("OAuth state mismatch. Please retry.");if(t.error)throw Error(t.error_description||t.error);if(!t.code)throw Error("Authorization code missing in callback.");n("exchanging");let l=await (0,_.exchangeMcpOAuthToken)({serverId:s.serverId,code:t.code,clientId:s.clientId,clientSecret:s.clientSecret,codeVerifier:s.codeVerifier,redirectUri:s.redirectUri,accessToken:e});r(l),d(l),n("success"),o(null),C.default.success("OAuth token retrieved successfully")}catch(t){let e=(0,eJ.extractErrorMessage)(t);o(e),n("error"),C.default.error(e)}finally{f(),setTimeout(()=>{m.current=!1},1e3)}},[r]);return(0,b.useEffect)(()=>{v()},[v]),{startOAuthFlow:y,status:a,error:i,tokenResponse:c}},eZ="../ui/assets/logos/mcp_logo.png",eX=[eo.AUTH_TYPE.API_KEY,eo.AUTH_TYPE.BEARER_TOKEN,eo.AUTH_TYPE.TOKEN,eo.AUTH_TYPE.BASIC],e0=[...eX,eo.AUTH_TYPE.OAUTH2,eo.AUTH_TYPE.AWS_SIGV4],e2="litellm-mcp-oauth-create-state",e1=e=>Array.isArray(e)?e.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{},e5=({userRole:e,accessToken:r,onCreateSuccess:a,isModalVisible:n,setModalVisible:i,availableAccessGroups:o,prefillData:c,onBackToDiscovery:d})=>{let[m]=D.Form.useForm(),[u,x]=(0,b.useState)(!1),[f,j]=(0,b.useState)({}),[y,v]=(0,b.useState)({}),[N,w]=(0,b.useState)(null),[S,T]=(0,b.useState)(!1),[k,A]=(0,b.useState)([]),[I,P]=(0,b.useState)({}),[O,M]=(0,b.useState)({}),[F,E]=(0,b.useState)(""),[L,R]=(0,b.useState)([]),[z,U]=(0,b.useState)(""),[B,q]=(0,b.useState)(null),[V,$]=(0,b.useState)(void 0),[K,W]=(0,b.useState)(null),{tools:J,isLoadingTools:Y,toolsError:G,toolsErrorStackTrace:Q,canFetchTools:Z,fetchTools:X,clearTools:ee}=ek({accessToken:r,oauthAccessToken:B,formValues:y,enabled:!0}),et=y.auth_type,es=!!et&&eX.includes(et),er=et===eo.AUTH_TYPE.OAUTH2,ec=et===eo.AUTH_TYPE.AWS_SIGV4,ed=er&&y.oauth_flow_type===eo.OAUTH_FLOW.M2M,{startOAuthFlow:em,status:ex,error:eh,tokenResponse:ep}=eQ({accessToken:r,getCredentials:()=>m.getFieldValue("credentials"),getTemporaryPayload:()=>{let e=m.getFieldsValue(!0),t=e.transport||F,s=e.url||(t===eo.TRANSPORT.OPENAPI?e.spec_path:void 0);if(!s||!t)return null;let r=e1(e.static_headers);return{server_id:void 0,server_name:e.server_name,alias:e.alias,description:e.description,url:s,transport:t===eo.TRANSPORT.OPENAPI?"http":t,auth_type:eo.AUTH_TYPE.OAUTH2,credentials:e.credentials,authorization_url:e.authorization_url,token_url:e.token_url,registration_url:e.registration_url,mcp_access_groups:e.mcp_access_groups,static_headers:r,command:e.command,args:e.args,env:e.env}},onTokenReceived:e=>{if(q(e?.access_token??null),e?.access_token){let t={access_token:e.access_token,...e.refresh_token&&{refresh_token:e.refresh_token},...e.expires_in&&{expires_in:e.expires_in},...e.scope&&{scope:e.scope}};m.setFieldsValue({credentials:t}),C.default.success("OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.")}},onBeforeRedirect:()=>{try{let e=m.getFieldsValue(!0);(0,eG.setSecureItem)(e2,JSON.stringify({modalVisible:n,formValues:e,transportType:F,costConfig:f,allowedTools:k,searchValue:z,aliasManuallyEdited:S,logoUrl:V}))}catch(e){console.warn("Failed to persist MCP create state",e)}}});b.default.useEffect(()=>{let e=(0,eG.getSecureItem)(e2);if(e)try{let t=JSON.parse(e);t.modalVisible&&i(!0);let s=t.formValues?.transport||t.transportType||"";s&&E(s),t.formValues&&w({values:t.formValues,transport:s}),t.costConfig&&j(t.costConfig),t.allowedTools&&A(t.allowedTools),t.searchValue&&U(t.searchValue),"boolean"==typeof t.aliasManuallyEdited&&T(t.aliasManuallyEdited),t.logoUrl&&$(t.logoUrl)}catch(e){console.error("Failed to restore MCP create state",e)}finally{window.sessionStorage.removeItem(e2)}},[m,i]),b.default.useEffect(()=>{N&&(F||N.transport,(!N.transport||F)&&(m.setFieldsValue(N.values),v(N.values),w(null)))},[N,m,F]),b.default.useEffect(()=>{if(!n||!c)return;let e=(c.name||"").replace(/[^a-zA-Z0-9_]/g,"_").replace(/_+/g,"_").replace(/^_|_$/g,""),t=c.transport||"";E(t);let s={server_name:e,alias:e,description:c.description||"",transport:t};if("stdio"===t){let e={};if(c.command&&(e.command=c.command),c.args&&c.args.length>0&&(e.args=c.args),c.env_vars&&c.env_vars.length>0){let t={};for(let e of c.env_vars)t[e.name]=e.description?`<${e.description}>`:"";e.env=t}Object.keys(e).length>0&&(s.stdio_config=JSON.stringify(e,null,2))}else c.url&&(s.url=c.url);m.setFieldsValue(s),v(s),T(!1)},[n,c,m]);let eg=async e=>{x(!0);try{let{static_headers:t,stdio_config:s,credentials:l,allow_all_keys:n,available_on_public_internet:o,delegate_auth_to_upstream:c,token_validation_json:d,...u}=e,h=u.mcp_access_groups,p=e1(t),g=l&&"object"==typeof l?Object.entries(l).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,b={};if(s&&"stdio"===F)try{let e=JSON.parse(s),t=e;if(e.mcpServers&&"object"==typeof e.mcpServers){let s=Object.keys(e.mcpServers);if(s.length>0){let r=s[0];t=e.mcpServers[r],u.server_name||(u.server_name=r.replace(/-/g,"_"))}}b={command:t.command,args:t.args,env:t.env},console.log("Parsed stdio config:",b)}catch(e){C.default.fromBackend("Invalid JSON in stdio configuration");return}u.transport===eo.TRANSPORT.OPENAPI&&(u.transport="http");let y=null;if(d&&""!==d.trim())try{y=JSON.parse(d)}catch{C.default.fromBackend("Invalid JSON in Token Validation Rules"),x(!1);return}let v={...u,...b,stdio_config:void 0,mcp_info:{server_name:u.server_name||u.url,description:u.description,logo_url:V||void 0,mcp_server_cost_info:Object.keys(f).length>0?f:null},mcp_access_groups:h,alias:u.alias,allowed_tools:k.length>0?k:null,tool_name_to_display_name:Object.keys(I).length>0?I:null,tool_name_to_description:Object.keys(O).length>0?O:null,allow_all_keys:!!n,available_on_public_internet:!!o,delegate_auth_to_upstream:!!c,static_headers:p,...null!==y&&{token_validation:y}};if(v.static_headers=p,u.auth_type&&e0.includes(u.auth_type)&&g&&Object.keys(g).length>0&&(v.credentials=g),console.log(`Payload: ${JSON.stringify(v)}`),null!=r){let e=ej?await (0,_.createMCPServer)(r,v):await (0,_.registerMCPServer)(r,v);C.default.success(ej?"MCP Server created successfully":"MCP Server submitted for admin review"),m.resetFields(),j({}),ee(),A([]),T(!1),$(void 0),i(!1),a(e)}}catch(t){let e=t instanceof Error?t.message:String(t);C.default.fromBackend(ej?`Error creating MCP Server: ${e}`:`Error submitting MCP Server: ${e}`)}finally{x(!1)}},eb=()=>{m.resetFields(),j({}),ee(),A([]),T(!1),$(void 0),i(!1)};b.default.useEffect(()=>{if(!S&&y.server_name){let e=y.server_name.replace(/\s+/g,"_");m.setFieldsValue({alias:e}),v(t=>({...t,alias:e}))}},[y.server_name]),b.default.useEffect(()=>{n||v({})},[n]);let ej=(0,s.isAdminRole)(e);return(0,t.jsx)(h.Modal,{title:(0,t.jsxs)("div",{className:"flex items-center pb-4 border-b border-gray-100",style:{gap:12},children:[d&&(0,t.jsx)("button",{onClick:d,className:"text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none",style:{flexShrink:0},children:"←"}),(0,t.jsx)("img",{src:eZ,alt:"MCP Logo",className:"w-8 h-8 object-contain",style:{height:"20px",width:"20px",objectFit:"contain"}}),(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:ej?"Add New MCP Server":"Submit MCP Server for Review"})]}),open:n,width:1e3,onCancel:eb,footer:null,forceRender:!0,className:"top-8",styles:{body:{padding:"24px"},header:{padding:"24px 24px 0 24px",border:"none"}},children:(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsxs)(D.Form,{form:m,onFinish:eg,onValuesChange:(e,t)=>v(t),layout:"vertical",className:"space-y-6",children:[!ej&&(0,t.jsxs)("div",{className:"rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800",children:["Your submission will be sent for admin review before it becomes active."," ","Note: the request must be made with a team-scoped API key."]}),(0,t.jsxs)("div",{className:"grid grid-cols-1 gap-6",children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["MCP Server Name",(0,t.jsx)(g.Tooltip,{title:"Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Cannot contain spaces or hyphens; use underscores instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"server_name",rules:[{required:!1,message:"Please enter a server name"},{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(ei.TextInput,{placeholder:"e.g., GitHub_MCP, Zapier_MCP, etc.",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Alias",(0,t.jsx)(g.Tooltip,{title:"A short, unique identifier for this server. Defaults to the server name if not provided. Cannot contain spaces or hyphens; use underscores instead.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"alias",rules:[{required:!1},{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(ei.TextInput,{placeholder:"e.g., GitHub_MCP, Zapier_MCP, etc.",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500",onChange:()=>T(!0)})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Description"}),name:"description",rules:[{required:!1,message:"Please enter a server description"}],children:(0,t.jsx)(ei.TextInput,{placeholder:"Brief description of what this server does",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(e$,{value:V,onChange:$}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"GitHub / Source URL"}),name:"source_url",children:(0,t.jsx)(ei.TextInput,{placeholder:"https://github.com/org/mcp-server",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"Transport Type"}),name:"transport",rules:[{required:!0,message:"Please select a transport type"}],children:(0,t.jsxs)(p.Select,{placeholder:"Select transport",className:"rounded-lg",size:"large",onChange:e=>{E(e),"stdio"===e?m.setFieldsValue({url:void 0,spec_path:void 0,auth_type:void 0,credentials:void 0}):e===eo.TRANSPORT.OPENAPI?m.setFieldsValue({url:void 0,command:void 0,args:void 0,env:void 0}):m.setFieldsValue({spec_path:void 0,command:void 0,args:void 0,env:void 0})},value:F,children:[(0,t.jsx)(p.Select.Option,{value:"http",children:"Streamable HTTP (Recommended)"}),(0,t.jsx)(p.Select.Option,{value:"sse",children:"Server-Sent Events (SSE)"}),(0,t.jsx)(p.Select.Option,{value:"stdio",children:"Standard Input/Output (stdio)"}),(0,t.jsx)(p.Select.Option,{value:eo.TRANSPORT.OPENAPI,children:"OpenAPI Spec"})]})}),("http"===F||"sse"===F)&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsx)("span",{className:"text-sm font-medium text-gray-700",children:"MCP Server URL"}),name:"url",rules:[{required:!0,message:"Please enter a server URL"},{validator:(e,t)=>eK(t)}],children:(0,t.jsx)(H.Input,{placeholder:"https://your-mcp-server.com",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),F===eo.TRANSPORT.OPENAPI&&(0,t.jsx)(eU,{form:m,accessToken:n?r:null,onValuesChange:e=>v(t=>({...t,...e})),onKeyToolsChange:R,onLogoUrlChange:$,onOAuthDocsUrlChange:W}),F===eo.TRANSPORT.OPENAPI&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center gap-2",children:["BYOK (Bring Your Own Key)",(0,t.jsx)(g.Tooltip,{title:"When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"is_byok",valuePropName:"checked",children:(0,t.jsx)(el.Switch,{})}),(0,t.jsx)(D.Form.Item,{noStyle:!0,shouldUpdate:(e,t)=>e.is_byok!==t.is_byok||e.auth_type!==t.auth_type,children:({getFieldValue:e})=>e("is_byok")?(0,t.jsxs)(t.Fragment,{children:[e("auth_type")&&"none"!==e("auth_type")&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2",children:[(0,t.jsx)(en.InfoCircleOutlined,{className:"mt-0.5 flex-shrink-0"}),(0,t.jsxs)("span",{children:["User keys will be sent as:"," ",(0,t.jsxs)("code",{className:"font-mono bg-blue-100 px-1 rounded",children:["bearer_token"===e("auth_type")&&"Authorization: Bearer {key}","token"===e("auth_type")&&"Authorization: token {key}","api_key"===e("auth_type")&&"x-api-key: {key}","basic"===e("auth_type")&&"Authorization: Basic {key}","authorization"===e("auth_type")&&"Authorization: {key}"]}),!e("auth_type")&&"Set Authentication Type below to specify the format."]})]}),!e("auth_type")&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2",children:[(0,t.jsx)(en.InfoCircleOutlined,{className:"mt-0.5 flex-shrink-0"}),(0,t.jsxs)("span",{children:["Set the ",(0,t.jsx)("strong",{children:"Authentication Type"})," below to specify how user keys are sent (e.g., Bearer Token, API Key header)."]})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["Access Description",(0,t.jsx)(g.Tooltip,{title:"List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"byok_description",children:(0,t.jsx)(p.Select,{mode:"tags",placeholder:"Add access description items (press Enter after each)",className:"w-full",tokenSeparators:[","]})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["API Key Help URL",(0,t.jsx)(g.Tooltip,{title:"Optional link shown to users to help them find their API key",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"byok_api_key_help_url",children:(0,t.jsx)(H.Input,{placeholder:"https://docs.example.com/api-keys"})})]}):null})]}),"stdio"!==F&&""!==F&&(0,t.jsx)(ea.Collapse,{defaultActiveKey:["auth"],className:"mb-4",items:[{key:"auth",label:(0,t.jsx)("span",{className:"text-sm font-semibold text-gray-700",children:"Authentication"}),children:(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{name:"auth_type",rules:[{required:!0,message:"Please select an auth type"}],children:(0,t.jsxs)(p.Select,{placeholder:"Select auth type",className:"rounded-lg",size:"large",children:[(0,t.jsx)(p.Select.Option,{value:"none",children:"None"}),(0,t.jsx)(p.Select.Option,{value:"api_key",children:"API Key"}),(0,t.jsx)(p.Select.Option,{value:"bearer_token",children:"Bearer Token"}),(0,t.jsx)(p.Select.Option,{value:"token",children:"Token"}),(0,t.jsx)(p.Select.Option,{value:"basic",children:"Basic Auth"}),(0,t.jsx)(p.Select.Option,{value:"oauth2",children:"OAuth"}),(0,t.jsx)(p.Select.Option,{value:"aws_sigv4",children:"AWS SigV4 (Bedrock AgentCore MCPs)"})]})}),es&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authentication Value",(0,t.jsx)(g.Tooltip,{title:"Token, password, or header value to send with each request for the selected auth type.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","auth_value"],rules:[{validator:(e,t)=>t&&"string"==typeof t&&""===t.trim()?Promise.reject(Error("Authentication value cannot be empty whitespace")):Promise.resolve()}],children:(0,t.jsx)(ei.TextInput,{type:"password",placeholder:"Enter token or secret",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),er&&(0,t.jsx)(eu,{isM2M:ed,initialFlowType:eo.OAUTH_FLOW.INTERACTIVE,docsUrl:K,oauthFlow:{startOAuthFlow:em,status:ex,error:eh,tokenResponse:ep}})]})}]}),"stdio"!==F&&""!==F&&ec&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-2",children:["For MCP servers hosted on AWS Bedrock AgentCore."," ",(0,t.jsx)("a",{href:"https://docs.litellm.ai/docs/mcp_aws_sigv4",target:"_blank",rel:"noopener noreferrer",className:"text-blue-500 hover:text-blue-700",children:"View docs →"})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Region",(0,t.jsx)(g.Tooltip,{title:"AWS region for SigV4 signing (e.g., us-east-1)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_region_name"],rules:[{required:!0,message:"AWS region is required for SigV4 auth"}],children:(0,t.jsx)(H.Input,{placeholder:"us-east-1",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Service Name",(0,t.jsx)(g.Tooltip,{title:"AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_service_name"],children:(0,t.jsx)(H.Input,{placeholder:"bedrock-agentcore",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Access Key ID",(0,t.jsx)(g.Tooltip,{title:"Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_access_key_id"],dependencies:[["credentials","aws_secret_access_key"]],rules:[({getFieldValue:e})=>({validator:(t,s)=>e(["credentials","aws_secret_access_key"])&&!s?Promise.reject(Error("Access Key ID is required when Secret Access Key is provided")):Promise.resolve()})],children:(0,t.jsx)(H.Input.Password,{placeholder:"AKIA... (optional — uses IAM role if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Secret Access Key",(0,t.jsx)(g.Tooltip,{title:"Optional. Required if AWS Access Key ID is provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_secret_access_key"],dependencies:[["credentials","aws_access_key_id"]],rules:[({getFieldValue:e})=>({validator:(t,s)=>e(["credentials","aws_access_key_id"])&&!s?Promise.reject(Error("Secret Access Key is required when Access Key ID is provided")):Promise.resolve()})],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter secret key (optional — uses IAM role if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Token",(0,t.jsx)(g.Tooltip,{title:"Optional. Only needed for temporary STS credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_token"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter session token (optional)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Role ARN",(0,t.jsx)(g.Tooltip,{title:"Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials. Uses ambient credentials (IAM role, env vars) as the source identity unless explicit keys are also provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_role_name"],children:(0,t.jsx)(H.Input,{placeholder:"arn:aws:iam::123456789012:role/MyRole (optional)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Name",(0,t.jsx)(g.Tooltip,{title:"Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_name"],children:(0,t.jsx)(H.Input,{placeholder:"litellm-prod (optional, auto-generated if blank)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})]}),(0,t.jsx)(eO,{isVisible:"stdio"===F})]}),(0,t.jsx)("div",{className:"mt-8",children:(0,t.jsx)(eR,{availableAccessGroups:o,mcpServer:null,searchValue:z,setSearchValue:U,getAccessGroupOptions:()=>{let e=o.map(e=>({value:e,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-green-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:e})]})}));return z&&!o.some(e=>e.toLowerCase().includes(z.toLowerCase()))&&e.push({value:z,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:z}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-1",children:"create new group"})]})}),e}})}),(0,t.jsx)("div",{className:"mt-8 pt-6 border-t border-gray-200",children:(0,t.jsx)(e_,{formValues:y,tools:J,isLoadingTools:Y,toolsError:G,toolsErrorStackTrace:Q,canFetchTools:Z,fetchTools:X})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eP,{accessToken:r,oauthAccessToken:B,formValues:y,allowedTools:k,existingAllowedTools:null,onAllowedToolsChange:A,toolNameToDisplayName:I,toolNameToDescription:O,onToolNameToDisplayNameChange:P,onToolNameToDescriptionChange:M,keyTools:L,externalTools:J,externalIsLoading:Y,externalError:G,externalCanFetch:Z})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(ef,{value:f,onChange:j,tools:J.filter(e=>k.includes(e.name)),disabled:!1})}),(0,t.jsxs)("div",{className:"flex items-center justify-end space-x-3 pt-6 border-t border-gray-100",children:[(0,t.jsx)(l.Button,{variant:"secondary",onClick:eb,children:"Cancel"}),(0,t.jsx)(l.Button,{variant:"primary",loading:u,children:u?"Creating...":"Add MCP Server"})]})]})})})};var e4=e.i(175712),e6=e.i(118366),e3=e.i(475254);let e7=(0,e3.default)("code",[["path",{d:"m16 18 6-6-6-6",key:"eg8j8"}],["path",{d:"m8 6-6 6 6 6",key:"ppft3o"}]]);e.s(["Code",()=>e7],758472);let e8=(0,e3.default)("terminal",[["path",{d:"M12 19h8",key:"baeox8"}],["path",{d:"m4 17 6-6-6-6",key:"1yngyt"}]]),e9=(0,e3.default)("globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);var te=e.i(634831),tt=e.i(438100);let ts=(0,e3.default)("zap",[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]]);var tr=e.i(500330);let{Title:tl,Text:ta}=f.Typography,{Panel:tn}=ea.Collapse,ti=({icon:e,title:s,description:r,children:l,serverName:a,accessGroups:n=["dev-group"]})=>{let[i,o]=(0,b.useState)(!1);return(0,t.jsxs)(e4.Card,{className:"border border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)("span",{className:"p-2 rounded-lg bg-gray-50",children:e}),(0,t.jsxs)("div",{children:[(0,t.jsx)(tl,{level:5,className:"mb-0",children:s}),(0,t.jsx)(ta,{className:"text-gray-600",children:r})]})]}),a&&("Implementation Example"===s||"Configuration"===s)&&(0,t.jsxs)(D.Form.Item,{className:"mb-4",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)(el.Switch,{size:"small",checked:i,onChange:o}),(0,t.jsxs)(ta,{className:"text-sm",children:["Limit tools to specific MCP servers or MCP groups by passing the ",(0,t.jsx)("code",{children:"x-mcp-servers"})," header"]})]}),i&&(0,t.jsx)(ej.Alert,{className:"mt-2",type:"info",showIcon:!0,message:"Two Options",description:(0,t.jsxs)("div",{children:[(0,t.jsxs)("p",{children:[(0,t.jsx)("strong",{children:"Option 1:"})," Get a specific server: ",(0,t.jsxs)("code",{children:['"',a.replace(/\s+/g,"_"),'"']})]}),(0,t.jsxs)("p",{children:[(0,t.jsx)("strong",{children:"Option 2:"})," Get a group of MCPs: ",(0,t.jsx)("code",{children:'"dev-group"'})]}),(0,t.jsxs)("p",{className:"mt-2 text-sm text-gray-600",children:["You can also mix both: ",(0,t.jsx)("code",{children:'"Server1,dev-group"'})]})]})})]}),b.default.Children.map(l,e=>{if(b.default.isValidElement(e)&&e.props.hasOwnProperty("code")&&e.props.hasOwnProperty("copyKey")){let t=e.props.code;if(t&&t.includes('"headers":'))return b.default.cloneElement(e,{code:t.replace(/"headers":\s*{[^}]*}/,`"headers": ${JSON.stringify((()=>{let e={"x-litellm-api-key":"Bearer YOUR_LITELLM_API_KEY"};if(i&&a){let t=[a.replace(/\s+/g,"_"),...n].join(",");e["x-mcp-servers"]=t}return e})(),null,8)}`)})}return e})]})},to=({currentServerAccessGroups:e=[]})=>{let s=(0,_.getProxyBaseUrl)(),[r,l]=(0,b.useState)({}),[u,x]=(0,b.useState)({openai:[],litellm:[],cursor:[],http:[]}),[h]=(0,b.useState)("Zapier_MCP"),p=async(e,t)=>{await (0,tr.copyToClipboard)(e)&&(l(e=>({...e,[t]:!0})),setTimeout(()=>{l(e=>({...e,[t]:!1}))},2e3))},g=({code:e,copyKey:s,title:l,className:a=""})=>(0,t.jsxs)("div",{className:"relative group",children:[l&&(0,t.jsxs)("div",{className:"flex items-center gap-2 mb-2",children:[(0,t.jsx)(e7,{size:16,className:"text-blue-600"}),(0,t.jsx)(ta,{strong:!0,className:"text-gray-700",children:l})]}),(0,t.jsxs)(e4.Card,{className:`bg-gray-50 border border-gray-200 relative ${a}`,children:[(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:r[s]?(0,t.jsx)(k.CheckIcon,{size:12}):(0,t.jsx)(e6.CopyIcon,{size:12}),onClick:()=>p(e,s),className:`absolute top-2 right-2 z-10 transition-all duration-200 ${r[s]?"text-green-600 bg-green-50 border-green-200":"text-gray-500 hover:text-gray-700 hover:bg-gray-100"}`}),(0,t.jsx)("pre",{className:"text-sm overflow-x-auto pr-10 text-gray-800 font-mono leading-relaxed",children:e})]})]}),f=({step:e,title:s,children:r})=>(0,t.jsxs)("div",{className:"flex gap-4",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("div",{className:"w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold",children:e})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsx)(ta,{strong:!0,className:"text-gray-800 block mb-2",children:s}),r]})]});return(0,t.jsx)("div",{children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(m.Title,{className:"text-3xl font-bold text-gray-900 mb-3",children:"Connect to your MCP client"}),(0,t.jsx)(d.Text,{className:"text-lg text-gray-600",children:"Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world tasks through a simple, secure connection."})]}),(0,t.jsxs)(n.TabGroup,{className:"w-full",children:[(0,t.jsx)(i.TabList,{className:"flex justify-start mt-8 mb-6",children:(0,t.jsxs)("div",{className:"flex bg-gray-100 p-1 rounded-lg",children:[(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e7,{size:18}),"OpenAI API"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(ts,{size:18}),"LiteLLM Proxy"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e8,{size:18}),"Cursor"]})}),(0,t.jsx)(a.Tab,{className:"px-6 py-3 rounded-md transition-all duration-200",children:(0,t.jsxs)("span",{className:"flex items-center gap-2 font-medium",children:[(0,t.jsx)(e9,{size:18}),"Streamable HTTP"]})})]})}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsx)(o.TabPanel,{className:"mt-6",children:(0,t.jsx)(()=>(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{className:"bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-100",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)(e7,{className:"text-blue-600",size:24}),(0,t.jsx)(tl,{level:4,className:"mb-0 text-blue-900",children:"OpenAI Responses API Integration"})]}),(0,t.jsx)(ta,{className:"text-blue-700",children:"Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration"})]}),(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsx)(ti,{icon:(0,t.jsx)(tt.KeyIcon,{className:"text-blue-600",size:16}),title:"API Key Setup",description:"Configure your OpenAI API key for authentication",children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"middle",className:"w-full",children:[(0,t.jsx)("div",{children:(0,t.jsxs)(ta,{children:["Get your API key from the"," ",(0,t.jsxs)("a",{href:"https://platform.openai.com/api-keys",target:"_blank",rel:"noopener noreferrer",className:"text-blue-600 hover:text-blue-700 inline-flex items-center gap-1",children:["OpenAI platform ",(0,t.jsx)(te.ExternalLinkIcon,{size:12})]})]})}),(0,t.jsx)(g,{title:"Environment Variable",code:'export OPENAI_API_KEY="sk-..."',copyKey:"openai-env"})]})}),(0,t.jsx)(ti,{icon:(0,t.jsx)(P.ServerIcon,{className:"text-blue-600",size:16}),title:"MCP Server Information",description:"Connection details for your LiteLLM MCP server",children:(0,t.jsx)(g,{title:"Server URL",code:`${s}/mcp`,copyKey:"openai-server-url"})}),(0,t.jsx)(ti,{icon:(0,t.jsx)(e7,{className:"text-blue-600",size:16}),title:"Implementation Example",description:"Complete cURL example for using the Responses API",serverName:"Zapier Gmail",accessGroups:["dev-group"],children:(0,t.jsx)(g,{code:`curl --location 'https://api.openai.com/v1/responses' \\ +>>>>>>>> origin/litellm_internal_staging:litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js --header 'Content-Type: application/json' \\ --header "Authorization: Bearer $OPENAI_API_KEY" \\ --data '{ @@ -66,9 +80,15 @@ } } } +<<<<<<<< HEAD:litellm/proxy/_experimental/out/_next/static/chunks/0279e5299e9f6e98.js +}`,copyKey:"cursor-config",className:"text-xs"})})]})]})]})]}),{})}),(0,t.jsx)(o.TabPanel,{className:"mt-6",children:(0,t.jsx)(()=>(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{className:"bg-gradient-to-r from-green-50 to-teal-50 p-6 rounded-lg border border-green-100",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)(e9,{className:"text-green-600",size:24}),(0,t.jsx)(tl,{level:4,className:"mb-0 text-green-900",children:"Streamable HTTP Transport"})]}),(0,t.jsx)(ta,{className:"text-green-700",children:"Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming."})]}),(0,t.jsx)(ti,{icon:(0,t.jsx)(e9,{className:"text-green-600",size:16}),title:"Universal MCP Connection",description:"Use this URL with any MCP client that supports HTTP transport",children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"middle",className:"w-full",children:[(0,t.jsx)("div",{children:(0,t.jsx)(ta,{children:"Each MCP client supports different transports. Refer to your client documentation to determine the appropriate transport method."})}),(0,t.jsx)(g,{title:"Server URL",code:`${s}/mcp`,copyKey:"http-server-url"}),(0,t.jsx)(g,{title:"Headers Configuration",code:JSON.stringify({"x-litellm-api-key":"Bearer YOUR_LITELLM_API_KEY"},null,2),copyKey:"http-headers"}),(0,t.jsx)("div",{className:"mt-4",children:(0,t.jsx)(eb.Button,{type:"link",className:"p-0 h-auto text-blue-600 hover:text-blue-700",href:"https://modelcontextprotocol.io/docs/concepts/transports",icon:(0,t.jsx)(te.ExternalLinkIcon,{size:14}),children:"Learn more about MCP transports"})})]})})]}),{})})]})]})]})})};var tc=e.i(752978),td=e.i(591935),tm=e.i(492030);let tu=({server:e,isLoadingHealth:s,isRechecking:r,onRecheck:l})=>{let[a,n]=(0,b.useState)(!1),i=e.status||"unknown",o=e.last_health_check,c=e.health_check_error;if(s||r)return(0,t.jsxs)("span",{className:"inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"}),"Checking"]});let d=!!l,m=(0,t.jsxs)("div",{className:"max-w-xs",children:[(0,t.jsxs)("div",{className:"font-semibold mb-1",children:["Health Status: ",i]}),o&&(0,t.jsxs)("div",{className:"text-xs mb-1",children:["Last Check: ",new Date(o).toLocaleString()]}),c&&(0,t.jsxs)("div",{className:"text-xs",children:[(0,t.jsx)("div",{className:"font-medium text-red-400 mb-1",children:"Error:"}),(0,t.jsx)("div",{className:"break-words",children:c})]}),!o&&!c&&(0,t.jsx)("div",{className:"text-xs text-gray-400",children:"No health check data available"}),d&&(0,t.jsx)("div",{className:"text-xs text-gray-400 mt-1",children:"Click to recheck"})]});return(0,t.jsx)(g.Tooltip,{title:m,placement:"top",children:(0,t.jsxs)("span",{className:`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${(e=>{switch(e){case"healthy":return"text-green-700 bg-green-50 border border-green-200";case"unhealthy":return"text-red-700 bg-red-50 border border-red-200";default:return"text-gray-600 bg-gray-50 border border-gray-200"}})(i)} ${d?"cursor-pointer hover:opacity-80":"cursor-default"}`,onMouseEnter:()=>n(!0),onMouseLeave:()=>n(!1),onClick:d?()=>l(e.server_id):void 0,children:[(0,t.jsx)("span",{children:a&&d?"↻":(e=>{switch(e){case"healthy":return"✓";case"unhealthy":return"✗";default:return"?"}})(i)}),a&&d?"Recheck":i.charAt(0).toUpperCase()+i.slice(1)]})})};var tx=e.i(530212),th=e.i(848725);let tp=b.forwardRef(function(e,t){return b.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:2,stroke:"currentColor","aria-hidden":"true",ref:t},e),b.createElement("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"}))});var tg=e.i(350967),tf=e.i(954616);function tb(e){if(!e)return[];if(Array.isArray(e))return e.map(e=>tj(e)).filter(e=>void 0!==e);let t=tj(e);return void 0===t?[]:[t]}function tj(e,t){if(!e)return;let s=void 0!==t?t:e.default;if("object"===e.type){let t="object"!=typeof s||null===s||Array.isArray(s)?{}:{...s};return e.properties&&Object.entries(e.properties).forEach(([e,s])=>{t[e]=tj(s,t[e])}),t}if("array"===e.type){if(Array.isArray(s)){let t=e.items;if(!t)return s;if(0===s.length){let e=tb(t);return e.length?e:s}return Array.isArray(t)?s.map((e,s)=>tj(t[s]??t[t.length-1],e)):s.map(e=>tj(t,e))}return void 0!==s?s:tb(e.items)}if(void 0!==s)return s;switch(e.type){case"integer":case"number":return 0;case"boolean":return!1;default:return""}}let ty=e=>{let t=tj(e);if("object"===e.type||"array"===e.type){let s="array"===e.type?[]:{};return JSON.stringify(t??s,null,2)}return t};function tv({tool:e,onSubmit:s,isLoading:r,result:a,error:n,onClose:i}){let[o]=D.Form.useForm(),[c,d]=b.default.useState("formatted"),[m,u]=b.default.useState(null),[x,h]=b.default.useState(null),f=b.default.useMemo(()=>"string"==typeof e.inputSchema?{type:"object",properties:{input:{type:"string",description:"Input for this tool"}},required:["input"]}:e.inputSchema,[e.inputSchema]),j=b.default.useMemo(()=>f.properties&&f.properties.params&&"object"===f.properties.params.type&&f.properties.params.properties?{type:"object",properties:f.properties.params.properties,required:f.properties.params.required||[]}:f,[f]);b.default.useEffect(()=>{if(o.resetFields(),!j.properties)return;let e={};Object.entries(j.properties).forEach(([t,s])=>{e[t]=ty(s)}),o.setFieldsValue(e)},[o,j,e]),b.default.useEffect(()=>{m&&(a||n)&&h(Date.now()-m)},[a,n,m]);let y=async e=>{try{if(navigator.clipboard&&window.isSecureContext)return await navigator.clipboard.writeText(e),!0;{let t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();let s=document.execCommand("copy");if(document.body.removeChild(t),!s)throw Error("execCommand failed");return!0}}catch(e){return console.error("Copy failed:",e),!1}},v=async()=>{await y(JSON.stringify(a,null,2))?C.default.success("Result copied to clipboard"):C.default.fromBackend("Failed to copy result")},N=async()=>{await y(e.name)?C.default.success("Tool name copied to clipboard"):C.default.fromBackend("Failed to copy tool name")};return(0,t.jsxs)("div",{className:"space-y-4 h-full",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between pb-3 border-b border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[e.mcp_info.logo_url&&(0,t.jsx)("img",{src:e.mcp_info.logo_url,alt:`${e.mcp_info.server_name} logo`,className:"w-6 h-6 object-contain"}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2 mb-1",children:[(0,t.jsx)("h2",{className:"text-lg font-semibold text-gray-900",children:"Test Tool:"}),(0,t.jsxs)("div",{className:"group inline-flex items-center space-x-1 bg-slate-50 hover:bg-slate-100 px-3 py-1 rounded-md cursor-pointer transition-colors border border-slate-200",onClick:N,title:"Click to copy tool name",children:[(0,t.jsx)("span",{className:"font-mono text-slate-700 font-medium text-sm",children:e.name}),(0,t.jsx)("svg",{className:"w-3 h-3 text-slate-400 group-hover:text-slate-600 transition-colors",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})})]})]}),(0,t.jsx)("p",{className:"text-xs text-gray-600",children:e.description}),(0,t.jsxs)("p",{className:"text-xs text-gray-500",children:["Provider: ",e.mcp_info.server_name]})]})]}),(0,t.jsx)(l.Button,{onClick:i,variant:"light",size:"sm",className:"text-gray-500 hover:text-gray-700",children:(0,t.jsx)("svg",{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M6 18L18 6M6 6l12 12"})})})]}),(0,t.jsxs)("div",{className:"grid grid-cols-2 gap-4 h-full",children:[(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"border-b border-gray-100 px-4 py-2",children:(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:"Input Parameters"}),(0,t.jsx)(g.Tooltip,{title:"Configure the input parameters for this tool call",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-gray-400 hover:text-gray-600"})})]})}),(0,t.jsx)("div",{className:"p-4",children:(0,t.jsxs)(D.Form,{form:o,onFinish:e=>{u(Date.now()),h(null);let t={};Object.entries(e).forEach(([e,s])=>{let r=j.properties?.[e];if(r&&null!=s&&""!==s)switch(r.type){case"boolean":t[e]="true"===s||!0===s;break;case"number":case"integer":{let l=Number(s);t[e]=Number.isNaN(l)?s:"integer"===r.type?Math.trunc(l):l;break}case"object":case"array":try{let l="string"==typeof s?JSON.parse(s):s,a="object"===r.type&&null!==l&&"object"==typeof l&&!Array.isArray(l),n="array"===r.type&&Array.isArray(l);"object"===r.type&&a||"array"===r.type&&n?t[e]=l:t[e]=s}catch(r){t[e]=s}break;case"string":t[e]=String(s);break;default:t[e]=s}else null!=s&&""!==s&&(t[e]=s)}),s(f.properties&&f.properties.params&&"object"===f.properties.params.type&&f.properties.params.properties?{params:t}:t)},layout:"vertical",className:"space-y-3",children:["string"==typeof e.inputSchema?(0,t.jsx)("div",{className:"space-y-3",children:(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["Input ",(0,t.jsx)("span",{className:"text-red-500",children:"*"})]}),name:"input",rules:[{required:!0,message:"Please enter input for this tool"}],className:"mb-3",children:(0,t.jsx)(ei.TextInput,{placeholder:"Enter input for this tool",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})}):void 0===j.properties?(0,t.jsx)("div",{className:"text-center py-6 bg-gray-50 rounded-lg border border-gray-200",children:(0,t.jsxs)("div",{className:"max-w-sm mx-auto",children:[(0,t.jsx)("h4",{className:"text-sm font-medium text-gray-900 mb-1",children:"No Parameters Required"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"This tool can be called without any input parameters."})]})}):(0,t.jsx)("div",{className:"space-y-3",children:Object.entries(j.properties).map(([s,r])=>{let l=ty(r),a=`${e.name}-${s}`;return(0,t.jsxs)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:[s," ",j.required?.includes(s)&&(0,t.jsx)("span",{className:"text-red-500",children:"*"}),r.description&&(0,t.jsx)(g.Tooltip,{title:r.description,children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-gray-400 hover:text-gray-600"})})]}),name:s,initialValue:l,rules:[{required:j.required?.includes(s),message:`Please enter ${s}`},..."object"===r.type||"array"===r.type?[{validator:(e,t)=>{if((null==t||""===t)&&!j.required?.includes(s))return Promise.resolve();try{let e="string"==typeof t?JSON.parse(t):t,s="object"===r.type&&null!==e&&"object"==typeof e&&!Array.isArray(e),l="array"===r.type&&Array.isArray(e);if("object"===r.type&&s||"array"===r.type&&l)return Promise.resolve();return Promise.reject(Error("object"===r.type?"Please enter a JSON object":"Please enter a JSON array"))}catch(e){return Promise.reject(Error("Invalid JSON"))}}}]:[]],className:"mb-3",children:["string"===r.type&&r.enum&&(0,t.jsxs)("select",{className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors",defaultValue:l??"",children:[!j.required?.includes(s)&&(0,t.jsxs)("option",{value:"",children:["Select ",s]}),r.enum.map(e=>(0,t.jsx)("option",{value:e,children:e},e))]}),"string"===r.type&&!r.enum&&(0,t.jsx)(ei.TextInput,{placeholder:r.description||`Enter ${s}`,defaultValue:l??"",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"}),("number"===r.type||"integer"===r.type)&&(0,t.jsx)("input",{type:"number",step:"integer"===r.type?1:"any",placeholder:r.description||`Enter ${s}`,defaultValue:l??0,className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"}),"boolean"===r.type&&(0,t.jsxs)(p.Select,{placeholder:`Select ${s}`,allowClear:!j.required?.includes(s),className:"w-full",children:[(0,t.jsx)(p.Select.Option,{value:!0,children:"True"}),(0,t.jsx)(p.Select.Option,{value:!1,children:"False"})]}),("object"===r.type||"array"===r.type)&&(0,t.jsxs)("div",{className:"space-y-2",children:[(0,t.jsx)("textarea",{rows:"object"===r.type?6:4,placeholder:r.description||("object"===r.type?`Enter JSON object for ${s}`:`Enter JSON array for ${s}`),defaultValue:l??("object"===r.type?"{}":"[]"),spellCheck:!1,"data-testid":`textarea-${s}`,className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"object"===r.type?"Provide a valid JSON object.":"Provide a valid JSON array."})]})]},a)})}),(0,t.jsx)("div",{className:"pt-3 border-t border-gray-100",children:(0,t.jsx)(l.Button,{onClick:()=>o.submit(),disabled:r,variant:"primary",className:"w-full",loading:r,children:r?"Calling Tool...":a||n?"Call Again":"Call Tool"})})]})})]}),(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"border-b border-gray-100 px-4 py-2",children:(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:"Tool Result"})}),(0,t.jsx)("div",{className:"p-4",children:a||n||r?(0,t.jsxs)("div",{className:"space-y-3",children:[a&&!r&&!n&&(0,t.jsx)("div",{className:"p-2 bg-green-50 border border-green-200 rounded-lg",children:(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2",children:[(0,t.jsx)("svg",{className:"h-4 w-4 text-green-500",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"})}),(0,t.jsx)("h4",{className:"text-xs font-medium text-green-900",children:"Tool executed successfully"}),null!==x&&(0,t.jsxs)("span",{className:"text-xs text-green-600 ml-1",children:["• ",(x/1e3).toFixed(2),"s"]})]}),(0,t.jsxs)("div",{className:"flex items-center space-x-1",children:[(0,t.jsxs)("div",{className:"flex bg-white rounded border border-green-300 p-0.5",children:[(0,t.jsx)("button",{onClick:()=>d("formatted"),className:`px-2 py-1 text-xs font-medium rounded transition-colors ${"formatted"===c?"bg-green-100 text-green-800":"text-green-600 hover:text-green-800"}`,children:"Formatted"}),(0,t.jsx)("button",{onClick:()=>d("json"),className:`px-2 py-1 text-xs font-medium rounded transition-colors ${"json"===c?"bg-green-100 text-green-800":"text-green-600 hover:text-green-800"}`,children:"JSON"})]}),(0,t.jsx)("button",{onClick:v,className:"p-1 hover:bg-green-100 rounded text-green-700",title:"Copy response",children:(0,t.jsxs)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[(0,t.jsx)("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2"}),(0,t.jsx)("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"})]})})]})]})}),(0,t.jsxs)("div",{className:"max-h-96 overflow-y-auto",children:[r&&(0,t.jsxs)("div",{className:"flex flex-col justify-center items-center h-48 text-gray-500",children:[(0,t.jsxs)("div",{className:"relative",children:[(0,t.jsx)("div",{className:"animate-spin rounded-full h-8 w-8 border-2 border-gray-200"}),(0,t.jsx)("div",{className:"animate-spin rounded-full h-8 w-8 border-2 border-blue-600 border-t-transparent absolute top-0"})]}),(0,t.jsx)("p",{className:"text-sm font-medium mt-3",children:"Calling tool..."}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-1",children:"Please wait while we process your request"})]}),n&&(0,t.jsx)("div",{className:"bg-red-50 border border-red-200 rounded-lg p-3",children:(0,t.jsxs)("div",{className:"flex items-start space-x-2",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("svg",{className:"h-4 w-4 text-red-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2 mb-1",children:[(0,t.jsx)("h4",{className:"text-xs font-medium text-red-900",children:"Tool Call Failed"}),null!==x&&(0,t.jsxs)("span",{className:"text-xs text-red-600",children:["• ",(x/1e3).toFixed(2),"s"]})]}),(0,t.jsx)("div",{className:"bg-white border border-red-200 rounded p-2 max-h-48 overflow-y-auto",children:(0,t.jsx)("pre",{className:"text-xs whitespace-pre-wrap text-red-700 font-mono",children:n.message})})]})]})}),a&&!r&&!n&&(0,t.jsx)("div",{className:"space-y-3",children:"formatted"===c?a.map((e,s)=>(0,t.jsxs)("div",{className:"border border-gray-200 rounded-lg overflow-hidden",children:["text"===e.type&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Text Response"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsx)("div",{className:"bg-white rounded border border-gray-200 max-h-64 overflow-y-auto",children:(0,t.jsx)("div",{className:"p-3 space-y-2",children:e.text.split("\n\n").map((e,s)=>{if(""===e.trim())return null;if(e.startsWith("##")){let r=e.replace(/^#+\s/,"");return(0,t.jsx)("div",{className:"border-b border-gray-200 pb-1 mb-2",children:(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:r})},s)}let r=/(https?:\/\/[^\s\)]+)/g;if(r.test(e)){let l=e.split(r);return(0,t.jsx)("div",{className:"bg-blue-50 border border-blue-200 rounded p-2",children:(0,t.jsx)("div",{className:"text-xs text-gray-700 leading-relaxed whitespace-pre-wrap",children:l.map((e,s)=>r.test(e)?(0,t.jsx)("a",{href:e,target:"_blank",rel:"noopener noreferrer",className:"text-blue-600 hover:text-blue-800 underline break-all",children:e},s):e)})},s)}return e.includes("Score:")?(0,t.jsx)("div",{className:"bg-green-50 border-l-4 border-green-400 p-2 rounded-r",children:(0,t.jsx)("p",{className:"text-xs text-green-800 font-medium whitespace-pre-wrap",children:e})},s):(0,t.jsx)("div",{className:"bg-gray-50 rounded p-2 border border-gray-200",children:(0,t.jsx)("div",{className:"text-xs text-gray-700 leading-relaxed whitespace-pre-wrap font-mono",children:e})},s)}).filter(Boolean)})})})]}),"image"===e.type&&e.url&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Image Response"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsx)("div",{className:"bg-gray-50 rounded p-3 border border-gray-200",children:(0,t.jsx)("img",{src:e.url,alt:"Tool result",className:"max-w-full h-auto rounded shadow-sm"})})})]}),"embedded_resource"===e.type&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Embedded Resource"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsxs)("div",{className:"flex items-center space-x-2 p-3 bg-blue-50 border border-blue-200 rounded",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("svg",{className:"h-5 w-5 text-blue-500",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"})})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("p",{className:"text-xs font-medium text-blue-900",children:["Resource Type: ",e.resource_type]}),e.url&&(0,t.jsxs)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center text-xs text-blue-600 hover:text-blue-800 hover:underline mt-1 transition-colors",children:["View Resource",(0,t.jsxs)("svg",{className:"ml-1 h-3 w-3",fill:"currentColor",viewBox:"0 0 20 20",children:[(0,t.jsx)("path",{d:"M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"}),(0,t.jsx)("path",{d:"M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"})]})]})]})]})})]})]},s)):(0,t.jsx)("div",{className:"bg-white rounded border border-gray-200",children:(0,t.jsx)("div",{className:"p-3 overflow-auto max-h-80 bg-gray-50",children:(0,t.jsx)("pre",{className:"text-xs font-mono whitespace-pre-wrap break-all text-gray-800",children:JSON.stringify(a,null,2)})})})})]})]}):(0,t.jsx)("div",{className:"flex flex-col justify-center items-center h-48 text-gray-500",children:(0,t.jsxs)("div",{className:"text-center max-w-sm",children:[(0,t.jsx)("div",{className:"mb-3",children:(0,t.jsx)("svg",{className:"mx-auto h-12 w-12 text-gray-300",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1,d:"M13 10V3L4 14h7v7l9-11h-7z"})})}),(0,t.jsx)("h4",{className:"text-sm font-medium text-gray-900 mb-1",children:"Ready to Call Tool"}),(0,t.jsx)("p",{className:"text-xs text-gray-500 leading-relaxed",children:'Configure the input parameters and click "Call Tool" to see the results here.'})]})})})]})]})]})}var tN=e.i(983561),t_=e.i(438957);let tw=({serverId:e,accessToken:s,auth_type:r,userRole:l,userID:a,serverAlias:n,extraHeaders:i})=>{let[o,c]=(0,b.useState)(null),[u,x]=(0,b.useState)(null),[h,p]=(0,b.useState)(null),[g,f]=(0,b.useState)(""),[j,v]=(0,b.useState)({}),[N,w]=(0,b.useState)(!1),S=i&&i.length>0,C=()=>{if(!n||!S)return;let e={};return Object.entries(j).forEach(([t,s])=>{s&&s.trim()&&(e[`x-mcp-${n}-${t.toLowerCase()}`]=s)}),Object.keys(e).length>0?e:void 0},{data:T,isLoading:k,error:A,refetch:I}=(0,y.useQuery)({queryKey:["mcpTools",e,j],queryFn:()=>{if(!s)throw Error("Access Token required");return(0,_.listMCPTools)(s,e,C())},enabled:!!s,staleTime:3e4}),{mutate:P,isPending:O}=(0,tf.useMutation)({mutationFn:async t=>{if(!s)throw Error("Access Token required");try{return await (0,_.callMCPTool)(s,e,t.tool.name,t.arguments,{customHeaders:C()})}catch(e){throw e}},onSuccess:e=>{x(e.content),p(null)},onError:e=>{p(e),x(null)}}),M=T?.tools||[],F=M.filter(e=>{let t=g.toLowerCase();return e.name.toLowerCase().includes(t)||e.description&&e.description.toLowerCase().includes(t)||e.mcp_info.server_name&&e.mcp_info.server_name.toLowerCase().includes(t)});return(0,t.jsx)("div",{className:"w-full h-screen p-4 bg-white",children:(0,t.jsx)(eg.Card,{className:"w-full rounded-xl shadow-md overflow-hidden",children:(0,t.jsxs)("div",{className:"flex h-auto w-full gap-4",children:[(0,t.jsxs)("div",{className:"w-1/4 p-4 bg-gray-50 flex flex-col",children:[(0,t.jsx)(m.Title,{className:"text-xl font-semibold mb-6 mt-2",children:"MCP Tools"}),(0,t.jsxs)("div",{className:"flex flex-col flex-1",children:[S&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-2",children:[(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsx)(t_.KeyOutlined,{className:"text-blue-600 mr-2"}),(0,t.jsx)(d.Text,{className:"text-sm font-medium text-blue-800",children:"Additional Headers"})]}),(0,t.jsx)(eb.Button,{size:"small",type:"link",onClick:()=>w(!N),className:"text-blue-700 p-0 h-auto",children:N?"Hide":"Configure"})]}),!N&&0===Object.keys(j).length&&(0,t.jsx)(d.Text,{className:"text-xs text-blue-700",children:'This server requires additional headers. Click "Configure" to provide values.'}),N&&(0,t.jsxs)("div",{className:"mt-3 space-y-2",children:[i?.map(e=>(0,t.jsxs)("div",{children:[(0,t.jsx)("label",{className:"block text-xs font-medium text-gray-700 mb-1",children:e}),(0,t.jsx)(H.Input,{size:"small",placeholder:`Enter ${e}`,value:j[e]||"",onChange:t=>{v({...j,[e]:t.target.value})},prefix:(0,t.jsx)(t_.KeyOutlined,{className:"text-gray-400"}),className:"rounded"})]},e)),(0,t.jsx)(eb.Button,{size:"small",type:"primary",onClick:()=>{I(),w(!1)},disabled:Object.values(j).every(e=>!e||!e.trim()),className:"w-full mt-2",children:"Load Tools"})]}),!N&&Object.keys(j).length>0&&(0,t.jsx)("div",{className:"mt-2",children:(0,t.jsxs)(d.Text,{className:"text-xs text-green-700 flex items-center",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 bg-green-500 rounded-full mr-2"}),Object.keys(j).length," header(s) configured"]})})]}),(0,t.jsxs)("div",{className:"flex flex-col flex-1 min-h-0",children:[(0,t.jsxs)(d.Text,{className:"font-medium block mb-3 text-gray-700 flex items-center",children:[(0,t.jsx)(ep.ToolOutlined,{className:"mr-2"})," Available Tools",M.length>0&&(0,t.jsx)("span",{className:"ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full",children:M.length})]}),M.length>0&&(0,t.jsx)("div",{className:"mb-3",children:(0,t.jsx)(H.Input,{placeholder:"Search tools...",prefix:(0,t.jsx)(ew.SearchOutlined,{className:"text-gray-400"}),value:g,onChange:e=>f(e.target.value),allowClear:!0,className:"rounded-lg",size:"middle"})}),k&&(0,t.jsxs)("div",{className:"flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg",children:[(0,t.jsxs)("div",{className:"relative mb-3",children:[(0,t.jsx)("div",{className:"animate-spin rounded-full h-6 w-6 border-2 border-gray-200"}),(0,t.jsx)("div",{className:"animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"})]}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700",children:"Loading tools..."})]}),T?.error&&!k&&!M.length&&(0,t.jsx)("div",{className:"p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200",children:(0,t.jsxs)("p",{className:"font-medium",children:["Error: ",T.message]})}),!k&&!T?.error&&(!M||0===M.length)&&(0,t.jsxs)("div",{className:"p-4 text-center bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-gray-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 8.172V5L8 4z"})})}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700 mb-1",children:"No tools available"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"No tools found for this server"})]}),!k&&!T?.error&&M.length>0&&(0,t.jsx)(t.Fragment,{children:0===F.length?(0,t.jsxs)("div",{className:"p-4 text-center bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)(ew.SearchOutlined,{className:"text-2xl text-gray-400 mb-2"}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700 mb-1",children:"No tools found"}),(0,t.jsxs)("p",{className:"text-xs text-gray-500",children:['No tools match "',g,'"']})]}):(0,t.jsx)("div",{className:"space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable",style:{maxHeight:"400px",scrollbarWidth:"auto",scrollbarColor:"#cbd5e0 #f7fafc"},children:F.map(e=>(0,t.jsxs)("div",{className:`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${o?.name===e.name?"border-blue-500 bg-blue-50 ring-1 ring-blue-200":"border-gray-200 bg-white hover:border-gray-300"}`,onClick:()=>{c(e),x(null),p(null)},children:[(0,t.jsxs)("div",{className:"flex items-start space-x-2",children:[e.mcp_info.logo_url&&(0,t.jsx)("img",{src:e.mcp_info.logo_url,alt:`${e.mcp_info.server_name} logo`,className:"w-4 h-4 object-contain flex-shrink-0 mt-0.5"}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("h4",{className:"font-mono text-xs font-medium text-gray-900 truncate",children:e.name}),(0,t.jsx)("p",{className:"text-xs text-gray-500 truncate",children:e.mcp_info.server_name}),(0,t.jsx)("p",{className:"text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed",children:e.description})]})]}),o?.name===e.name&&(0,t.jsx)("div",{className:"mt-2 pt-2 border-t border-blue-200",children:(0,t.jsxs)("div",{className:"flex items-center text-xs font-medium text-blue-700",children:[(0,t.jsx)("svg",{className:"w-3 h-3 mr-1",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",clipRule:"evenodd"})}),"Selected"]})})]},e.name))})})]})]})]}),(0,t.jsxs)("div",{className:"w-3/4 flex flex-col bg-white",children:[(0,t.jsx)("div",{className:"p-4 border-b border-gray-200 flex justify-between items-center",children:(0,t.jsx)(m.Title,{className:"text-xl font-semibold mb-0",children:"Tool Testing Playground"})}),(0,t.jsx)("div",{className:"flex-1 overflow-auto p-4",children:o?(0,t.jsx)("div",{className:"h-full",children:(0,t.jsx)(tv,{tool:o,onSubmit:e=>{P({tool:o,arguments:e})},result:u,error:h,isLoading:O,onClose:()=>c(null)})}):(0,t.jsxs)("div",{className:"h-full flex flex-col items-center justify-center text-gray-400",children:[(0,t.jsx)(tN.RobotOutlined,{style:{fontSize:"48px",marginBottom:"16px"}}),(0,t.jsx)(d.Text,{className:"text-lg font-medium text-gray-600 mb-2",children:"Select a Tool to Test"}),(0,t.jsx)(d.Text,{className:"text-center text-gray-500 max-w-md",children:"Choose a tool from the left sidebar to start testing its functionality with custom inputs."})]})})]})]})})})},tS=[eo.AUTH_TYPE.API_KEY,eo.AUTH_TYPE.BEARER_TOKEN,eo.AUTH_TYPE.TOKEN,eo.AUTH_TYPE.BASIC],tC=[...tS,eo.AUTH_TYPE.OAUTH2,eo.AUTH_TYPE.AWS_SIGV4],tT="litellm-mcp-oauth-edit-state",tk=({mcpServer:e,accessToken:s,onCancel:r,onSuccess:d,availableAccessGroups:m})=>{let[u]=D.Form.useForm(),[x,h]=(0,b.useState)({}),[f,j]=(0,b.useState)([]),[y,v]=(0,b.useState)(!1),[N,w]=(0,b.useState)(null),[S,T]=(0,b.useState)(""),[k,A]=(0,b.useState)(!1),[I,P]=(0,b.useState)([]),[O,M]=(0,b.useState)({}),[F,E]=(0,b.useState)({}),[L,R]=(0,b.useState)(null),[U,z]=(0,b.useState)(e.mcp_info?.logo_url||void 0),B=D.Form.useWatch("auth_type",u),q=D.Form.useWatch("transport",u),V="stdio"===q,$=q===eo.TRANSPORT.OPENAPI,K=!!B&&tS.includes(B),W=B===eo.AUTH_TYPE.OAUTH2,J=B===eo.AUTH_TYPE.AWS_SIGV4,Y=D.Form.useWatch("oauth_flow_type",u),G=W&&Y===eo.OAUTH_FLOW.M2M,[Q,Z]=(0,b.useState)(null),X=D.Form.useWatch("url",u),ee=D.Form.useWatch("spec_path",u),et=D.Form.useWatch("server_name",u),es=D.Form.useWatch("auth_type",u),er=D.Form.useWatch("static_headers",u),el=D.Form.useWatch("credentials",u),ea=D.Form.useWatch("authorization_url",u),ei=D.Form.useWatch("token_url",u),ed=D.Form.useWatch("registration_url",u),{startOAuthFlow:em,status:eu,error:ex,tokenResponse:eh}=eQ({accessToken:s,getCredentials:()=>u.getFieldValue("credentials"),getTemporaryPayload:()=>{let t=u.getFieldsValue(!0),s=t.url||e.url,r=t.transport||e.transport;if(!s||!r)return null;let l=Array.isArray(t.static_headers)?t.static_headers.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{};return{server_id:e.server_id,server_name:t.server_name||e.server_name||e.alias,alias:t.alias||e.alias,description:t.description||e.description,url:s,transport:r,auth_type:eo.AUTH_TYPE.OAUTH2,credentials:t.credentials,mcp_access_groups:t.mcp_access_groups||e.mcp_access_groups,static_headers:l,command:t.command,args:t.args,env:t.env}},onTokenReceived:e=>{if(Z(e?.access_token??null),e?.access_token){let t={access_token:e.access_token,...e.refresh_token&&{refresh_token:e.refresh_token},...e.expires_in&&{expires_in:e.expires_in},...e.scope&&{scope:e.scope}};u.setFieldsValue({credentials:t}),C.default.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.")}},onBeforeRedirect:()=>{try{let t=u.getFieldsValue(!0);(0,eG.setSecureItem)(tT,JSON.stringify({serverId:e.server_id,formValues:t,costConfig:x,allowedTools:I,searchValue:S,aliasManuallyEdited:k}))}catch(e){console.warn("Failed to persist MCP edit state",e)}}}),ep=b.default.useMemo(()=>e.static_headers?Object.entries(e.static_headers).map(([e,t])=>({header:e,value:null!=t?String(t):""})):[],[e.static_headers]),eg=b.default.useMemo(()=>{let t=e.env??void 0;if(!t||0===Object.keys(t).length)return"";try{return JSON.stringify(t,null,2)}catch{return""}},[e.env]),ej=b.default.useMemo(()=>e.spec_path&&"stdio"!==e.transport?eo.TRANSPORT.OPENAPI:e.transport,[e]),ey=b.default.useMemo(()=>({...e,transport:ej,static_headers:ep,extra_headers:e.extra_headers||[],oauth_flow_type:e.token_url?eo.OAUTH_FLOW.M2M:eo.OAUTH_FLOW.INTERACTIVE,token_validation_json:e.token_validation?JSON.stringify(e.token_validation,null,2):void 0}),[e,ej,ep,eg]);(0,b.useEffect)(()=>{e.mcp_info?.mcp_server_cost_info&&h(e.mcp_info.mcp_server_cost_info)},[e]),(0,b.useEffect)(()=>{e.allowed_tools&&P(e.allowed_tools),M(e.tool_name_to_display_name??{}),E(e.tool_name_to_description??{})},[e]),(0,b.useEffect)(()=>{let t=(0,eG.getSecureItem)(tT);if(t)try{let s=JSON.parse(t);if(!s||s.serverId!==e.server_id)return;s.formValues&&R({...e,...s.formValues}),s.costConfig&&h(s.costConfig),s.allowedTools&&P(s.allowedTools),s.searchValue&&T(s.searchValue),"boolean"==typeof s.aliasManuallyEdited&&A(s.aliasManuallyEdited)}catch(e){console.error("Failed to restore MCP edit state",e)}finally{window.sessionStorage.removeItem(tT)}},[u,e]),(0,b.useEffect)(()=>{if(!L)return;let t=L.transport||e.transport;t&&t!==u.getFieldValue("transport")?u.setFieldsValue({transport:t}):(u.setFieldsValue(L),R(null))},[L,u,e.transport]),(0,b.useEffect)(()=>{if(e.mcp_access_groups){let t=e.mcp_access_groups.map(e=>"string"==typeof e?e:e.name||String(e));u.setFieldValue("mcp_access_groups",t)}},[e]),(0,b.useEffect)(()=>{e.server_id&&""!==e.server_id.trim()&&ev()},[e,s]);let ev=async()=>{if(s&&e.server_id){v(!0),w(null);try{let t=await (0,_.listMCPTools)(s,e.server_id);t.tools&&!t.error?j(t.tools):(console.error("Failed to fetch tools:",t.message),j([]),w(t.message||"Failed to load tools"))}catch(e){console.error("Tools fetch error:",e),j([]),w(e instanceof Error?e.message:"Failed to load tools")}finally{v(!1)}}},eN=async t=>{if(s)try{let{static_headers:r,credentials:l,stdio_config:a,env_json:n,command:i,args:o,allow_all_keys:c,available_on_public_internet:m,delegate_auth_to_upstream:u,token_validation_json:h,...p}=t,g=(p.mcp_access_groups||[]).map(e=>"string"==typeof e?e:e.name||String(e)),f=Array.isArray(r)?r.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{},b=l&&"object"==typeof l?Object.entries(l).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,j={};if("stdio"===p.transport)if(a)try{let e=JSON.parse(a),t=e;if(e?.mcpServers&&"object"==typeof e.mcpServers){let s=Object.keys(e.mcpServers);s.length>0&&(t=e.mcpServers[s[0]])}let s=Array.isArray(t?.args)?t.args.map(e=>String(e)).filter(e=>""!==e.trim()):[],r=t?.env&&"object"==typeof t.env&&!Array.isArray(t.env)?Object.entries(t.env).reduce((e,[t,s])=>(null==t||""===String(t).trim()||(e[String(t)]=null==s?"":String(s)),e),{}):{};if(!(j={command:t?.command?String(t.command):void 0,args:s,env:r}).command)return void C.default.fromBackend("Stdio configuration must include a command")}catch{C.default.fromBackend("Invalid JSON in stdio configuration");return}else{let e={};if(n)try{let t=JSON.parse(n);t&&"object"==typeof t&&!Array.isArray(t)&&(e=Object.entries(t).reduce((e,[t,s])=>(null==t||""===String(t).trim()||(e[String(t)]=null==s?"":String(s)),e),{}))}catch{C.default.fromBackend("Invalid JSON in stdio env configuration");return}let t=Array.isArray(o)?o.map(e=>String(e)).filter(e=>""!==e.trim()):[],s=i?String(i).trim():"";if(!s)return void C.default.fromBackend("Stdio transport requires a command");j={command:s,args:t,env:e}}p.transport===eo.TRANSPORT.OPENAPI&&(p.transport="http");let y=null;if(h&&""!==h.trim())try{y=JSON.parse(h)}catch{C.default.fromBackend("Invalid JSON in Token Validation Rules");return}let v=p.server_name||p.url||e.server_name||e.url||p.alias||e.alias||"unknown",N={...p,...j,stdio_config:void 0,env_json:void 0,server_id:e.server_id,mcp_info:{server_name:v,description:p.description,logo_url:U||void 0,mcp_server_cost_info:Object.keys(x).length>0?x:null},mcp_access_groups:g,alias:p.alias,extra_headers:p.extra_headers||[],allowed_tools:I.length>0?I:null,tool_name_to_display_name:Object.keys(O).length>0?O:null,tool_name_to_description:Object.keys(F).length>0?F:null,disallowed_tools:p.disallowed_tools||[],static_headers:f,allow_all_keys:!!(c??e.allow_all_keys),available_on_public_internet:!!(m??e.available_on_public_internet),delegate_auth_to_upstream:p.auth_type===eo.AUTH_TYPE.OAUTH2&&!!(u??e.delegate_auth_to_upstream),...null!==y||e.token_validation?{token_validation:y}:{}};p.auth_type&&tC.includes(p.auth_type)&&b&&Object.keys(b).length>0&&(N.credentials=b);let w=await (0,_.updateMCPServer)(s,N);C.default.success("MCP Server updated successfully"),d(w)}catch(e){C.default.fromBackend("Failed to update MCP Server"+(e?.message?`: ${e.message}`:""))}};return(0,t.jsxs)(n.TabGroup,{children:[(0,t.jsxs)(i.TabList,{className:"grid w-full grid-cols-2",children:[(0,t.jsx)(a.Tab,{children:"Server Configuration"}),(0,t.jsx)(a.Tab,{children:"Cost Configuration"})]}),(0,t.jsxs)(c.TabPanels,{className:"mt-6",children:[(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)(D.Form,{form:u,onFinish:eN,initialValues:ey,layout:"vertical",children:[(0,t.jsx)(D.Form.Item,{label:"MCP Server Name",name:"server_name",rules:[{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(H.Input,{className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Alias",name:"alias",rules:[{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(H.Input,{onChange:()=>A(!0),className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Description",name:"description",children:(0,t.jsx)(H.Input,{className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(e$,{value:U,onChange:z}),(0,t.jsx)(D.Form.Item,{label:"Transport Type",name:"transport",rules:[{required:!0}],children:(0,t.jsxs)(p.Select,{onChange:e=>{"stdio"===e?u.setFieldsValue({url:void 0,spec_path:void 0,auth_type:void 0,credentials:void 0,authorization_url:void 0,token_url:void 0,registration_url:void 0}):e===eo.TRANSPORT.OPENAPI?u.setFieldsValue({url:void 0,command:void 0,args:void 0,env_json:void 0,stdio_config:void 0}):u.setFieldsValue({spec_path:void 0,command:void 0,args:void 0,env_json:void 0,stdio_config:void 0})},children:[(0,t.jsx)(p.Select.Option,{value:"http",children:"Streamable HTTP (Recommended)"}),(0,t.jsx)(p.Select.Option,{value:"sse",children:"Server-Sent Events (SSE)"}),(0,t.jsx)(p.Select.Option,{value:"stdio",children:"Standard Input/Output (stdio)"}),(0,t.jsx)(p.Select.Option,{value:eo.TRANSPORT.OPENAPI,children:"OpenAPI Spec"})]})}),!V&&!$&&(0,t.jsx)(D.Form.Item,{label:"MCP Server URL",name:"url",rules:[{required:!0,message:"Please enter a server URL"},{validator:(e,t)=>eK(t)}],children:(0,t.jsx)(H.Input,{placeholder:"https://your-mcp-server.com",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),$&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OpenAPI Spec URL",(0,t.jsx)(g.Tooltip,{title:"URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"spec_path",rules:[{required:!0,message:"Please enter an OpenAPI spec URL"}],children:(0,t.jsx)(H.Input,{placeholder:"https://petstore3.swagger.io/api/v3/openapi.json",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!V&&(0,t.jsx)(D.Form.Item,{label:"Authentication",name:"auth_type",rules:[{required:!0}],children:(0,t.jsxs)(p.Select,{children:[(0,t.jsx)(p.Select.Option,{value:"none",children:"None"}),(0,t.jsx)(p.Select.Option,{value:"api_key",children:"API Key"}),(0,t.jsx)(p.Select.Option,{value:"bearer_token",children:"Bearer Token"}),(0,t.jsx)(p.Select.Option,{value:"token",children:"Token"}),(0,t.jsx)(p.Select.Option,{value:"basic",children:"Basic Auth"}),(0,t.jsx)(p.Select.Option,{value:"oauth2",children:"OAuth"}),(0,t.jsx)(p.Select.Option,{value:"aws_sigv4",children:"AWS SigV4 (Bedrock AgentCore MCPs)"})]})}),V&&(0,t.jsxs)("div",{className:"rounded-lg border border-gray-200 p-4 space-y-4",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Configure the stdio transport used to launch the MCP server process. You can either fill in the fields below or paste a JSON configuration."}),(0,t.jsx)(D.Form.Item,{label:"Command",name:"command",rules:[{required:!0,message:"Please enter a command for stdio transport"}],children:(0,t.jsx)(H.Input,{placeholder:"e.g., npx",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Args",name:"args",children:(0,t.jsx)(p.Select,{mode:"tags",size:"large",tokenSeparators:[","],placeholder:"Add args (press enter or comma)",className:"rounded-lg"})}),(0,t.jsx)(D.Form.Item,{label:"Environment (JSON object)",name:"env_json",rules:[{validator:(e,t)=>{if(!t)return Promise.resolve();try{let e=JSON.parse(t);if(e&&"object"==typeof e&&!Array.isArray(e))return Promise.resolve();return Promise.reject(Error("Env must be a JSON object"))}catch{return Promise.reject(Error("Please enter valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{rows:6,className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm",placeholder:`{ + "KEY": "value" +}`})}),(0,t.jsx)(eO,{isVisible:!0,required:!1})]}),!V&&K&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authentication Value",(0,t.jsx)(g.Tooltip,{title:"Token, password, or header value to send with each request for the selected auth type.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","auth_value"],rules:[{validator:(e,t)=>t&&"string"==typeof t&&""===t.trim()?Promise.reject(Error("Authentication value cannot be empty")):Promise.resolve()}],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter token or secret (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!V&&W&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Client ID (optional)",(0,t.jsx)(g.Tooltip,{title:"Provide only if your MCP server cannot handle dynamic client registration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","client_id"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter OAuth client ID (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Client Secret (optional)",(0,t.jsx)(g.Tooltip,{title:"Provide only if your MCP server cannot handle dynamic client registration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","client_secret"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter OAuth client secret (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Scopes (optional)",(0,t.jsx)(g.Tooltip,{title:"Add scopes to override the default scope list used for this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authorization URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the authorization endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"authorization_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/authorize",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the token endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/token",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Registration URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the dynamic client registration endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"registration_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/register",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!G&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token Validation Rules (optional)",(0,t.jsx)(g.Tooltip,{title:'JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.',children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_validation_json",rules:[{validator:(e,t)=>{if(!t||""===t.trim())return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject(Error("Must be valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:'{\n "organization": "my-org",\n "team.id": "123"\n}',rows:4,className:"font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token Storage TTL (seconds, optional)",(0,t.jsx)(g.Tooltip,{title:"How long to cache each user's OAuth access token in Redis before evicting it (regardless of the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_storage_ttl_seconds",children:(0,t.jsx)(ec.InputNumber,{min:1,placeholder:"e.g. 3600",style:{width:"100%"},className:"rounded-lg"})})]}),(0,t.jsxs)("div",{className:"rounded-lg border border-dashed border-gray-300 p-4 space-y-2",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value."}),(0,t.jsx)(l.Button,{variant:"secondary",onClick:em,disabled:"authorizing"===eu||"exchanging"===eu,children:"authorizing"===eu?"Waiting for authorization...":"exchanging"===eu?"Exchanging authorization code...":"Authorize & Fetch Token"}),ex&&(0,t.jsx)("p",{className:"text-sm text-red-500",children:ex}),"success"===eu&&eh?.access_token&&(0,t.jsxs)("p",{className:"text-sm text-green-600",children:["Token fetched. Expires in ",eh.expires_in??"?"," seconds."]})]})]}),!V&&J&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-2",children:["For MCP servers hosted on AWS Bedrock AgentCore."," ",(0,t.jsx)("a",{href:"https://docs.litellm.ai/docs/mcp_aws_sigv4",target:"_blank",rel:"noopener noreferrer",className:"text-blue-500 hover:text-blue-700",children:"View docs →"})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Region",(0,t.jsx)(g.Tooltip,{title:"AWS region for SigV4 signing (e.g., us-east-1)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_region_name"],rules:[],children:(0,t.jsx)(H.Input,{placeholder:"us-east-1 (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Service Name",(0,t.jsx)(g.Tooltip,{title:"AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_service_name"],children:(0,t.jsx)(H.Input,{placeholder:"bedrock-agentcore (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Access Key ID",(0,t.jsx)(g.Tooltip,{title:"Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_access_key_id"],rules:[],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Secret Access Key",(0,t.jsx)(g.Tooltip,{title:"Optional. Required if AWS Access Key ID is provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_secret_access_key"],rules:[],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Token",(0,t.jsx)(g.Tooltip,{title:"Optional. Only needed for temporary STS credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_token"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Role ARN",(0,t.jsx)(g.Tooltip,{title:"Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_role_name"],children:(0,t.jsx)(H.Input,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Name",(0,t.jsx)(g.Tooltip,{title:"Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_name"],children:(0,t.jsx)(H.Input,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})]}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eR,{availableAccessGroups:m,mcpServer:e,searchValue:S,setSearchValue:T,getAccessGroupOptions:()=>{let e=m.map(e=>({value:e,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-green-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:e})]})}));return S&&!m.some(e=>e.toLowerCase().includes(S.toLowerCase()))&&e.push({value:S,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:S}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-1",children:"create new group"})]})}),e}})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eP,{accessToken:s,oauthAccessToken:Q,formValues:{server_id:e.server_id,server_name:et??e.server_name,url:X??e.url,spec_path:ee??e.spec_path,transport:q??e.transport,auth_type:es??e.auth_type,mcp_info:e.mcp_info,oauth_flow_type:ei??e.token_url?eo.OAUTH_FLOW.M2M:eo.OAUTH_FLOW.INTERACTIVE,static_headers:er??e.static_headers,credentials:el,authorization_url:ea??e.authorization_url,token_url:ei??e.token_url,registration_url:ed??e.registration_url},allowedTools:I,existingAllowedTools:e.allowed_tools||null,onAllowedToolsChange:P,toolNameToDisplayName:O,toolNameToDescription:F,onToolNameToDisplayNameChange:M,onToolNameToDescriptionChange:E})}),(0,t.jsxs)("div",{className:"flex justify-end gap-2",children:[(0,t.jsx)(eb.Button,{onClick:r,children:"Cancel"}),(0,t.jsx)(l.Button,{type:"submit",children:"Save Changes"})]})]})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)("div",{className:"space-y-6",children:[(0,t.jsx)(ef,{value:x,onChange:h,tools:f,disabled:y}),(0,t.jsxs)("div",{className:"flex justify-end gap-2",children:[(0,t.jsx)(eb.Button,{onClick:r,children:"Cancel"}),(0,t.jsx)(l.Button,{onClick:()=>u.submit(),children:"Save Changes"})]})]})})]})]})},tA=({costConfig:e})=>{let s=e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null,r=e?.tool_name_to_cost_per_query&&Object.keys(e.tool_name_to_cost_per_query).length>0;return s||r?(0,t.jsx)("div",{className:"mt-6 pt-6 border-t border-gray-200",children:(0,t.jsxs)("div",{className:"space-y-4",children:[s&&e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null&&(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"font-medium",children:"Default Cost per Query"}),(0,t.jsxs)("div",{className:"text-green-600 font-mono",children:["$",e.default_cost_per_query.toFixed(4)]})]}),r&&e?.tool_name_to_cost_per_query&&(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"font-medium",children:"Tool-Specific Costs"}),(0,t.jsx)("div",{className:"mt-2 space-y-2",children:Object.entries(e.tool_name_to_cost_per_query).map(([e,s])=>null!=s&&(0,t.jsxs)("div",{className:"flex justify-between items-center p-3 bg-gray-50 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"font-medium",children:e}),(0,t.jsxs)(d.Text,{className:"text-green-600 font-mono",children:["$",s.toFixed(4)," per query"]})]},e))})]}),(0,t.jsxs)("div",{className:"mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"text-blue-800 font-medium",children:"Cost Summary:"}),(0,t.jsxs)("div",{className:"mt-2 space-y-1",children:[s&&e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• Default cost: $",e.default_cost_per_query.toFixed(4)," per query"]}),r&&e?.tool_name_to_cost_per_query&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• ",Object.keys(e.tool_name_to_cost_per_query).length," tool(s) with custom pricing"]})]})]})]})}):(0,t.jsx)("div",{className:"mt-6 pt-6 border-t border-gray-200",children:(0,t.jsx)("div",{className:"space-y-4",children:(0,t.jsx)("div",{className:"p-4 bg-gray-50 border border-gray-200 rounded-lg",children:(0,t.jsx)(d.Text,{className:"text-gray-600",children:"No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."})})})})},tI=({mcpServer:e,onBack:s,isEditing:r,isProxyAdmin:u,accessToken:x,userRole:h,userID:p,availableAccessGroups:g})=>{let[f,j]=(0,b.useState)(r),[y,v]=(0,b.useState)(!1),[N,_]=(0,b.useState)({}),[w,S]=(0,b.useState)(0),C=e.url??"",{maskedUrl:T,hasToken:A}=C?eH(C):{maskedUrl:"—",hasToken:!1},I=(e,t)=>e?A?t?e:T:e:"—",P=async(e,t)=>{await (0,tr.copyToClipboard)(e)&&(_(e=>({...e,[t]:!0})),setTimeout(()=>{_(e=>({...e,[t]:!1}))},2e3))},O=e=>{let s=e.toUpperCase();return(0,t.jsx)("span",{className:"inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:s})},M=e=>(0,t.jsx)("span",{className:"inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:e});return(0,t.jsxs)("div",{className:"p-4 max-w-full",children:[(0,t.jsxs)("div",{className:"mb-6",children:[(0,t.jsx)(l.Button,{icon:tx.ArrowLeftIcon,variant:"light",className:"mb-4",onClick:s,children:"Back to All Servers"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(m.Title,{className:"text-2xl",children:e.server_name||e.alias||"Unnamed Server"}),(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:N["mcp-server_name"]?(0,t.jsx)(k.CheckIcon,{size:12}):(0,t.jsx)(e6.CopyIcon,{size:12}),onClick:()=>P(e.server_name||e.alias,"mcp-server_name"),className:`transition-all duration-200 ${N["mcp-server_name"]?"text-green-600 bg-green-50 border-green-200":"text-gray-400 hover:text-gray-600 hover:bg-gray-100"}`}),e.alias&&e.server_name&&e.alias!==e.server_name&&(0,t.jsx)("span",{className:"ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600 border border-gray-200 font-mono",children:e.alias})]}),(0,t.jsxs)("div",{className:"flex items-center gap-1.5 mt-1",children:[(0,t.jsx)(d.Text,{className:"text-gray-400 font-mono text-xs",children:e.server_id}),(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:N["mcp-server-id"]?(0,t.jsx)(k.CheckIcon,{size:10}):(0,t.jsx)(e6.CopyIcon,{size:10}),onClick:()=>P(e.server_id,"mcp-server-id"),className:`transition-all duration-200 ${N["mcp-server-id"]?"text-green-600 bg-green-50 border-green-200":"text-gray-300 hover:text-gray-500 hover:bg-gray-50"}`})]}),e.description&&(0,t.jsx)(d.Text,{className:"text-gray-500 mt-2",children:e.description})]}),(0,t.jsxs)(n.TabGroup,{index:w,onIndexChange:S,children:[(0,t.jsx)(i.TabList,{className:"mb-4",children:[(0,t.jsx)(a.Tab,{children:"Overview"},"overview"),(0,t.jsx)(a.Tab,{children:"MCP Tools"},"tools"),...u?[(0,t.jsx)(a.Tab,{children:"Settings"},"settings")]:[]]}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsxs)(o.TabPanel,{children:[(0,t.jsxs)(tg.Grid,{numItems:1,numItemsSm:2,numItemsLg:3,className:"gap-4",children:[(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Transport"}),(0,t.jsx)("div",{className:"mt-3",children:O((0,eo.handleTransport)(e.transport??void 0,e.spec_path??void 0))})]}),(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Authentication"}),(0,t.jsx)("div",{className:"mt-3",children:M((0,eo.handleAuth)(e.auth_type??void 0))})]}),(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Host URL"}),(0,t.jsxs)("div",{className:"mt-3 flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"break-all overflow-wrap-anywhere font-mono text-sm",children:I(e.url,y)}),A&&u&&(0,t.jsx)("button",{onClick:()=>v(!y),className:"p-1 hover:bg-gray-100 rounded flex-shrink-0",children:(0,t.jsx)(tc.Icon,{icon:y?tp:th.EyeIcon,size:"sm",className:"text-gray-500"})})]})]})]}),(0,t.jsxs)(eg.Card,{className:"mt-4 p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Cost Configuration"}),(0,t.jsx)("div",{className:"mt-3",children:(0,t.jsx)(tA,{costConfig:e.mcp_info?.mcp_server_cost_info})})]})]}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tw,{serverId:e.server_id,accessToken:x,auth_type:e.auth_type,userRole:h,userID:p,serverAlias:e.alias,extraHeaders:e.extra_headers})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)(eg.Card,{children:[(0,t.jsxs)("div",{className:"flex justify-between items-center mb-4",children:[(0,t.jsx)(m.Title,{children:"MCP Server Settings"}),f?null:(0,t.jsx)(l.Button,{variant:"light",onClick:()=>j(!0),children:"Edit Settings"})]}),f?(0,t.jsx)(tk,{mcpServer:e,accessToken:x,onCancel:()=>j(!1),onSuccess:e=>{j(!1),s()},availableAccessGroups:g}):(0,t.jsxs)("div",{className:"divide-y divide-gray-100",children:[(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Server Name"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.server_name||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Alias"}),(0,t.jsx)("div",{className:"col-span-2 text-sm font-mono text-gray-900",children:e.alias||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Description"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.description||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"URL"}),(0,t.jsxs)("div",{className:"col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2",children:[I(e.url,y),A&&(0,t.jsx)("button",{onClick:()=>v(!y),className:"p-1 hover:bg-gray-100 rounded flex-shrink-0",children:(0,t.jsx)(tc.Icon,{icon:y?tp:th.EyeIcon,size:"sm",className:"text-gray-500"})})]})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Transport"}),(0,t.jsx)("div",{className:"col-span-2",children:O((0,eo.handleTransport)(e.transport,e.spec_path))})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Authentication"}),(0,t.jsx)("div",{className:"col-span-2",children:M((0,eo.handleAuth)(e.auth_type))})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Extra Headers"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.extra_headers&&e.extra_headers.length>0?e.extra_headers.join(", "):(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Allow All Keys"}),(0,t.jsx)("div",{className:"col-span-2",children:e.allow_all_keys?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Enabled"]}):(0,t.jsx)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium",children:"Disabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Network Access"}),(0,t.jsx)("div",{className:"col-span-2",children:e.available_on_public_internet?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Public"]}):(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-orange-500"}),"Internal only"]})})]}),"oauth2"===(0,eo.handleAuth)(e.auth_type)&&(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Delegate Auth to Upstream"}),(0,t.jsx)("div",{className:"col-span-2",children:e.delegate_auth_to_upstream?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Enabled (PKCE passthrough)"]}):(0,t.jsx)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium",children:"Disabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Access Groups"}),(0,t.jsx)("div",{className:"col-span-2",children:e.mcp_access_groups&&e.mcp_access_groups.length>0?(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5",children:e.mcp_access_groups.map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200",children:"string"==typeof e?e:e?.name??""},s))}):(0,t.jsx)("span",{className:"text-sm text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Allowed Tools"}),(0,t.jsx)("div",{className:"col-span-2",children:e.allowed_tools&&e.allowed_tools.length>0?(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5",children:e.allowed_tools.map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-mono font-medium px-2 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200",children:e},s))}):(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-green-50 text-green-700 border border-green-200",children:"All tools enabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Cost"}),(0,t.jsx)("div",{className:"col-span-2",children:(0,t.jsx)(tA,{costConfig:e.mcp_info?.mcp_server_cost_info})})]})]})]})})]})]})]})},tP=(0,N.createQueryKeys)("mcpSemanticFilterSettings"),tO=(0,N.createQueryKeys)("mcpSemanticFilterSettings");var tM=e.i(178654),tF=e.i(621192),tE=e.i(981339),tL=e.i(850627),tR=e.i(987432),tU=e.i(689020),tz=e.i(245094),tB=e.i(788191),tq=e.i(653496),tV=e.i(992619);function t$({accessToken:e,testQuery:s,setTestQuery:r,testModel:l,setTestModel:a,isTesting:n,onTest:i,filterEnabled:o,testResult:c,curlCommand:d}){return(0,t.jsx)(e4.Card,{title:"Test Configuration",style:{marginBottom:16},children:(0,t.jsx)(tq.Tabs,{defaultActiveKey:"test",items:[{key:"test",label:"Test",children:(0,t.jsxs)(eM.Space,{direction:"vertical",style:{width:"100%"},size:"large",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:[(0,t.jsx)(tB.PlayCircleOutlined,{})," Test Query"]}),(0,t.jsx)(H.Input.TextArea,{placeholder:"Enter a test query to see which tools would be selected...",value:s,onChange:e=>r(e.target.value),rows:4,disabled:n})]}),(0,t.jsx)("div",{children:(0,t.jsx)(tV.default,{accessToken:e||"",value:l,onChange:a,disabled:n,showLabel:!0,labelText:"Select Model"})}),(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tB.PlayCircleOutlined,{}),onClick:i,loading:n,disabled:!s||!l||!o,block:!0,children:"Test Filter"}),!o&&(0,t.jsx)(ej.Alert,{type:"warning",message:"Semantic filtering is disabled",description:"Enable semantic filtering and save settings to test the filter.",showIcon:!0}),c&&(0,t.jsxs)("div",{children:[(0,t.jsx)(f.Typography.Title,{level:5,children:"Results"}),(0,t.jsx)(ej.Alert,{type:"success",message:`${c.selectedTools} tools selected`,description:`Filtered from ${c.totalTools} available tools`,showIcon:!0,style:{marginBottom:16}}),(0,t.jsxs)("div",{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:"Selected Tools:"}),(0,t.jsx)("ul",{style:{paddingLeft:20,margin:0},children:c.tools.map((e,s)=>(0,t.jsx)("li",{style:{marginBottom:4},children:(0,t.jsx)(f.Typography.Text,{children:e})},s))})]})]})]})},{key:"api",label:"API Usage",children:(0,t.jsxs)("div",{children:[(0,t.jsxs)(eM.Space,{style:{marginBottom:8},children:[(0,t.jsx)(tz.CodeOutlined,{}),(0,t.jsx)(f.Typography.Text,{strong:!0,children:"API Usage"})]}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block",marginBottom:8},children:"Use this curl command to test the semantic filter with your current configuration."}),(0,t.jsx)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:"Response headers to check:"}),(0,t.jsxs)("ul",{style:{paddingLeft:20,margin:"0 0 12px 0"},children:[(0,t.jsxs)("li",{children:[(0,t.jsx)(f.Typography.Text,{children:"x-litellm-semantic-filter: shows total tools → selected tools"}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block"},children:"Example: 10→3"})]}),(0,t.jsxs)("li",{children:[(0,t.jsx)(f.Typography.Text,{children:"x-litellm-semantic-filter-tools: CSV of selected tool names"}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block"},children:"Example: wikipedia-fetch,github-search,slack-post"})]})]}),(0,t.jsx)("pre",{style:{background:"#f5f5f5",padding:12,borderRadius:4,overflow:"auto",fontSize:12,margin:0},children:d})]})}]})})}let tD=async({accessToken:e,testModel:t,testQuery:s,setIsTesting:r,setTestResult:l})=>{if(!s||!t||!e)return void C.default.error("Please enter a query and select a model");r(!0),l(null);try{let{headers:r}=await (0,_.testMCPSemanticFilter)(e,t,s),a=(e=>{if(!e.filter)return null;let[t,s]=e.filter.split("->").map(Number);return{totalTools:t,selectedTools:s,tools:e.tools?e.tools.split(",").map(e=>e.trim()):[]}})(r);if(!a)return void C.default.warning("Semantic filter is not enabled or no tools were filtered");l(a),C.default.success("Semantic filter test completed successfully")}catch(e){console.error("Test failed:",e),C.default.error("Failed to test semantic filter")}finally{r(!1)}};function tH({accessToken:e}){var s;let l,{data:a,isLoading:n,isError:i,error:o}=(()=>{let{accessToken:e}=(0,w.default)();return(0,y.useQuery)({queryKey:tP.list({}),queryFn:async()=>await (0,_.getMCPSemanticFilterSettings)(e),enabled:!!e,staleTime:36e5,gcTime:36e5})})(),{mutate:c,isPending:d,error:m}=(s=e||"",l=(0,v.useQueryClient)(),(0,tf.useMutation)({mutationFn:async e=>{if(!s)throw Error("Access token is required");return(0,_.updateMCPSemanticFilterSettings)(s,e)},onSuccess:()=>{l.invalidateQueries({queryKey:tO.all})}})),[u]=D.Form.useForm(),[x,h]=(0,b.useState)(!1),[j,N]=(0,b.useState)(!1),[S,T]=(0,b.useState)([]),[k,A]=(0,b.useState)(!0),[I,P]=(0,b.useState)(""),[O,M]=(0,b.useState)("gpt-4o"),[F,E]=(0,b.useState)(null),[L,R]=(0,b.useState)(!1),U=a?.field_schema,z=a?.values??{};(0,b.useEffect)(()=>{(async()=>{if(e)try{A(!0);let t=(await (0,tU.fetchAvailableModels)(e)).filter(e=>"embedding"===e.mode);T(t)}catch(e){console.error("Error fetching embedding models:",e)}finally{A(!1)}})()},[e]),(0,b.useEffect)(()=>{z&&(u.setFieldsValue({enabled:z.enabled??!1,embedding_model:z.embedding_model??"text-embedding-3-small",top_k:z.top_k??10,similarity_threshold:z.similarity_threshold??.3}),N(!1))},[z,u]);let B=async()=>{try{let e=await u.validateFields();c(e,{onSuccess:()=>{N(!1),h(!0),setTimeout(()=>h(!1),3e3),C.default.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.")},onError:e=>{C.default.fromBackend(e)}})}catch(e){console.error("Form validation failed:",e)}},q=async()=>{e&&await tD({accessToken:e,testModel:O,testQuery:I,setIsTesting:R,setTestResult:E})};return e?(0,t.jsx)("div",{style:{width:"100%"},children:n?(0,t.jsx)(tE.Skeleton,{active:!0}):i?(0,t.jsx)(ej.Alert,{type:"error",message:"Could not load MCP Semantic Filter settings",description:o instanceof Error?o.message:void 0,style:{marginBottom:24}}):(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(ej.Alert,{type:"info",message:"Semantic Tool Filtering",description:"Filter MCP tools semantically based on query relevance. This reduces context window size and improves tool selection accuracy. Click 'Save Settings' to apply changes across all pods (takes effect within 10 seconds).",showIcon:!0,style:{marginBottom:24}}),x&&(0,t.jsx)(ej.Alert,{type:"success",message:"Settings saved successfully",icon:(0,t.jsx)(ey.CheckCircleOutlined,{}),showIcon:!0,closable:!0,style:{marginBottom:16}}),m&&(0,t.jsx)(ej.Alert,{type:"error",message:"Could not update settings",description:m instanceof Error?m.message:void 0,style:{marginBottom:16}}),(0,t.jsxs)(tF.Row,{gutter:24,children:[(0,t.jsx)(tM.Col,{xs:24,lg:12,children:(0,t.jsxs)(D.Form,{form:u,layout:"vertical",disabled:d,onValuesChange:()=>{N(!0)},children:[(0,t.jsxs)(e4.Card,{style:{marginBottom:16},children:[(0,t.jsx)(D.Form.Item,{name:"enabled",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Enable Semantic Filtering"}),(0,t.jsx)(g.Tooltip,{title:"When enabled, only the most relevant MCP tools will be included in requests based on semantic similarity",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),valuePropName:"checked",children:(0,t.jsx)(el.Switch,{disabled:d})}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block",marginTop:-16,marginBottom:16},children:U?.properties?.enabled?.description})]}),(0,t.jsxs)(e4.Card,{title:"Configuration",style:{marginBottom:16},children:[(0,t.jsx)(D.Form.Item,{name:"embedding_model",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Embedding Model"}),(0,t.jsx)(g.Tooltip,{title:"The model used to generate embeddings for semantic matching",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(p.Select,{options:S.map(e=>({label:e.model_group,value:e.model_group})),placeholder:k?"Loading models...":"Select embedding model",showSearch:!0,disabled:d||k,loading:k,notFoundContent:k?"Loading...":"No embedding models available"})}),(0,t.jsx)(D.Form.Item,{name:"top_k",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Top K Results"}),(0,t.jsx)(g.Tooltip,{title:"Maximum number of tools to return after filtering",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(ec.InputNumber,{min:1,max:100,style:{width:"100%"},disabled:d})}),(0,t.jsx)(D.Form.Item,{name:"similarity_threshold",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Similarity Threshold"}),(0,t.jsx)(g.Tooltip,{title:"Minimum similarity score (0-1) for a tool to be included",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(tL.Slider,{min:0,max:1,step:.05,marks:{0:"0.0",.3:"0.3",.5:"0.5",.7:"0.7",1:"1.0"},disabled:d})})]}),(0,t.jsx)("div",{style:{display:"flex",justifyContent:"flex-end",gap:8},children:(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tR.SaveOutlined,{}),onClick:B,loading:d,disabled:!j,children:"Save Settings"})})]})}),(0,t.jsx)(tM.Col,{xs:24,lg:12,children:(0,t.jsx)(t$,{accessToken:e,testQuery:I,setTestQuery:P,testModel:O,setTestModel:M,isTesting:L,onTest:q,filterEnabled:!!z.enabled,testResult:F,curlCommand:`curl --location 'http://localhost:4000/v1/responses' \\ +======== }`,copyKey:"cursor-config",className:"text-xs"})})]})]})]})]}),{})}),(0,t.jsx)(o.TabPanel,{className:"mt-6",children:(0,t.jsx)(()=>(0,t.jsxs)(eM.Space,{direction:"vertical",size:"large",className:"w-full",children:[(0,t.jsxs)("div",{className:"bg-gradient-to-r from-green-50 to-teal-50 p-6 rounded-lg border border-green-100",children:[(0,t.jsxs)("div",{className:"flex items-center gap-3 mb-3",children:[(0,t.jsx)(e9,{className:"text-green-600",size:24}),(0,t.jsx)(tl,{level:4,className:"mb-0 text-green-900",children:"Streamable HTTP Transport"})]}),(0,t.jsx)(ta,{className:"text-green-700",children:"Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming."})]}),(0,t.jsx)(ti,{icon:(0,t.jsx)(e9,{className:"text-green-600",size:16}),title:"Universal MCP Connection",description:"Use this URL with any MCP client that supports HTTP transport",children:(0,t.jsxs)(eM.Space,{direction:"vertical",size:"middle",className:"w-full",children:[(0,t.jsx)("div",{children:(0,t.jsx)(ta,{children:"Each MCP client supports different transports. Refer to your client documentation to determine the appropriate transport method."})}),(0,t.jsx)(g,{title:"Server URL",code:`${s}/mcp`,copyKey:"http-server-url"}),(0,t.jsx)(g,{title:"Headers Configuration",code:JSON.stringify({"x-litellm-api-key":"Bearer YOUR_LITELLM_API_KEY"},null,2),copyKey:"http-headers"}),(0,t.jsx)("div",{className:"mt-4",children:(0,t.jsx)(eb.Button,{type:"link",className:"p-0 h-auto text-blue-600 hover:text-blue-700",href:"https://modelcontextprotocol.io/docs/concepts/transports",icon:(0,t.jsx)(te.ExternalLinkIcon,{size:14}),children:"Learn more about MCP transports"})})]})})]}),{})})]})]})]})})};var tc=e.i(752978),td=e.i(591935),tm=e.i(492030);let tu=({server:e,isLoadingHealth:s,isRechecking:r,onRecheck:l})=>{let[a,n]=(0,b.useState)(!1),i=e.status||"unknown",o=e.last_health_check,c=e.health_check_error;if(s||r)return(0,t.jsxs)("span",{className:"inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"}),"Checking"]});let d=!!l,m=(0,t.jsxs)("div",{className:"max-w-xs",children:[(0,t.jsxs)("div",{className:"font-semibold mb-1",children:["Health Status: ",i]}),o&&(0,t.jsxs)("div",{className:"text-xs mb-1",children:["Last Check: ",new Date(o).toLocaleString()]}),c&&(0,t.jsxs)("div",{className:"text-xs",children:[(0,t.jsx)("div",{className:"font-medium text-red-400 mb-1",children:"Error:"}),(0,t.jsx)("div",{className:"break-words",children:c})]}),!o&&!c&&(0,t.jsx)("div",{className:"text-xs text-gray-400",children:"No health check data available"}),d&&(0,t.jsx)("div",{className:"text-xs text-gray-400 mt-1",children:"Click to recheck"})]});return(0,t.jsx)(g.Tooltip,{title:m,placement:"top",children:(0,t.jsxs)("span",{className:`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${(e=>{switch(e){case"healthy":return"text-green-700 bg-green-50 border border-green-200";case"unhealthy":return"text-red-700 bg-red-50 border border-red-200";default:return"text-gray-600 bg-gray-50 border border-gray-200"}})(i)} ${d?"cursor-pointer hover:opacity-80":"cursor-default"}`,onMouseEnter:()=>n(!0),onMouseLeave:()=>n(!1),onClick:d?()=>l(e.server_id):void 0,children:[(0,t.jsx)("span",{children:a&&d?"↻":(e=>{switch(e){case"healthy":return"✓";case"unhealthy":return"✗";default:return"?"}})(i)}),a&&d?"Recheck":i.charAt(0).toUpperCase()+i.slice(1)]})})};var tx=e.i(530212),th=e.i(848725);let tp=b.forwardRef(function(e,t){return b.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:2,stroke:"currentColor","aria-hidden":"true",ref:t},e),b.createElement("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"}))});var tg=e.i(350967),tf=e.i(954616);function tb(e){if(!e)return[];if(Array.isArray(e))return e.map(e=>tj(e)).filter(e=>void 0!==e);let t=tj(e);return void 0===t?[]:[t]}function tj(e,t){if(!e)return;let s=void 0!==t?t:e.default;if("object"===e.type){let t="object"!=typeof s||null===s||Array.isArray(s)?{}:{...s};return e.properties&&Object.entries(e.properties).forEach(([e,s])=>{t[e]=tj(s,t[e])}),t}if("array"===e.type){if(Array.isArray(s)){let t=e.items;if(!t)return s;if(0===s.length){let e=tb(t);return e.length?e:s}return Array.isArray(t)?s.map((e,s)=>tj(t[s]??t[t.length-1],e)):s.map(e=>tj(t,e))}return void 0!==s?s:tb(e.items)}if(void 0!==s)return s;switch(e.type){case"integer":case"number":return 0;case"boolean":return!1;default:return""}}let ty=e=>{let t=tj(e);if("object"===e.type||"array"===e.type){let s="array"===e.type?[]:{};return JSON.stringify(t??s,null,2)}return t};function tv({tool:e,onSubmit:s,isLoading:r,result:a,error:n,onClose:i}){let[o]=D.Form.useForm(),[c,d]=b.default.useState("formatted"),[m,u]=b.default.useState(null),[x,h]=b.default.useState(null),f=b.default.useMemo(()=>"string"==typeof e.inputSchema?{type:"object",properties:{input:{type:"string",description:"Input for this tool"}},required:["input"]}:e.inputSchema,[e.inputSchema]),j=b.default.useMemo(()=>f.properties&&f.properties.params&&"object"===f.properties.params.type&&f.properties.params.properties?{type:"object",properties:f.properties.params.properties,required:f.properties.params.required||[]}:f,[f]);b.default.useEffect(()=>{if(o.resetFields(),!j.properties)return;let e={};Object.entries(j.properties).forEach(([t,s])=>{e[t]=ty(s)}),o.setFieldsValue(e)},[o,j,e]),b.default.useEffect(()=>{m&&(a||n)&&h(Date.now()-m)},[a,n,m]);let y=async e=>{try{if(navigator.clipboard&&window.isSecureContext)return await navigator.clipboard.writeText(e),!0;{let t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();let s=document.execCommand("copy");if(document.body.removeChild(t),!s)throw Error("execCommand failed");return!0}}catch(e){return console.error("Copy failed:",e),!1}},v=async()=>{await y(JSON.stringify(a,null,2))?C.default.success("Result copied to clipboard"):C.default.fromBackend("Failed to copy result")},N=async()=>{await y(e.name)?C.default.success("Tool name copied to clipboard"):C.default.fromBackend("Failed to copy tool name")};return(0,t.jsxs)("div",{className:"space-y-4 h-full",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between pb-3 border-b border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[e.mcp_info.logo_url&&(0,t.jsx)("img",{src:e.mcp_info.logo_url,alt:`${e.mcp_info.server_name} logo`,className:"w-6 h-6 object-contain"}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2 mb-1",children:[(0,t.jsx)("h2",{className:"text-lg font-semibold text-gray-900",children:"Test Tool:"}),(0,t.jsxs)("div",{className:"group inline-flex items-center space-x-1 bg-slate-50 hover:bg-slate-100 px-3 py-1 rounded-md cursor-pointer transition-colors border border-slate-200",onClick:N,title:"Click to copy tool name",children:[(0,t.jsx)("span",{className:"font-mono text-slate-700 font-medium text-sm",children:e.name}),(0,t.jsx)("svg",{className:"w-3 h-3 text-slate-400 group-hover:text-slate-600 transition-colors",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})})]})]}),(0,t.jsx)("p",{className:"text-xs text-gray-600",children:e.description}),(0,t.jsxs)("p",{className:"text-xs text-gray-500",children:["Provider: ",e.mcp_info.server_name]})]})]}),(0,t.jsx)(l.Button,{onClick:i,variant:"light",size:"sm",className:"text-gray-500 hover:text-gray-700",children:(0,t.jsx)("svg",{className:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M6 18L18 6M6 6l12 12"})})})]}),(0,t.jsxs)("div",{className:"grid grid-cols-2 gap-4 h-full",children:[(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"border-b border-gray-100 px-4 py-2",children:(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:"Input Parameters"}),(0,t.jsx)(g.Tooltip,{title:"Configure the input parameters for this tool call",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"text-gray-400 hover:text-gray-600"})})]})}),(0,t.jsx)("div",{className:"p-4",children:(0,t.jsxs)(D.Form,{form:o,onFinish:e=>{u(Date.now()),h(null);let t={};Object.entries(e).forEach(([e,s])=>{let r=j.properties?.[e];if(r&&null!=s&&""!==s)switch(r.type){case"boolean":t[e]="true"===s||!0===s;break;case"number":case"integer":{let l=Number(s);t[e]=Number.isNaN(l)?s:"integer"===r.type?Math.trunc(l):l;break}case"object":case"array":try{let l="string"==typeof s?JSON.parse(s):s,a="object"===r.type&&null!==l&&"object"==typeof l&&!Array.isArray(l),n="array"===r.type&&Array.isArray(l);"object"===r.type&&a||"array"===r.type&&n?t[e]=l:t[e]=s}catch(r){t[e]=s}break;case"string":t[e]=String(s);break;default:t[e]=s}else null!=s&&""!==s&&(t[e]=s)}),s(f.properties&&f.properties.params&&"object"===f.properties.params.type&&f.properties.params.properties?{params:t}:t)},layout:"vertical",className:"space-y-3",children:["string"==typeof e.inputSchema?(0,t.jsx)("div",{className:"space-y-3",children:(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700",children:["Input ",(0,t.jsx)("span",{className:"text-red-500",children:"*"})]}),name:"input",rules:[{required:!0,message:"Please enter input for this tool"}],className:"mb-3",children:(0,t.jsx)(ei.TextInput,{placeholder:"Enter input for this tool",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})}):void 0===j.properties?(0,t.jsx)("div",{className:"text-center py-6 bg-gray-50 rounded-lg border border-gray-200",children:(0,t.jsxs)("div",{className:"max-w-sm mx-auto",children:[(0,t.jsx)("h4",{className:"text-sm font-medium text-gray-900 mb-1",children:"No Parameters Required"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"This tool can be called without any input parameters."})]})}):(0,t.jsx)("div",{className:"space-y-3",children:Object.entries(j.properties).map(([s,r])=>{let l=ty(r),a=`${e.name}-${s}`;return(0,t.jsxs)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:[s," ",j.required?.includes(s)&&(0,t.jsx)("span",{className:"text-red-500",children:"*"}),r.description&&(0,t.jsx)(g.Tooltip,{title:r.description,children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-gray-400 hover:text-gray-600"})})]}),name:s,initialValue:l,rules:[{required:j.required?.includes(s),message:`Please enter ${s}`},..."object"===r.type||"array"===r.type?[{validator:(e,t)=>{if((null==t||""===t)&&!j.required?.includes(s))return Promise.resolve();try{let e="string"==typeof t?JSON.parse(t):t,s="object"===r.type&&null!==e&&"object"==typeof e&&!Array.isArray(e),l="array"===r.type&&Array.isArray(e);if("object"===r.type&&s||"array"===r.type&&l)return Promise.resolve();return Promise.reject(Error("object"===r.type?"Please enter a JSON object":"Please enter a JSON array"))}catch(e){return Promise.reject(Error("Invalid JSON"))}}}]:[]],className:"mb-3",children:["string"===r.type&&r.enum&&(0,t.jsxs)("select",{className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors",defaultValue:l??"",children:[!j.required?.includes(s)&&(0,t.jsxs)("option",{value:"",children:["Select ",s]}),r.enum.map(e=>(0,t.jsx)("option",{value:e,children:e},e))]}),"string"===r.type&&!r.enum&&(0,t.jsx)(ei.TextInput,{placeholder:r.description||`Enter ${s}`,defaultValue:l??"",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"}),("number"===r.type||"integer"===r.type)&&(0,t.jsx)("input",{type:"number",step:"integer"===r.type?1:"any",placeholder:r.description||`Enter ${s}`,defaultValue:l??0,className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"}),"boolean"===r.type&&(0,t.jsxs)(p.Select,{placeholder:`Select ${s}`,allowClear:!j.required?.includes(s),className:"w-full",children:[(0,t.jsx)(p.Select.Option,{value:!0,children:"True"}),(0,t.jsx)(p.Select.Option,{value:!1,children:"False"})]}),("object"===r.type||"array"===r.type)&&(0,t.jsxs)("div",{className:"space-y-2",children:[(0,t.jsx)("textarea",{rows:"object"===r.type?6:4,placeholder:r.description||("object"===r.type?`Enter JSON object for ${s}`:`Enter JSON array for ${s}`),defaultValue:l??("object"===r.type?"{}":"[]"),spellCheck:!1,"data-testid":`textarea-${s}`,className:"w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"object"===r.type?"Provide a valid JSON object.":"Provide a valid JSON array."})]})]},a)})}),(0,t.jsx)("div",{className:"pt-3 border-t border-gray-100",children:(0,t.jsx)(l.Button,{onClick:()=>o.submit(),disabled:r,variant:"primary",className:"w-full",loading:r,children:r?"Calling Tool...":a||n?"Call Again":"Call Tool"})})]})})]}),(0,t.jsxs)("div",{className:"bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"border-b border-gray-100 px-4 py-2",children:(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:"Tool Result"})}),(0,t.jsx)("div",{className:"p-4",children:a||n||r?(0,t.jsxs)("div",{className:"space-y-3",children:[a&&!r&&!n&&(0,t.jsx)("div",{className:"p-2 bg-green-50 border border-green-200 rounded-lg",children:(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2",children:[(0,t.jsx)("svg",{className:"h-4 w-4 text-green-500",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"})}),(0,t.jsx)("h4",{className:"text-xs font-medium text-green-900",children:"Tool executed successfully"}),null!==x&&(0,t.jsxs)("span",{className:"text-xs text-green-600 ml-1",children:["• ",(x/1e3).toFixed(2),"s"]})]}),(0,t.jsxs)("div",{className:"flex items-center space-x-1",children:[(0,t.jsxs)("div",{className:"flex bg-white rounded border border-green-300 p-0.5",children:[(0,t.jsx)("button",{onClick:()=>d("formatted"),className:`px-2 py-1 text-xs font-medium rounded transition-colors ${"formatted"===c?"bg-green-100 text-green-800":"text-green-600 hover:text-green-800"}`,children:"Formatted"}),(0,t.jsx)("button",{onClick:()=>d("json"),className:`px-2 py-1 text-xs font-medium rounded transition-colors ${"json"===c?"bg-green-100 text-green-800":"text-green-600 hover:text-green-800"}`,children:"JSON"})]}),(0,t.jsx)("button",{onClick:v,className:"p-1 hover:bg-green-100 rounded text-green-700",title:"Copy response",children:(0,t.jsxs)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[(0,t.jsx)("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2"}),(0,t.jsx)("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"})]})})]})]})}),(0,t.jsxs)("div",{className:"max-h-96 overflow-y-auto",children:[r&&(0,t.jsxs)("div",{className:"flex flex-col justify-center items-center h-48 text-gray-500",children:[(0,t.jsxs)("div",{className:"relative",children:[(0,t.jsx)("div",{className:"animate-spin rounded-full h-8 w-8 border-2 border-gray-200"}),(0,t.jsx)("div",{className:"animate-spin rounded-full h-8 w-8 border-2 border-blue-600 border-t-transparent absolute top-0"})]}),(0,t.jsx)("p",{className:"text-sm font-medium mt-3",children:"Calling tool..."}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-1",children:"Please wait while we process your request"})]}),n&&(0,t.jsx)("div",{className:"bg-red-50 border border-red-200 rounded-lg p-3",children:(0,t.jsxs)("div",{className:"flex items-start space-x-2",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("svg",{className:"h-4 w-4 text-red-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-2 mb-1",children:[(0,t.jsx)("h4",{className:"text-xs font-medium text-red-900",children:"Tool Call Failed"}),null!==x&&(0,t.jsxs)("span",{className:"text-xs text-red-600",children:["• ",(x/1e3).toFixed(2),"s"]})]}),(0,t.jsx)("div",{className:"bg-white border border-red-200 rounded p-2 max-h-48 overflow-y-auto",children:(0,t.jsx)("pre",{className:"text-xs whitespace-pre-wrap text-red-700 font-mono",children:n.message})})]})]})}),a&&!r&&!n&&(0,t.jsx)("div",{className:"space-y-3",children:"formatted"===c?a.map((e,s)=>(0,t.jsxs)("div",{className:"border border-gray-200 rounded-lg overflow-hidden",children:["text"===e.type&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Text Response"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsx)("div",{className:"bg-white rounded border border-gray-200 max-h-64 overflow-y-auto",children:(0,t.jsx)("div",{className:"p-3 space-y-2",children:e.text.split("\n\n").map((e,s)=>{if(""===e.trim())return null;if(e.startsWith("##")){let r=e.replace(/^#+\s/,"");return(0,t.jsx)("div",{className:"border-b border-gray-200 pb-1 mb-2",children:(0,t.jsx)("h3",{className:"text-sm font-semibold text-gray-900",children:r})},s)}let r=/(https?:\/\/[^\s\)]+)/g;if(r.test(e)){let l=e.split(r);return(0,t.jsx)("div",{className:"bg-blue-50 border border-blue-200 rounded p-2",children:(0,t.jsx)("div",{className:"text-xs text-gray-700 leading-relaxed whitespace-pre-wrap",children:l.map((e,s)=>r.test(e)?(0,t.jsx)("a",{href:e,target:"_blank",rel:"noopener noreferrer",className:"text-blue-600 hover:text-blue-800 underline break-all",children:e},s):e)})},s)}return e.includes("Score:")?(0,t.jsx)("div",{className:"bg-green-50 border-l-4 border-green-400 p-2 rounded-r",children:(0,t.jsx)("p",{className:"text-xs text-green-800 font-medium whitespace-pre-wrap",children:e})},s):(0,t.jsx)("div",{className:"bg-gray-50 rounded p-2 border border-gray-200",children:(0,t.jsx)("div",{className:"text-xs text-gray-700 leading-relaxed whitespace-pre-wrap font-mono",children:e})},s)}).filter(Boolean)})})})]}),"image"===e.type&&e.url&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Image Response"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsx)("div",{className:"bg-gray-50 rounded p-3 border border-gray-200",children:(0,t.jsx)("img",{src:e.url,alt:"Tool result",className:"max-w-full h-auto rounded shadow-sm"})})})]}),"embedded_resource"===e.type&&(0,t.jsxs)("div",{children:[(0,t.jsx)("div",{className:"bg-gray-50 px-3 py-1 border-b border-gray-200",children:(0,t.jsx)("span",{className:"text-xs font-medium text-gray-700 uppercase tracking-wide",children:"Embedded Resource"})}),(0,t.jsx)("div",{className:"p-3",children:(0,t.jsxs)("div",{className:"flex items-center space-x-2 p-3 bg-blue-50 border border-blue-200 rounded",children:[(0,t.jsx)("div",{className:"flex-shrink-0",children:(0,t.jsx)("svg",{className:"h-5 w-5 text-blue-500",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"})})}),(0,t.jsxs)("div",{className:"flex-1",children:[(0,t.jsxs)("p",{className:"text-xs font-medium text-blue-900",children:["Resource Type: ",e.resource_type]}),e.url&&(0,t.jsxs)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center text-xs text-blue-600 hover:text-blue-800 hover:underline mt-1 transition-colors",children:["View Resource",(0,t.jsxs)("svg",{className:"ml-1 h-3 w-3",fill:"currentColor",viewBox:"0 0 20 20",children:[(0,t.jsx)("path",{d:"M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"}),(0,t.jsx)("path",{d:"M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"})]})]})]})]})})]})]},s)):(0,t.jsx)("div",{className:"bg-white rounded border border-gray-200",children:(0,t.jsx)("div",{className:"p-3 overflow-auto max-h-80 bg-gray-50",children:(0,t.jsx)("pre",{className:"text-xs font-mono whitespace-pre-wrap break-all text-gray-800",children:JSON.stringify(a,null,2)})})})})]})]}):(0,t.jsx)("div",{className:"flex flex-col justify-center items-center h-48 text-gray-500",children:(0,t.jsxs)("div",{className:"text-center max-w-sm",children:[(0,t.jsx)("div",{className:"mb-3",children:(0,t.jsx)("svg",{className:"mx-auto h-12 w-12 text-gray-300",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:1,d:"M13 10V3L4 14h7v7l9-11h-7z"})})}),(0,t.jsx)("h4",{className:"text-sm font-medium text-gray-900 mb-1",children:"Ready to Call Tool"}),(0,t.jsx)("p",{className:"text-xs text-gray-500 leading-relaxed",children:'Configure the input parameters and click "Call Tool" to see the results here.'})]})})})]})]})]})}var tN=e.i(983561),t_=e.i(438957);let tw=({serverId:e,accessToken:s,auth_type:r,userRole:l,userID:a,serverAlias:n,extraHeaders:i})=>{let[o,c]=(0,b.useState)(null),[u,x]=(0,b.useState)(null),[h,p]=(0,b.useState)(null),[g,f]=(0,b.useState)(""),[j,v]=(0,b.useState)({}),[N,w]=(0,b.useState)(!1),S=i&&i.length>0,C=()=>{if(!n||!S)return;let e={};return Object.entries(j).forEach(([t,s])=>{s&&s.trim()&&(e[`x-mcp-${n}-${t.toLowerCase()}`]=s)}),Object.keys(e).length>0?e:void 0},{data:T,isLoading:k,error:A,refetch:I}=(0,y.useQuery)({queryKey:["mcpTools",e,j],queryFn:()=>{if(!s)throw Error("Access Token required");return(0,_.listMCPTools)(s,e,C())},enabled:!!s,staleTime:3e4}),{mutate:P,isPending:O}=(0,tf.useMutation)({mutationFn:async t=>{if(!s)throw Error("Access Token required");try{return await (0,_.callMCPTool)(s,e,t.tool.name,t.arguments,{customHeaders:C()})}catch(e){throw e}},onSuccess:e=>{x(e.content),p(null)},onError:e=>{p(e),x(null)}}),M=T?.tools||[],F=M.filter(e=>{let t=g.toLowerCase();return e.name.toLowerCase().includes(t)||e.description&&e.description.toLowerCase().includes(t)||e.mcp_info.server_name&&e.mcp_info.server_name.toLowerCase().includes(t)});return(0,t.jsx)("div",{className:"w-full h-screen p-4 bg-white",children:(0,t.jsx)(eg.Card,{className:"w-full rounded-xl shadow-md overflow-hidden",children:(0,t.jsxs)("div",{className:"flex h-auto w-full gap-4",children:[(0,t.jsxs)("div",{className:"w-1/4 p-4 bg-gray-50 flex flex-col",children:[(0,t.jsx)(m.Title,{className:"text-xl font-semibold mb-6 mt-2",children:"MCP Tools"}),(0,t.jsxs)("div",{className:"flex flex-col flex-1",children:[S&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsxs)("div",{className:"flex items-center justify-between mb-2",children:[(0,t.jsxs)("div",{className:"flex items-center",children:[(0,t.jsx)(t_.KeyOutlined,{className:"text-blue-600 mr-2"}),(0,t.jsx)(d.Text,{className:"text-sm font-medium text-blue-800",children:"Additional Headers"})]}),(0,t.jsx)(eb.Button,{size:"small",type:"link",onClick:()=>w(!N),className:"text-blue-700 p-0 h-auto",children:N?"Hide":"Configure"})]}),!N&&0===Object.keys(j).length&&(0,t.jsx)(d.Text,{className:"text-xs text-blue-700",children:'This server requires additional headers. Click "Configure" to provide values.'}),N&&(0,t.jsxs)("div",{className:"mt-3 space-y-2",children:[i?.map(e=>(0,t.jsxs)("div",{children:[(0,t.jsx)("label",{className:"block text-xs font-medium text-gray-700 mb-1",children:e}),(0,t.jsx)(H.Input,{size:"small",placeholder:`Enter ${e}`,value:j[e]||"",onChange:t=>{v({...j,[e]:t.target.value})},prefix:(0,t.jsx)(t_.KeyOutlined,{className:"text-gray-400"}),className:"rounded"})]},e)),(0,t.jsx)(eb.Button,{size:"small",type:"primary",onClick:()=>{I(),w(!1)},disabled:Object.values(j).every(e=>!e||!e.trim()),className:"w-full mt-2",children:"Load Tools"})]}),!N&&Object.keys(j).length>0&&(0,t.jsx)("div",{className:"mt-2",children:(0,t.jsxs)(d.Text,{className:"text-xs text-green-700 flex items-center",children:[(0,t.jsx)("span",{className:"inline-block w-2 h-2 bg-green-500 rounded-full mr-2"}),Object.keys(j).length," header(s) configured"]})})]}),(0,t.jsxs)("div",{className:"flex flex-col flex-1 min-h-0",children:[(0,t.jsxs)(d.Text,{className:"font-medium block mb-3 text-gray-700 flex items-center",children:[(0,t.jsx)(ep.ToolOutlined,{className:"mr-2"})," Available Tools",M.length>0&&(0,t.jsx)("span",{className:"ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full",children:M.length})]}),M.length>0&&(0,t.jsx)("div",{className:"mb-3",children:(0,t.jsx)(H.Input,{placeholder:"Search tools...",prefix:(0,t.jsx)(ew.SearchOutlined,{className:"text-gray-400"}),value:g,onChange:e=>f(e.target.value),allowClear:!0,className:"rounded-lg",size:"middle"})}),k&&(0,t.jsxs)("div",{className:"flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg",children:[(0,t.jsxs)("div",{className:"relative mb-3",children:[(0,t.jsx)("div",{className:"animate-spin rounded-full h-6 w-6 border-2 border-gray-200"}),(0,t.jsx)("div",{className:"animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"})]}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700",children:"Loading tools..."})]}),T?.error&&!k&&!M.length&&(0,t.jsx)("div",{className:"p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200",children:(0,t.jsxs)("p",{className:"font-medium",children:["Error: ",T.message]})}),!k&&!T?.error&&(!M||0===M.length)&&(0,t.jsxs)("div",{className:"p-4 text-center bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)("div",{className:"mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-gray-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:(0,t.jsx)("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 8.172V5L8 4z"})})}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700 mb-1",children:"No tools available"}),(0,t.jsx)("p",{className:"text-xs text-gray-500",children:"No tools found for this server"})]}),!k&&!T?.error&&M.length>0&&(0,t.jsx)(t.Fragment,{children:0===F.length?(0,t.jsxs)("div",{className:"p-4 text-center bg-white border border-gray-200 rounded-lg",children:[(0,t.jsx)(ew.SearchOutlined,{className:"text-2xl text-gray-400 mb-2"}),(0,t.jsx)("p",{className:"text-xs font-medium text-gray-700 mb-1",children:"No tools found"}),(0,t.jsxs)("p",{className:"text-xs text-gray-500",children:['No tools match "',g,'"']})]}):(0,t.jsx)("div",{className:"space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable",style:{maxHeight:"400px",scrollbarWidth:"auto",scrollbarColor:"#cbd5e0 #f7fafc"},children:F.map(e=>(0,t.jsxs)("div",{className:`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${o?.name===e.name?"border-blue-500 bg-blue-50 ring-1 ring-blue-200":"border-gray-200 bg-white hover:border-gray-300"}`,onClick:()=>{c(e),x(null),p(null)},children:[(0,t.jsxs)("div",{className:"flex items-start space-x-2",children:[e.mcp_info.logo_url&&(0,t.jsx)("img",{src:e.mcp_info.logo_url,alt:`${e.mcp_info.server_name} logo`,className:"w-4 h-4 object-contain flex-shrink-0 mt-0.5"}),(0,t.jsxs)("div",{className:"flex-1 min-w-0",children:[(0,t.jsx)("h4",{className:"font-mono text-xs font-medium text-gray-900 truncate",children:e.name}),(0,t.jsx)("p",{className:"text-xs text-gray-500 truncate",children:e.mcp_info.server_name}),(0,t.jsx)("p",{className:"text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed",children:e.description})]})]}),o?.name===e.name&&(0,t.jsx)("div",{className:"mt-2 pt-2 border-t border-blue-200",children:(0,t.jsxs)("div",{className:"flex items-center text-xs font-medium text-blue-700",children:[(0,t.jsx)("svg",{className:"w-3 h-3 mr-1",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",clipRule:"evenodd"})}),"Selected"]})})]},e.name))})})]})]})]}),(0,t.jsxs)("div",{className:"w-3/4 flex flex-col bg-white",children:[(0,t.jsx)("div",{className:"p-4 border-b border-gray-200 flex justify-between items-center",children:(0,t.jsx)(m.Title,{className:"text-xl font-semibold mb-0",children:"Tool Testing Playground"})}),(0,t.jsx)("div",{className:"flex-1 overflow-auto p-4",children:o?(0,t.jsx)("div",{className:"h-full",children:(0,t.jsx)(tv,{tool:o,onSubmit:e=>{P({tool:o,arguments:e})},result:u,error:h,isLoading:O,onClose:()=>c(null)})}):(0,t.jsxs)("div",{className:"h-full flex flex-col items-center justify-center text-gray-400",children:[(0,t.jsx)(tN.RobotOutlined,{style:{fontSize:"48px",marginBottom:"16px"}}),(0,t.jsx)(d.Text,{className:"text-lg font-medium text-gray-600 mb-2",children:"Select a Tool to Test"}),(0,t.jsx)(d.Text,{className:"text-center text-gray-500 max-w-md",children:"Choose a tool from the left sidebar to start testing its functionality with custom inputs."})]})})]})]})})})},tS=[eo.AUTH_TYPE.API_KEY,eo.AUTH_TYPE.BEARER_TOKEN,eo.AUTH_TYPE.TOKEN,eo.AUTH_TYPE.BASIC],tC=[...tS,eo.AUTH_TYPE.OAUTH2,eo.AUTH_TYPE.AWS_SIGV4],tT="litellm-mcp-oauth-edit-state",tk=({mcpServer:e,accessToken:s,onCancel:r,onSuccess:d,availableAccessGroups:m})=>{let[u]=D.Form.useForm(),[x,h]=(0,b.useState)({}),[f,j]=(0,b.useState)([]),[y,v]=(0,b.useState)(!1),[N,w]=(0,b.useState)(null),[S,T]=(0,b.useState)(""),[k,A]=(0,b.useState)(!1),[I,P]=(0,b.useState)([]),[O,M]=(0,b.useState)({}),[F,E]=(0,b.useState)({}),[L,R]=(0,b.useState)(null),[z,U]=(0,b.useState)(e.mcp_info?.logo_url||void 0),B=D.Form.useWatch("auth_type",u),q=D.Form.useWatch("transport",u),V="stdio"===q,$=q===eo.TRANSPORT.OPENAPI,K=!!B&&tS.includes(B),W=B===eo.AUTH_TYPE.OAUTH2,J=B===eo.AUTH_TYPE.AWS_SIGV4,Y=D.Form.useWatch("oauth_flow_type",u),G=W&&Y===eo.OAUTH_FLOW.M2M,[Q,Z]=(0,b.useState)(null),X=D.Form.useWatch("url",u),ee=D.Form.useWatch("spec_path",u),et=D.Form.useWatch("server_name",u),es=D.Form.useWatch("auth_type",u),er=D.Form.useWatch("static_headers",u),el=D.Form.useWatch("credentials",u),ea=D.Form.useWatch("authorization_url",u),ei=D.Form.useWatch("token_url",u),ed=D.Form.useWatch("registration_url",u),{startOAuthFlow:em,status:eu,error:ex,tokenResponse:eh}=eQ({accessToken:s,getCredentials:()=>u.getFieldValue("credentials"),getTemporaryPayload:()=>{let t=u.getFieldsValue(!0),s=t.url||e.url,r=t.transport||e.transport;if(!s||!r)return null;let l=Array.isArray(t.static_headers)?t.static_headers.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{};return{server_id:e.server_id,server_name:t.server_name||e.server_name||e.alias,alias:t.alias||e.alias,description:t.description||e.description,url:s,transport:r,auth_type:eo.AUTH_TYPE.OAUTH2,credentials:t.credentials,mcp_access_groups:t.mcp_access_groups||e.mcp_access_groups,static_headers:l,command:t.command,args:t.args,env:t.env}},onTokenReceived:e=>{if(Z(e?.access_token??null),e?.access_token){let t={access_token:e.access_token,...e.refresh_token&&{refresh_token:e.refresh_token},...e.expires_in&&{expires_in:e.expires_in},...e.scope&&{scope:e.scope}};u.setFieldsValue({credentials:t}),C.default.success("OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.")}},onBeforeRedirect:()=>{try{let t=u.getFieldsValue(!0);(0,eG.setSecureItem)(tT,JSON.stringify({serverId:e.server_id,formValues:t,costConfig:x,allowedTools:I,searchValue:S,aliasManuallyEdited:k}))}catch(e){console.warn("Failed to persist MCP edit state",e)}}}),ep=b.default.useMemo(()=>e.static_headers?Object.entries(e.static_headers).map(([e,t])=>({header:e,value:null!=t?String(t):""})):[],[e.static_headers]),eg=b.default.useMemo(()=>{let t=e.env??void 0;if(!t||0===Object.keys(t).length)return"";try{return JSON.stringify(t,null,2)}catch{return""}},[e.env]),ej=b.default.useMemo(()=>e.spec_path&&"stdio"!==e.transport?eo.TRANSPORT.OPENAPI:e.transport,[e]),ey=b.default.useMemo(()=>({...e,transport:ej,static_headers:ep,extra_headers:e.extra_headers||[],oauth_flow_type:e.token_url?eo.OAUTH_FLOW.M2M:eo.OAUTH_FLOW.INTERACTIVE,token_validation_json:e.token_validation?JSON.stringify(e.token_validation,null,2):void 0}),[e,ej,ep,eg]);(0,b.useEffect)(()=>{e.mcp_info?.mcp_server_cost_info&&h(e.mcp_info.mcp_server_cost_info)},[e]),(0,b.useEffect)(()=>{e.allowed_tools&&P(e.allowed_tools),M(e.tool_name_to_display_name??{}),E(e.tool_name_to_description??{})},[e]),(0,b.useEffect)(()=>{let t=(0,eG.getSecureItem)(tT);if(t)try{let s=JSON.parse(t);if(!s||s.serverId!==e.server_id)return;s.formValues&&R({...e,...s.formValues}),s.costConfig&&h(s.costConfig),s.allowedTools&&P(s.allowedTools),s.searchValue&&T(s.searchValue),"boolean"==typeof s.aliasManuallyEdited&&A(s.aliasManuallyEdited)}catch(e){console.error("Failed to restore MCP edit state",e)}finally{window.sessionStorage.removeItem(tT)}},[u,e]),(0,b.useEffect)(()=>{if(!L)return;let t=L.transport||e.transport;t&&t!==u.getFieldValue("transport")?u.setFieldsValue({transport:t}):(u.setFieldsValue(L),R(null))},[L,u,e.transport]),(0,b.useEffect)(()=>{if(e.mcp_access_groups){let t=e.mcp_access_groups.map(e=>"string"==typeof e?e:e.name||String(e));u.setFieldValue("mcp_access_groups",t)}},[e]),(0,b.useEffect)(()=>{e.server_id&&""!==e.server_id.trim()&&ev()},[e,s]);let ev=async()=>{if(s&&e.server_id){v(!0),w(null);try{let t=await (0,_.listMCPTools)(s,e.server_id);t.tools&&!t.error?j(t.tools):(console.error("Failed to fetch tools:",t.message),j([]),w(t.message||"Failed to load tools"))}catch(e){console.error("Tools fetch error:",e),j([]),w(e instanceof Error?e.message:"Failed to load tools")}finally{v(!1)}}},eN=async t=>{if(s)try{let{static_headers:r,credentials:l,stdio_config:a,env_json:n,command:i,args:o,allow_all_keys:c,available_on_public_internet:m,delegate_auth_to_upstream:u,token_validation_json:h,...p}=t,g=(p.mcp_access_groups||[]).map(e=>"string"==typeof e?e:e.name||String(e)),f=Array.isArray(r)?r.reduce((e,t)=>{let s=t?.header?.trim();return s&&(e[s]=t?.value??""),e},{}):{},b=l&&"object"==typeof l?Object.entries(l).reduce((e,[t,s])=>{if(null==s||""===s)return e;if("scopes"===t){if(Array.isArray(s)){let r=s.filter(e=>null!=e&&""!==e);r.length>0&&(e[t]=r)}}else e[t]=s;return e},{}):void 0,j={};if("stdio"===p.transport)if(a)try{let e=JSON.parse(a),t=e;if(e?.mcpServers&&"object"==typeof e.mcpServers){let s=Object.keys(e.mcpServers);s.length>0&&(t=e.mcpServers[s[0]])}let s=Array.isArray(t?.args)?t.args.map(e=>String(e)).filter(e=>""!==e.trim()):[],r=t?.env&&"object"==typeof t.env&&!Array.isArray(t.env)?Object.entries(t.env).reduce((e,[t,s])=>(null==t||""===String(t).trim()||(e[String(t)]=null==s?"":String(s)),e),{}):{};if(!(j={command:t?.command?String(t.command):void 0,args:s,env:r}).command)return void C.default.fromBackend("Stdio configuration must include a command")}catch{C.default.fromBackend("Invalid JSON in stdio configuration");return}else{let e={};if(n)try{let t=JSON.parse(n);t&&"object"==typeof t&&!Array.isArray(t)&&(e=Object.entries(t).reduce((e,[t,s])=>(null==t||""===String(t).trim()||(e[String(t)]=null==s?"":String(s)),e),{}))}catch{C.default.fromBackend("Invalid JSON in stdio env configuration");return}let t=Array.isArray(o)?o.map(e=>String(e)).filter(e=>""!==e.trim()):[],s=i?String(i).trim():"";if(!s)return void C.default.fromBackend("Stdio transport requires a command");j={command:s,args:t,env:e}}p.transport===eo.TRANSPORT.OPENAPI&&(p.transport="http");let y=null;if(h&&""!==h.trim())try{y=JSON.parse(h)}catch{C.default.fromBackend("Invalid JSON in Token Validation Rules");return}let v=p.server_name||p.url||e.server_name||e.url||p.alias||e.alias||"unknown",N={...p,...j,stdio_config:void 0,env_json:void 0,server_id:e.server_id,mcp_info:{server_name:v,description:p.description,logo_url:z||void 0,mcp_server_cost_info:Object.keys(x).length>0?x:null},mcp_access_groups:g,alias:p.alias,extra_headers:p.extra_headers||[],allowed_tools:I.length>0?I:null,tool_name_to_display_name:Object.keys(O).length>0?O:null,tool_name_to_description:Object.keys(F).length>0?F:null,disallowed_tools:p.disallowed_tools||[],static_headers:f,allow_all_keys:!!(c??e.allow_all_keys),available_on_public_internet:!!(m??e.available_on_public_internet),delegate_auth_to_upstream:p.auth_type===eo.AUTH_TYPE.OAUTH2&&!!(u??e.delegate_auth_to_upstream),...null!==y||e.token_validation?{token_validation:y}:{}};p.auth_type&&tC.includes(p.auth_type)&&b&&Object.keys(b).length>0&&(N.credentials=b);let w=await (0,_.updateMCPServer)(s,N);C.default.success("MCP Server updated successfully"),d(w)}catch(e){C.default.fromBackend("Failed to update MCP Server"+(e?.message?`: ${e.message}`:""))}};return(0,t.jsxs)(n.TabGroup,{children:[(0,t.jsxs)(i.TabList,{className:"grid w-full grid-cols-2",children:[(0,t.jsx)(a.Tab,{children:"Server Configuration"}),(0,t.jsx)(a.Tab,{children:"Cost Configuration"})]}),(0,t.jsxs)(c.TabPanels,{className:"mt-6",children:[(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)(D.Form,{form:u,onFinish:eN,initialValues:ey,layout:"vertical",children:[(0,t.jsx)(D.Form.Item,{label:"MCP Server Name",name:"server_name",rules:[{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(H.Input,{className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Alias",name:"alias",rules:[{validator:(e,t)=>eW(t)}],children:(0,t.jsx)(H.Input,{onChange:()=>A(!0),className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Description",name:"description",children:(0,t.jsx)(H.Input,{className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(e$,{value:z,onChange:U}),(0,t.jsx)(D.Form.Item,{label:"Transport Type",name:"transport",rules:[{required:!0}],children:(0,t.jsxs)(p.Select,{onChange:e=>{"stdio"===e?u.setFieldsValue({url:void 0,spec_path:void 0,auth_type:void 0,credentials:void 0,authorization_url:void 0,token_url:void 0,registration_url:void 0}):e===eo.TRANSPORT.OPENAPI?u.setFieldsValue({url:void 0,command:void 0,args:void 0,env_json:void 0,stdio_config:void 0}):u.setFieldsValue({spec_path:void 0,command:void 0,args:void 0,env_json:void 0,stdio_config:void 0})},children:[(0,t.jsx)(p.Select.Option,{value:"http",children:"Streamable HTTP (Recommended)"}),(0,t.jsx)(p.Select.Option,{value:"sse",children:"Server-Sent Events (SSE)"}),(0,t.jsx)(p.Select.Option,{value:"stdio",children:"Standard Input/Output (stdio)"}),(0,t.jsx)(p.Select.Option,{value:eo.TRANSPORT.OPENAPI,children:"OpenAPI Spec"})]})}),!V&&!$&&(0,t.jsx)(D.Form.Item,{label:"MCP Server URL",name:"url",rules:[{required:!0,message:"Please enter a server URL"},{validator:(e,t)=>eK(t)}],children:(0,t.jsx)(H.Input,{placeholder:"https://your-mcp-server.com",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),$&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OpenAPI Spec URL",(0,t.jsx)(g.Tooltip,{title:"URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"spec_path",rules:[{required:!0,message:"Please enter an OpenAPI spec URL"}],children:(0,t.jsx)(H.Input,{placeholder:"https://petstore3.swagger.io/api/v3/openapi.json",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!V&&(0,t.jsx)(D.Form.Item,{label:"Authentication",name:"auth_type",rules:[{required:!0}],children:(0,t.jsxs)(p.Select,{children:[(0,t.jsx)(p.Select.Option,{value:"none",children:"None"}),(0,t.jsx)(p.Select.Option,{value:"api_key",children:"API Key"}),(0,t.jsx)(p.Select.Option,{value:"bearer_token",children:"Bearer Token"}),(0,t.jsx)(p.Select.Option,{value:"token",children:"Token"}),(0,t.jsx)(p.Select.Option,{value:"basic",children:"Basic Auth"}),(0,t.jsx)(p.Select.Option,{value:"oauth2",children:"OAuth"}),(0,t.jsx)(p.Select.Option,{value:"aws_sigv4",children:"AWS SigV4 (Bedrock AgentCore MCPs)"})]})}),V&&(0,t.jsxs)("div",{className:"rounded-lg border border-gray-200 p-4 space-y-4",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Configure the stdio transport used to launch the MCP server process. You can either fill in the fields below or paste a JSON configuration."}),(0,t.jsx)(D.Form.Item,{label:"Command",name:"command",rules:[{required:!0,message:"Please enter a command for stdio transport"}],children:(0,t.jsx)(H.Input,{placeholder:"e.g., npx",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:"Args",name:"args",children:(0,t.jsx)(p.Select,{mode:"tags",size:"large",tokenSeparators:[","],placeholder:"Add args (press enter or comma)",className:"rounded-lg"})}),(0,t.jsx)(D.Form.Item,{label:"Environment (JSON object)",name:"env_json",rules:[{validator:(e,t)=>{if(!t)return Promise.resolve();try{let e=JSON.parse(t);if(e&&"object"==typeof e&&!Array.isArray(e))return Promise.resolve();return Promise.reject(Error("Env must be a JSON object"))}catch{return Promise.reject(Error("Please enter valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{rows:6,className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 font-mono text-sm",placeholder:`{ "KEY": "value" }`})}),(0,t.jsx)(eO,{isVisible:!0,required:!1})]}),!V&&K&&(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authentication Value",(0,t.jsx)(g.Tooltip,{title:"Token, password, or header value to send with each request for the selected auth type.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","auth_value"],rules:[{validator:(e,t)=>t&&"string"==typeof t&&""===t.trim()?Promise.reject(Error("Authentication value cannot be empty")):Promise.resolve()}],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter token or secret (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!V&&W&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Client ID (optional)",(0,t.jsx)(g.Tooltip,{title:"Provide only if your MCP server cannot handle dynamic client registration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","client_id"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter OAuth client ID (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Client Secret (optional)",(0,t.jsx)(g.Tooltip,{title:"Provide only if your MCP server cannot handle dynamic client registration.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","client_secret"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Enter OAuth client secret (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["OAuth Scopes (optional)",(0,t.jsx)(g.Tooltip,{title:"Add scopes to override the default scope list used for this MCP server.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","scopes"],children:(0,t.jsx)(p.Select,{mode:"tags",tokenSeparators:[","],placeholder:"Add scopes",className:"rounded-lg",size:"large"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Authorization URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the authorization endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"authorization_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/authorize",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the token endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/token",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Registration URL Override (optional)",(0,t.jsx)(g.Tooltip,{title:"Optional override for the dynamic client registration endpoint.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"registration_url",children:(0,t.jsx)(H.Input,{placeholder:"https://example.com/oauth/register",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),!G&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token Validation Rules (optional)",(0,t.jsx)(g.Tooltip,{title:'JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.',children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_validation_json",rules:[{validator:(e,t)=>{if(!t||""===t.trim())return Promise.resolve();try{return JSON.parse(t),Promise.resolve()}catch{return Promise.reject(Error("Must be valid JSON"))}}}],children:(0,t.jsx)(H.Input.TextArea,{placeholder:'{\n "organization": "my-org",\n "team.id": "123"\n}',rows:4,className:"font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["Token Storage TTL (seconds, optional)",(0,t.jsx)(g.Tooltip,{title:"How long to cache each user's OAuth access token in Redis before evicting it (regardless of the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:"token_storage_ttl_seconds",children:(0,t.jsx)(ec.InputNumber,{min:1,placeholder:"e.g. 3600",style:{width:"100%"},className:"rounded-lg"})})]}),(0,t.jsxs)("div",{className:"rounded-lg border border-dashed border-gray-300 p-4 space-y-2",children:[(0,t.jsx)("p",{className:"text-sm text-gray-600",children:"Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value."}),(0,t.jsx)(l.Button,{variant:"secondary",onClick:em,disabled:"authorizing"===eu||"exchanging"===eu,children:"authorizing"===eu?"Waiting for authorization...":"exchanging"===eu?"Exchanging authorization code...":"Authorize & Fetch Token"}),ex&&(0,t.jsx)("p",{className:"text-sm text-red-500",children:ex}),"success"===eu&&eh?.access_token&&(0,t.jsxs)("p",{className:"text-sm text-green-600",children:["Token fetched. Expires in ",eh.expires_in??"?"," seconds."]})]})]}),!V&&J&&(0,t.jsxs)(t.Fragment,{children:[(0,t.jsxs)("p",{className:"text-sm text-gray-500 mb-2",children:["For MCP servers hosted on AWS Bedrock AgentCore."," ",(0,t.jsx)("a",{href:"https://docs.litellm.ai/docs/mcp_aws_sigv4",target:"_blank",rel:"noopener noreferrer",className:"text-blue-500 hover:text-blue-700",children:"View docs →"})]}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Region",(0,t.jsx)(g.Tooltip,{title:"AWS region for SigV4 signing (e.g., us-east-1)",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_region_name"],rules:[],children:(0,t.jsx)(H.Input,{placeholder:"us-east-1 (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Service Name",(0,t.jsx)(g.Tooltip,{title:"AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_service_name"],children:(0,t.jsx)(H.Input,{placeholder:"bedrock-agentcore (leave blank to keep existing)",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Access Key ID",(0,t.jsx)(g.Tooltip,{title:"Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_access_key_id"],rules:[],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Secret Access Key",(0,t.jsx)(g.Tooltip,{title:"Optional. Required if AWS Access Key ID is provided.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_secret_access_key"],rules:[],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Token",(0,t.jsx)(g.Tooltip,{title:"Optional. Only needed for temporary STS credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_token"],children:(0,t.jsx)(H.Input.Password,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Role ARN",(0,t.jsx)(g.Tooltip,{title:"Optional. IAM role ARN to assume via STS before signing. If set, LiteLLM calls sts:AssumeRole to get temporary credentials.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_role_name"],children:(0,t.jsx)(H.Input,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})}),(0,t.jsx)(D.Form.Item,{label:(0,t.jsxs)("span",{className:"text-sm font-medium text-gray-700 flex items-center",children:["AWS Session Name",(0,t.jsx)(g.Tooltip,{title:"Optional. Session name for the AssumeRole call — appears in CloudTrail logs. Auto-generated if omitted.",children:(0,t.jsx)(en.InfoCircleOutlined,{className:"ml-2 text-blue-400 hover:text-blue-600 cursor-help"})})]}),name:["credentials","aws_session_name"],children:(0,t.jsx)(H.Input,{placeholder:"Leave blank to keep existing",className:"rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"})})]}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eR,{availableAccessGroups:m,mcpServer:e,searchValue:S,setSearchValue:T,getAccessGroupOptions:()=>{let e=m.map(e=>({value:e,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-green-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:e})]})}));return S&&!m.some(e=>e.toLowerCase().includes(S.toLowerCase()))&&e.push({value:S,label:(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)("div",{className:"w-2 h-2 bg-blue-500 rounded-full"}),(0,t.jsx)("span",{className:"font-medium",children:S}),(0,t.jsx)("span",{className:"text-gray-400 text-xs ml-1",children:"create new group"})]})}),e}})}),(0,t.jsx)("div",{className:"mt-6",children:(0,t.jsx)(eP,{accessToken:s,oauthAccessToken:Q,formValues:{server_id:e.server_id,server_name:et??e.server_name,url:X??e.url,spec_path:ee??e.spec_path,transport:q??e.transport,auth_type:es??e.auth_type,mcp_info:e.mcp_info,oauth_flow_type:ei??e.token_url?eo.OAUTH_FLOW.M2M:eo.OAUTH_FLOW.INTERACTIVE,static_headers:er??e.static_headers,credentials:el,authorization_url:ea??e.authorization_url,token_url:ei??e.token_url,registration_url:ed??e.registration_url},allowedTools:I,existingAllowedTools:e.allowed_tools||null,onAllowedToolsChange:P,toolNameToDisplayName:O,toolNameToDescription:F,onToolNameToDisplayNameChange:M,onToolNameToDescriptionChange:E})}),(0,t.jsxs)("div",{className:"flex justify-end gap-2",children:[(0,t.jsx)(eb.Button,{onClick:r,children:"Cancel"}),(0,t.jsx)(l.Button,{type:"submit",children:"Save Changes"})]})]})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)("div",{className:"space-y-6",children:[(0,t.jsx)(ef,{value:x,onChange:h,tools:f,disabled:y}),(0,t.jsxs)("div",{className:"flex justify-end gap-2",children:[(0,t.jsx)(eb.Button,{onClick:r,children:"Cancel"}),(0,t.jsx)(l.Button,{onClick:()=>u.submit(),children:"Save Changes"})]})]})})]})]})},tA=({costConfig:e})=>{let s=e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null,r=e?.tool_name_to_cost_per_query&&Object.keys(e.tool_name_to_cost_per_query).length>0;return s||r?(0,t.jsx)("div",{className:"mt-6 pt-6 border-t border-gray-200",children:(0,t.jsxs)("div",{className:"space-y-4",children:[s&&e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null&&(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"font-medium",children:"Default Cost per Query"}),(0,t.jsxs)("div",{className:"text-green-600 font-mono",children:["$",e.default_cost_per_query.toFixed(4)]})]}),r&&e?.tool_name_to_cost_per_query&&(0,t.jsxs)("div",{children:[(0,t.jsx)(d.Text,{className:"font-medium",children:"Tool-Specific Costs"}),(0,t.jsx)("div",{className:"mt-2 space-y-2",children:Object.entries(e.tool_name_to_cost_per_query).map(([e,s])=>null!=s&&(0,t.jsxs)("div",{className:"flex justify-between items-center p-3 bg-gray-50 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"font-medium",children:e}),(0,t.jsxs)(d.Text,{className:"text-green-600 font-mono",children:["$",s.toFixed(4)," per query"]})]},e))})]}),(0,t.jsxs)("div",{className:"mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg",children:[(0,t.jsx)(d.Text,{className:"text-blue-800 font-medium",children:"Cost Summary:"}),(0,t.jsxs)("div",{className:"mt-2 space-y-1",children:[s&&e?.default_cost_per_query!==void 0&&e?.default_cost_per_query!==null&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• Default cost: $",e.default_cost_per_query.toFixed(4)," per query"]}),r&&e?.tool_name_to_cost_per_query&&(0,t.jsxs)(d.Text,{className:"text-blue-700",children:["• ",Object.keys(e.tool_name_to_cost_per_query).length," tool(s) with custom pricing"]})]})]})]})}):(0,t.jsx)("div",{className:"mt-6 pt-6 border-t border-gray-200",children:(0,t.jsx)("div",{className:"space-y-4",children:(0,t.jsx)("div",{className:"p-4 bg-gray-50 border border-gray-200 rounded-lg",children:(0,t.jsx)(d.Text,{className:"text-gray-600",children:"No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."})})})})},tI=({mcpServer:e,onBack:s,isEditing:r,isProxyAdmin:u,accessToken:x,userRole:h,userID:p,availableAccessGroups:g})=>{let[f,j]=(0,b.useState)(r),[y,v]=(0,b.useState)(!1),[N,_]=(0,b.useState)({}),[w,S]=(0,b.useState)(0),C=e.url??"",{maskedUrl:T,hasToken:A}=C?eH(C):{maskedUrl:"—",hasToken:!1},I=(e,t)=>e?A?t?e:T:e:"—",P=async(e,t)=>{await (0,tr.copyToClipboard)(e)&&(_(e=>({...e,[t]:!0})),setTimeout(()=>{_(e=>({...e,[t]:!1}))},2e3))},O=e=>{let s=e.toUpperCase();return(0,t.jsx)("span",{className:"inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:s})},M=e=>(0,t.jsx)("span",{className:"inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:e});return(0,t.jsxs)("div",{className:"p-4 max-w-full",children:[(0,t.jsxs)("div",{className:"mb-6",children:[(0,t.jsx)(l.Button,{icon:tx.ArrowLeftIcon,variant:"light",className:"mb-4",onClick:s,children:"Back to All Servers"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(m.Title,{className:"text-2xl",children:e.server_name||e.alias||"Unnamed Server"}),(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:N["mcp-server_name"]?(0,t.jsx)(k.CheckIcon,{size:12}):(0,t.jsx)(e6.CopyIcon,{size:12}),onClick:()=>P(e.server_name||e.alias,"mcp-server_name"),className:`transition-all duration-200 ${N["mcp-server_name"]?"text-green-600 bg-green-50 border-green-200":"text-gray-400 hover:text-gray-600 hover:bg-gray-100"}`}),e.alias&&e.server_name&&e.alias!==e.server_name&&(0,t.jsx)("span",{className:"ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600 border border-gray-200 font-mono",children:e.alias})]}),(0,t.jsxs)("div",{className:"flex items-center gap-1.5 mt-1",children:[(0,t.jsx)(d.Text,{className:"text-gray-400 font-mono text-xs",children:e.server_id}),(0,t.jsx)(eb.Button,{type:"text",size:"small",icon:N["mcp-server-id"]?(0,t.jsx)(k.CheckIcon,{size:10}):(0,t.jsx)(e6.CopyIcon,{size:10}),onClick:()=>P(e.server_id,"mcp-server-id"),className:`transition-all duration-200 ${N["mcp-server-id"]?"text-green-600 bg-green-50 border-green-200":"text-gray-300 hover:text-gray-500 hover:bg-gray-50"}`})]}),e.description&&(0,t.jsx)(d.Text,{className:"text-gray-500 mt-2",children:e.description})]}),(0,t.jsxs)(n.TabGroup,{index:w,onIndexChange:S,children:[(0,t.jsx)(i.TabList,{className:"mb-4",children:[(0,t.jsx)(a.Tab,{children:"Overview"},"overview"),(0,t.jsx)(a.Tab,{children:"MCP Tools"},"tools"),...u?[(0,t.jsx)(a.Tab,{children:"Settings"},"settings")]:[]]}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsxs)(o.TabPanel,{children:[(0,t.jsxs)(tg.Grid,{numItems:1,numItemsSm:2,numItemsLg:3,className:"gap-4",children:[(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Transport"}),(0,t.jsx)("div",{className:"mt-3",children:O((0,eo.handleTransport)(e.transport??void 0,e.spec_path??void 0))})]}),(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Authentication"}),(0,t.jsx)("div",{className:"mt-3",children:M((0,eo.handleAuth)(e.auth_type??void 0))})]}),(0,t.jsxs)(eg.Card,{className:"p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Host URL"}),(0,t.jsxs)("div",{className:"mt-3 flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"break-all overflow-wrap-anywhere font-mono text-sm",children:I(e.url,y)}),A&&u&&(0,t.jsx)("button",{onClick:()=>v(!y),className:"p-1 hover:bg-gray-100 rounded flex-shrink-0",children:(0,t.jsx)(tc.Icon,{icon:y?tp:th.EyeIcon,size:"sm",className:"text-gray-500"})})]})]})]}),(0,t.jsxs)(eg.Card,{className:"mt-4 p-4",children:[(0,t.jsx)(d.Text,{className:"text-xs font-medium text-gray-500 uppercase tracking-wide",children:"Cost Configuration"}),(0,t.jsx)("div",{className:"mt-3",children:(0,t.jsx)(tA,{costConfig:e.mcp_info?.mcp_server_cost_info})})]})]}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tw,{serverId:e.server_id,accessToken:x,auth_type:e.auth_type,userRole:h,userID:p,serverAlias:e.alias,extraHeaders:e.extra_headers})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsxs)(eg.Card,{children:[(0,t.jsxs)("div",{className:"flex justify-between items-center mb-4",children:[(0,t.jsx)(m.Title,{children:"MCP Server Settings"}),f?null:(0,t.jsx)(l.Button,{variant:"light",onClick:()=>j(!0),children:"Edit Settings"})]}),f?(0,t.jsx)(tk,{mcpServer:e,accessToken:x,onCancel:()=>j(!1),onSuccess:e=>{j(!1),s()},availableAccessGroups:g}):(0,t.jsxs)("div",{className:"divide-y divide-gray-100",children:[(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Server Name"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.server_name||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Alias"}),(0,t.jsx)("div",{className:"col-span-2 text-sm font-mono text-gray-900",children:e.alias||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Description"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.description||(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"URL"}),(0,t.jsxs)("div",{className:"col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2",children:[I(e.url,y),A&&(0,t.jsx)("button",{onClick:()=>v(!y),className:"p-1 hover:bg-gray-100 rounded flex-shrink-0",children:(0,t.jsx)(tc.Icon,{icon:y?tp:th.EyeIcon,size:"sm",className:"text-gray-500"})})]})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Transport"}),(0,t.jsx)("div",{className:"col-span-2",children:O((0,eo.handleTransport)(e.transport,e.spec_path))})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Authentication"}),(0,t.jsx)("div",{className:"col-span-2",children:M((0,eo.handleAuth)(e.auth_type))})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Extra Headers"}),(0,t.jsx)("div",{className:"col-span-2 text-sm text-gray-900",children:e.extra_headers&&e.extra_headers.length>0?e.extra_headers.join(", "):(0,t.jsx)("span",{className:"text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Allow All Keys"}),(0,t.jsx)("div",{className:"col-span-2",children:e.allow_all_keys?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Enabled"]}):(0,t.jsx)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium",children:"Disabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Network Access"}),(0,t.jsx)("div",{className:"col-span-2",children:e.available_on_public_internet?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Public"]}):(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-orange-500"}),"Internal only"]})})]}),"oauth2"===(0,eo.handleAuth)(e.auth_type)&&(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Delegate Auth to Upstream"}),(0,t.jsx)("div",{className:"col-span-2",children:e.delegate_auth_to_upstream?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Enabled (PKCE passthrough)"]}):(0,t.jsx)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium",children:"Disabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Access Groups"}),(0,t.jsx)("div",{className:"col-span-2",children:e.mcp_access_groups&&e.mcp_access_groups.length>0?(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5",children:e.mcp_access_groups.map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200",children:"string"==typeof e?e:e?.name??""},s))}):(0,t.jsx)("span",{className:"text-sm text-gray-400",children:"—"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Allowed Tools"}),(0,t.jsx)("div",{className:"col-span-2",children:e.allowed_tools&&e.allowed_tools.length>0?(0,t.jsx)("div",{className:"flex flex-wrap gap-1.5",children:e.allowed_tools.map((e,s)=>(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-mono font-medium px-2 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200",children:e},s))}):(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-green-50 text-green-700 border border-green-200",children:"All tools enabled"})})]}),(0,t.jsxs)("div",{className:"py-3 grid grid-cols-3 gap-4",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-500",children:"Cost"}),(0,t.jsx)("div",{className:"col-span-2",children:(0,t.jsx)(tA,{costConfig:e.mcp_info?.mcp_server_cost_info})})]})]})]})})]})]})]})},tP=(0,N.createQueryKeys)("mcpSemanticFilterSettings"),tO=(0,N.createQueryKeys)("mcpSemanticFilterSettings");var tM=e.i(178654),tF=e.i(621192),tE=e.i(981339),tL=e.i(850627),tR=e.i(987432),tz=e.i(689020),tU=e.i(245094),tB=e.i(788191),tq=e.i(653496),tV=e.i(992619);function t$({accessToken:e,testQuery:s,setTestQuery:r,testModel:l,setTestModel:a,isTesting:n,onTest:i,filterEnabled:o,testResult:c,curlCommand:d}){return(0,t.jsx)(e4.Card,{title:"Test Configuration",style:{marginBottom:16},children:(0,t.jsx)(tq.Tabs,{defaultActiveKey:"test",items:[{key:"test",label:"Test",children:(0,t.jsxs)(eM.Space,{direction:"vertical",style:{width:"100%"},size:"large",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:[(0,t.jsx)(tB.PlayCircleOutlined,{})," Test Query"]}),(0,t.jsx)(H.Input.TextArea,{placeholder:"Enter a test query to see which tools would be selected...",value:s,onChange:e=>r(e.target.value),rows:4,disabled:n})]}),(0,t.jsx)("div",{children:(0,t.jsx)(tV.default,{accessToken:e||"",value:l,onChange:a,disabled:n,showLabel:!0,labelText:"Select Model"})}),(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tB.PlayCircleOutlined,{}),onClick:i,loading:n,disabled:!s||!l||!o,block:!0,children:"Test Filter"}),!o&&(0,t.jsx)(ej.Alert,{type:"warning",message:"Semantic filtering is disabled",description:"Enable semantic filtering and save settings to test the filter.",showIcon:!0}),c&&(0,t.jsxs)("div",{children:[(0,t.jsx)(f.Typography.Title,{level:5,children:"Results"}),(0,t.jsx)(ej.Alert,{type:"success",message:`${c.selectedTools} tools selected`,description:`Filtered from ${c.totalTools} available tools`,showIcon:!0,style:{marginBottom:16}}),(0,t.jsxs)("div",{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:"Selected Tools:"}),(0,t.jsx)("ul",{style:{paddingLeft:20,margin:0},children:c.tools.map((e,s)=>(0,t.jsx)("li",{style:{marginBottom:4},children:(0,t.jsx)(f.Typography.Text,{children:e})},s))})]})]})]})},{key:"api",label:"API Usage",children:(0,t.jsxs)("div",{children:[(0,t.jsxs)(eM.Space,{style:{marginBottom:8},children:[(0,t.jsx)(tU.CodeOutlined,{}),(0,t.jsx)(f.Typography.Text,{strong:!0,children:"API Usage"})]}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block",marginBottom:8},children:"Use this curl command to test the semantic filter with your current configuration."}),(0,t.jsx)(f.Typography.Text,{strong:!0,style:{display:"block",marginBottom:8},children:"Response headers to check:"}),(0,t.jsxs)("ul",{style:{paddingLeft:20,margin:"0 0 12px 0"},children:[(0,t.jsxs)("li",{children:[(0,t.jsx)(f.Typography.Text,{children:"x-litellm-semantic-filter: shows total tools → selected tools"}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block"},children:"Example: 10→3"})]}),(0,t.jsxs)("li",{children:[(0,t.jsx)(f.Typography.Text,{children:"x-litellm-semantic-filter-tools: CSV of selected tool names"}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block"},children:"Example: wikipedia-fetch,github-search,slack-post"})]})]}),(0,t.jsx)("pre",{style:{background:"#f5f5f5",padding:12,borderRadius:4,overflow:"auto",fontSize:12,margin:0},children:d})]})}]})})}let tD=async({accessToken:e,testModel:t,testQuery:s,setIsTesting:r,setTestResult:l})=>{if(!s||!t||!e)return void C.default.error("Please enter a query and select a model");r(!0),l(null);try{let{headers:r}=await (0,_.testMCPSemanticFilter)(e,t,s),a=(e=>{if(!e.filter)return null;let[t,s]=e.filter.split("->").map(Number);return{totalTools:t,selectedTools:s,tools:e.tools?e.tools.split(",").map(e=>e.trim()):[]}})(r);if(!a)return void C.default.warning("Semantic filter is not enabled or no tools were filtered");l(a),C.default.success("Semantic filter test completed successfully")}catch(e){console.error("Test failed:",e),C.default.error("Failed to test semantic filter")}finally{r(!1)}};function tH({accessToken:e}){var s;let l,{data:a,isLoading:n,isError:i,error:o}=(()=>{let{accessToken:e}=(0,w.default)();return(0,y.useQuery)({queryKey:tP.list({}),queryFn:async()=>await (0,_.getMCPSemanticFilterSettings)(e),enabled:!!e,staleTime:36e5,gcTime:36e5})})(),{mutate:c,isPending:d,error:m}=(s=e||"",l=(0,v.useQueryClient)(),(0,tf.useMutation)({mutationFn:async e=>{if(!s)throw Error("Access token is required");return(0,_.updateMCPSemanticFilterSettings)(s,e)},onSuccess:()=>{l.invalidateQueries({queryKey:tO.all})}})),[u]=D.Form.useForm(),[x,h]=(0,b.useState)(!1),[j,N]=(0,b.useState)(!1),[S,T]=(0,b.useState)([]),[k,A]=(0,b.useState)(!0),[I,P]=(0,b.useState)(""),[O,M]=(0,b.useState)("gpt-4o"),[F,E]=(0,b.useState)(null),[L,R]=(0,b.useState)(!1),z=a?.field_schema,U=a?.values??{};(0,b.useEffect)(()=>{(async()=>{if(e)try{A(!0);let t=(await (0,tz.fetchAvailableModels)(e)).filter(e=>"embedding"===e.mode);T(t)}catch(e){console.error("Error fetching embedding models:",e)}finally{A(!1)}})()},[e]),(0,b.useEffect)(()=>{U&&(u.setFieldsValue({enabled:U.enabled??!1,embedding_model:U.embedding_model??"text-embedding-3-small",top_k:U.top_k??10,similarity_threshold:U.similarity_threshold??.3}),N(!1))},[U,u]);let B=async()=>{try{let e=await u.validateFields();c(e,{onSuccess:()=>{N(!1),h(!0),setTimeout(()=>h(!1),3e3),C.default.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.")},onError:e=>{C.default.fromBackend(e)}})}catch(e){console.error("Form validation failed:",e)}},q=async()=>{e&&await tD({accessToken:e,testModel:O,testQuery:I,setIsTesting:R,setTestResult:E})};return e?(0,t.jsx)("div",{style:{width:"100%"},children:n?(0,t.jsx)(tE.Skeleton,{active:!0}):i?(0,t.jsx)(ej.Alert,{type:"error",message:"Could not load MCP Semantic Filter settings",description:o instanceof Error?o.message:void 0,style:{marginBottom:24}}):(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(ej.Alert,{type:"info",message:"Semantic Tool Filtering",description:"Filter MCP tools semantically based on query relevance. This reduces context window size and improves tool selection accuracy. Click 'Save Settings' to apply changes across all pods (takes effect within 10 seconds).",showIcon:!0,style:{marginBottom:24}}),x&&(0,t.jsx)(ej.Alert,{type:"success",message:"Settings saved successfully",icon:(0,t.jsx)(ey.CheckCircleOutlined,{}),showIcon:!0,closable:!0,style:{marginBottom:16}}),m&&(0,t.jsx)(ej.Alert,{type:"error",message:"Could not update settings",description:m instanceof Error?m.message:void 0,style:{marginBottom:16}}),(0,t.jsxs)(tF.Row,{gutter:24,children:[(0,t.jsx)(tM.Col,{xs:24,lg:12,children:(0,t.jsxs)(D.Form,{form:u,layout:"vertical",disabled:d,onValuesChange:()=>{N(!0)},children:[(0,t.jsxs)(e4.Card,{style:{marginBottom:16},children:[(0,t.jsx)(D.Form.Item,{name:"enabled",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Enable Semantic Filtering"}),(0,t.jsx)(g.Tooltip,{title:"When enabled, only the most relevant MCP tools will be included in requests based on semantic similarity",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),valuePropName:"checked",children:(0,t.jsx)(el.Switch,{disabled:d})}),(0,t.jsx)(f.Typography.Text,{type:"secondary",style:{display:"block",marginTop:-16,marginBottom:16},children:z?.properties?.enabled?.description})]}),(0,t.jsxs)(e4.Card,{title:"Configuration",style:{marginBottom:16},children:[(0,t.jsx)(D.Form.Item,{name:"embedding_model",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Embedding Model"}),(0,t.jsx)(g.Tooltip,{title:"The model used to generate embeddings for semantic matching",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(p.Select,{options:S.map(e=>({label:e.model_group,value:e.model_group})),placeholder:k?"Loading models...":"Select embedding model",showSearch:!0,disabled:d||k,loading:k,notFoundContent:k?"Loading...":"No embedding models available"})}),(0,t.jsx)(D.Form.Item,{name:"top_k",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Top K Results"}),(0,t.jsx)(g.Tooltip,{title:"Maximum number of tools to return after filtering",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(ec.InputNumber,{min:1,max:100,style:{width:"100%"},disabled:d})}),(0,t.jsx)(D.Form.Item,{name:"similarity_threshold",label:(0,t.jsxs)(eM.Space,{children:[(0,t.jsx)(f.Typography.Text,{strong:!0,children:"Similarity Threshold"}),(0,t.jsx)(g.Tooltip,{title:"Minimum similarity score (0-1) for a tool to be included",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{color:"#8c8c8c"}})})]}),children:(0,t.jsx)(tL.Slider,{min:0,max:1,step:.05,marks:{0:"0.0",.3:"0.3",.5:"0.5",.7:"0.7",1:"1.0"},disabled:d})})]}),(0,t.jsx)("div",{style:{display:"flex",justifyContent:"flex-end",gap:8},children:(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tR.SaveOutlined,{}),onClick:B,loading:d,disabled:!j,children:"Save Settings"})})]})}),(0,t.jsx)(tM.Col,{xs:24,lg:12,children:(0,t.jsx)(t$,{accessToken:e,testQuery:I,setTestQuery:P,testModel:O,setTestModel:M,isTesting:L,onTest:q,filterEnabled:!!U.enabled,testResult:F,curlCommand:`curl --location 'http://localhost:4000/v1/responses' \\ +>>>>>>>> origin/litellm_internal_staging:litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js --header 'Content-Type: application/json' \\ --header 'Authorization: Bearer sk-1234' \\ --data '{ @@ -88,4 +108,8 @@ } ], "tool_choice": "required" -}'`})})]})]})}):(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Please log in to configure semantic filter settings."})}var tK=e.i(262218);let{Text:tW}=f.Typography,tJ=({accessToken:e})=>{let s,[r,l]=(0,b.useState)(!0),[a,n]=(0,b.useState)(!1),[i,o]=(0,b.useState)([]),[c,d]=(0,b.useState)(null);(0,b.useEffect)(()=>{m(),u()},[e]);let m=async()=>{if(e){l(!0);try{for(let t of(await (0,_.getGeneralSettingsCall)(e)))"mcp_internal_ip_ranges"===t.field_name&&t.field_value&&o(t.field_value)}catch(e){console.error("Failed to load MCP network settings:",e)}finally{l(!1)}}},u=async()=>{if(!e)return;let t=await (0,_.fetchMCPClientIp)(e);t&&d(t)},x=async()=>{if(e){n(!0);try{i.length>0?await (0,_.updateConfigFieldSetting)(e,"mcp_internal_ip_ranges",i):await (0,_.deleteConfigFieldSetting)(e,"mcp_internal_ip_ranges")}catch(e){console.error("Failed to save MCP network settings:",e)}finally{n(!1)}}};if(r)return(0,t.jsx)("div",{className:"flex justify-center py-12",children:(0,t.jsx)(W.Spin,{})});let h=c?4!==(s=c.split(".")).length?c+"/32":`${s[0]}.${s[1]}.${s[2]}.0/24`:null;return(0,t.jsxs)("div",{className:"space-y-6 p-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(tW,{className:"text-lg font-semibold",children:"Private IP Ranges"}),(0,t.jsx)("p",{className:"text-sm text-gray-500 mt-1",children:'Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Callers from any other IP can only see servers marked "Available on Public Internet".'})]}),(0,t.jsxs)(e4.Card,{children:[c&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 rounded-lg",children:[(0,t.jsxs)(tW,{className:"text-sm text-blue-700",children:["Your current IP: ",(0,t.jsx)("span",{className:"font-mono font-medium",children:c})]}),h&&!i.includes(h)&&(0,t.jsxs)("div",{className:"mt-1",children:[(0,t.jsx)(tW,{className:"text-sm text-blue-600",children:"Suggested range: "}),(0,t.jsx)(tK.Tag,{className:"cursor-pointer font-mono",color:"blue",icon:(0,t.jsx)(eE.PlusOutlined,{}),onClick:()=>{!i.includes(h)&&o([...i,h])},children:h})]})]}),(0,t.jsx)("div",{className:"flex items-center mb-2",children:(0,t.jsx)(tW,{className:"font-medium",children:"Your Private Network Ranges"})}),(0,t.jsx)(p.Select,{mode:"tags",value:i,onChange:o,placeholder:"Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8",tokenSeparators:[","],className:"w-full",size:"large",allowClear:!0}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-2",children:"Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used."})]}),(0,t.jsx)("div",{className:"flex justify-end",children:(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tR.SaveOutlined,{}),onClick:x,loading:a,children:"Save"})})]})},{Search:tY}=H.Input,{Text:tG}=f.Typography,tQ=["#3B82F6","#10B981","#F59E0B","#EF4444","#8B5CF6","#EC4899","#06B6D4","#84CC16"],tZ=({isVisible:e,onClose:s,onSelectServer:r,onCustomServer:l,accessToken:a})=>{let[n,i]=(0,b.useState)([]),[o,c]=(0,b.useState)([]),[d,m]=(0,b.useState)(!1),[u,x]=(0,b.useState)(null),[p,g]=(0,b.useState)(""),[f,j]=(0,b.useState)("All");(0,b.useEffect)(()=>{e&&a&&(m(!0),x(null),(0,_.fetchDiscoverableMCPServers)(a).then(e=>{i(e.servers||[]),c(e.categories||[])}).catch(e=>{x(e.message||"Failed to load MCP servers")}).finally(()=>{m(!1)}))},[e,a]),(0,b.useEffect)(()=>{e&&(g(""),j("All"))},[e]);let y=(0,b.useMemo)(()=>{let e=n;if("All"!==f&&(e=e.filter(e=>e.category===f)),p.trim()){let t=p.toLowerCase();e=e.filter(e=>e.name.toLowerCase().includes(t)||e.title.toLowerCase().includes(t)||e.description.toLowerCase().includes(t))}return e},[n,f,p]),v=(0,b.useMemo)(()=>{let e={};for(let t of y){let s=t.category||"Other";e[s]||(e[s]=[]),e[s].push(t)}return e},[y]);return(0,t.jsxs)(h.Modal,{title:(0,t.jsxs)("div",{className:"flex items-center justify-between pb-4 border-b border-gray-100",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[(0,t.jsx)("img",{src:eZ,alt:"MCP Logo",className:"w-8 h-8 object-contain",style:{height:"20px",width:"20px",marginRight:"8px",objectFit:"contain"}}),(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:"Add MCP Server"})]}),(0,t.jsx)("button",{onClick:l,className:"text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium",children:"+ Custom Server"})]}),open:e,onCancel:s,footer:null,width:1e3,className:"top-8",styles:{body:{padding:"24px",maxHeight:"70vh",overflowY:"auto"},header:{padding:"24px 24px 0 24px",border:"none"}},children:[(0,t.jsx)("div",{style:{display:"flex",gap:6,flexWrap:"wrap",marginBottom:12},children:["All",...o].map(e=>{let s=f===e;return(0,t.jsx)("button",{onClick:()=>j(e),style:{padding:"4px 12px",borderRadius:4,border:s?"1px solid #111827":"1px solid #e5e7eb",background:s?"#111827":"#fff",color:s?"#fff":"#4b5563",cursor:"pointer",fontSize:12,fontWeight:s?500:400,lineHeight:"20px"},children:e},e)})}),(0,t.jsx)(tY,{placeholder:"Search servers...",value:p,onChange:e=>g(e.target.value),style:{marginBottom:16},allowClear:!0}),d&&(0,t.jsx)("div",{style:{display:"flex",flexDirection:"column",gap:4},children:Array.from({length:8}).map((e,s)=>(0,t.jsx)("div",{style:{height:36,borderRadius:6,background:"#f9fafb"}},s))}),u&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["Failed to load servers: ",u]})}),!d&&!u&&0===y.length&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["No servers found."," ",(0,t.jsx)("a",{onClick:l,style:{color:"#2563eb",cursor:"pointer"},children:"Add a custom server"})]})}),!d&&!u&&Object.entries(v).map(([e,s])=>(0,t.jsxs)("div",{style:{marginBottom:16},children:[(0,t.jsx)("div",{style:{fontSize:11,fontWeight:500,color:"#9ca3af",textTransform:"uppercase",letterSpacing:"0.05em",padding:"6px 0",borderBottom:"1px solid #f3f4f6",marginBottom:4},children:e}),(0,t.jsx)("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"0 16px"},children:s.map(e=>{var s;let l,a,n=(l=(s=e.title||e.name).charAt(0).toUpperCase(),a=s.split("").reduce((e,t)=>e+t.charCodeAt(0),0)%tQ.length,{initial:l,backgroundColor:tQ[a]});return(0,t.jsxs)("div",{onClick:()=>r(e),style:{display:"flex",alignItems:"center",padding:"8px 10px",borderRadius:6,cursor:"pointer",transition:"background 0.1s ease"},onMouseEnter:e=>{e.currentTarget.style.background="#f9fafb"},onMouseLeave:e=>{e.currentTarget.style.background="transparent"},children:[e.icon_url?(0,t.jsx)("img",{src:e.icon_url,alt:e.title,style:{width:20,height:20,objectFit:"contain",flexShrink:0,marginRight:12},onError:e=>{let t=e.currentTarget;t.style.display="none";let s=t.nextElementSibling;s&&(s.style.display="flex")}}):null,(0,t.jsx)("div",{style:{width:20,height:20,borderRadius:4,backgroundColor:n.backgroundColor,color:"#fff",display:e.icon_url?"none":"flex",alignItems:"center",justifyContent:"center",fontWeight:600,fontSize:11,flexShrink:0,marginRight:12},children:n.initial}),(0,t.jsx)("span",{style:{fontSize:14,fontWeight:400,color:"#111827",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e.title||e.name}),(0,t.jsx)("span",{style:{color:"#d1d5db",fontSize:14,flexShrink:0,marginLeft:8},children:"›"})]},e.name)})})]},e))]})};var tX=e.i(611052);let{Text:t0,Title:t2}=f.Typography,{Option:t1}=p.Select;e.s(["MCPServers",0,({accessToken:e,userRole:f,userID:N})=>{let{data:T,isLoading:k,refetch:A}=(0,j.useMCPServers)(),{data:I,isLoading:P,recheckServerHealth:O,recheckingServerIds:M}=(()=>{let{accessToken:e}=(0,w.default)(),t=(0,v.useQueryClient)(),[s,r]=(0,b.useState)(new Set),l=(0,y.useQuery)({queryKey:S.lists(),queryFn:async()=>await (0,_.fetchMCPServerHealth)(e),enabled:!!e,refetchInterval:3e4}),a=(0,b.useCallback)(async s=>{if(e){r(e=>new Set(e).add(s));try{let r=await (0,_.fetchMCPServerHealth)(e,[s]);t.setQueriesData({queryKey:S.lists()},e=>e?e.map(e=>r.find(t=>t.server_id===e.server_id)??e):r)}finally{r(e=>{let t=new Set(e);return t.delete(s),t})}}},[e,t]);return{...l,recheckServerHealth:a,recheckingServerIds:s}})(),F=(0,b.useMemo)(()=>{if(!T)return[];if(!I)return T;let e=new Map(I.map(e=>[e.server_id,e.status]));return T.map(t=>{let s=e.get(t.server_id);return{...t,status:s||t.status}})},[T,I]),[E,L]=(0,b.useState)(null),[R,z]=(0,b.useState)(!1),[U,B]=(0,b.useState)(null),[q,V]=(0,b.useState)(!1),[D,H]=(0,b.useState)("all"),[K,W]=(0,b.useState)("all"),[J,Y]=(0,b.useState)([]),[Q,X]=(0,b.useState)(!1),[ee,et]=(0,b.useState)(!1),[es,el]=(0,b.useState)(null),[ea,en]=(0,b.useState)(!1),[ei,eo]=(0,b.useState)(null),ec="Internal User"===f;(0,b.useEffect)(()=>{try{let e=(0,eG.getSecureItem)("litellm-mcp-oauth-edit-state");if(!e)return;let t=JSON.parse(e);t?.serverId&&(B(t.serverId),V(!0))}catch(e){console.error("Failed to restore MCP edit view state",e)}},[]);let ed=b.default.useMemo(()=>{if(!F)return[];let e=new Set,t=[];return F.forEach(s=>{s.teams&&s.teams.forEach(s=>{let r=s.team_id;e.has(r)||(e.add(r),t.push(s))})}),t},[F]),em=b.default.useMemo(()=>F?Array.from(new Set(F.flatMap(e=>e.mcp_access_groups).filter(e=>null!=e))):[],[F]),eu=(0,b.useCallback)((e,t)=>{if(!F)return Y([]);let s=F;"personal"===e?Y([]):("all"!==e&&(s=s.filter(t=>t.teams?.some(t=>t.team_id===e))),"all"!==t&&(s=s.filter(e=>e.mcp_access_groups?.some(e=>"string"==typeof e?e===t:e&&e.name===t))),Y([...s].sort((e,t)=>e.created_at||t.created_at?e.created_at?t.created_at?new Date(t.created_at).getTime()-new Date(e.created_at).getTime():-1:1:0)))},[F]);(0,b.useEffect)(()=>{eu(D,K)},[F,D,K,eu]);let ex=b.default.useMemo(()=>{let e,s,r,l;return e=e=>{B(e),V(!1)},s=e=>{B(e),V(!0)},r=eh,l=e=>eo(e),[{accessorKey:"server_id",header:"Server ID",enableSorting:!0,cell:({row:s})=>(0,t.jsxs)("button",{onClick:()=>e(s.original.server_id),className:"font-mono text-blue-600 bg-blue-50 hover:bg-blue-100 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 text-left truncate whitespace-nowrap cursor-pointer max-w-[15ch] transition-colors",children:[s.original.server_id.slice(0,7),"..."]})},{accessorKey:"server_name",header:"Name",enableSorting:!0,cell:({row:e})=>{let s=e.original.mcp_info?.logo_url,r=e.original.server_name;return(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[s?(0,t.jsx)("img",{src:s,alt:`${r??"MCP"} logo`,className:"h-5 w-5 rounded object-contain flex-shrink-0",onError:e=>{e.target.style.display="none"}}):null,(0,t.jsx)("span",{children:r})]})}},{accessorKey:"alias",header:"Alias",enableSorting:!0},{id:"url",header:"URL",cell:({row:e})=>{let s=e.original.url;if(!s)return(0,t.jsx)("span",{className:"text-gray-400",children:"—"});let{maskedUrl:r}=eH(s);return(0,t.jsx)("span",{className:"font-mono text-sm",children:r})}},{accessorKey:"transport",header:"Transport",enableSorting:!0,cell:({row:e})=>{let s=e.original.transport||"http",r=(e.original.spec_path&&"stdio"!==s?"OPENAPI":s).toUpperCase();return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:r})}},{accessorKey:"auth_type",header:"Auth Type",enableSorting:!0,cell:({getValue:e})=>{let s=e()||"none";return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:s})}},{id:"health_status",header:"Health Status",cell:({row:e})=>(0,t.jsx)(tu,{server:e.original,isLoadingHealth:P,isRechecking:M?.has(e.original.server_id),onRecheck:O})},{id:"mcp_access_groups",header:"Access Groups",cell:({row:e})=>{let s=e.original.mcp_access_groups;if(Array.isArray(s)&&s.length>0&&"string"==typeof s[0]){let e=s.join(", ");return(0,t.jsx)(g.Tooltip,{title:e,children:(0,t.jsxs)("div",{className:"flex items-center gap-1 max-w-[200px]",children:[(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[140px]",children:s[0]}),s.length>1&&(0,t.jsxs)("span",{className:"text-xs text-gray-400 font-medium",children:["+",s.length-1]})]})})}return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"})}},{id:"available_on_public_internet",header:"Network Access",cell:({row:e})=>e.original.available_on_public_internet?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Public"]}):(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-orange-500"}),"Internal"]})},{header:"Created",accessorKey:"created_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.created_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.created_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{header:"Updated",accessorKey:"updated_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.updated_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.updated_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{id:"byok_credential",header:"Credential",cell:({row:e})=>{let s=e.original;return s.is_byok?s.has_user_credential?(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200",children:[(0,t.jsx)(tm.CheckOutlined,{style:{fontSize:10}})," Connected"]}),l&&(0,t.jsx)("button",{className:"text-xs text-gray-400 hover:text-blue-600 transition-colors",onClick:()=>l(s),children:"Update"})]}):l?(0,t.jsx)("button",{className:"text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md font-medium transition-colors shadow-sm",onClick:()=>l(s),children:"Connect"}):null:(0,t.jsx)("span",{className:"text-gray-300 text-xs",children:"—"})}},{id:"actions",header:"Actions",cell:({row:e})=>(0,t.jsxs)("div",{className:"flex items-center gap-1",children:[(0,t.jsx)(g.Tooltip,{title:"Edit",children:(0,t.jsx)("button",{onClick:()=>s(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:td.PencilAltIcon,size:"sm"})})}),(0,t.jsx)(g.Tooltip,{title:"Delete",children:(0,t.jsx)("button",{onClick:()=>r(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:G.TrashIcon,size:"sm"})})})]})}]},[f,P,O,M]);function eh(e){L(e),z(!0)}let ep=async()=>{if(null!=E&&null!=e)try{en(!0),await (0,_.deleteMCPServer)(e,E),C.default.success("Deleted MCP Server successfully"),A()}catch(e){console.error("Error deleting the mcp server:",e)}finally{en(!1),z(!1),L(null)}},eg=E?(T||[]).find(e=>e.server_id===E):null,ef=b.default.useMemo(()=>J.find(e=>e.server_id===U)||{server_id:"",server_name:"",alias:"",url:"",transport:"",auth_type:"",created_at:"",created_by:"",updated_at:"",updated_by:""},[J,U]),eb=b.default.useCallback(()=>{V(!1),B(null),A()},[A]);return e&&f&&N?(0,t.jsxs)("div",{className:"w-full h-full p-6",children:[(0,t.jsx)(h.Modal,{open:R,title:"Delete MCP Server?",onOk:ep,okText:ea?"Deleting...":"Delete",onCancel:()=>{z(!1),L(null)},cancelText:"Cancel",cancelButtonProps:{disabled:ea},okButtonProps:{danger:!0},confirmLoading:ea,children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)(t0,{className:"text-gray-600",children:"This action is permanent and cannot be undone. All associated configurations will be removed."}),eg&&(0,t.jsx)("div",{className:"mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200",children:(0,t.jsxs)(x.Descriptions,{column:1,size:"small",colon:!1,children:[eg.server_name&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"Name"}),children:(0,t.jsx)(t0,{strong:!0,className:"text-sm",children:eg.server_name})}),(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"ID"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs",children:eg.server_id})}),eg.url&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"URL"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs break-all",children:eg.url})})]})})]})}),(0,t.jsx)(e5,{userRole:f,accessToken:e,onCreateSuccess:e=>{Y(t=>[...t,e]),X(!1),A()},isModalVisible:Q,setModalVisible:X,availableAccessGroups:em,prefillData:es,onBackToDiscovery:()=>{X(!1),el(null),et(!0)}}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-3",children:[(0,t.jsx)(m.Title,{children:"MCP Servers"}),J.length>0&&(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200",children:J.length})]}),(0,t.jsx)(d.Text,{className:"text-tremor-content mt-1",children:"Configure and manage your MCP servers"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>et(!0),children:"+ Add New MCP Server"}),!(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>{el(null),X(!0)},variant:"secondary",children:"+ Submit MCP Server"})]})]}),(0,t.jsx)(tZ,{isVisible:ee,onClose:()=>et(!1),onSelectServer:e=>{el(e),et(!1),X(!0)},onCustomServer:()=>{el(null),et(!1),X(!0)},accessToken:e}),(0,t.jsxs)(n.TabGroup,{className:"w-full h-full",children:[(0,t.jsx)(i.TabList,{className:"flex justify-between mt-2 w-full items-center",children:(0,t.jsxs)("div",{className:"flex",children:[(0,t.jsx)(a.Tab,{children:"All Servers"}),(0,t.jsx)(a.Tab,{children:"Toolsets"}),(0,t.jsx)(a.Tab,{children:"Connect"}),(0,t.jsx)(a.Tab,{children:"Semantic Filter"}),(0,t.jsx)(a.Tab,{children:"Network Settings"}),(0,s.isAdminRole)(f)&&(0,t.jsx)(a.Tab,{children:(0,t.jsxs)("span",{className:"flex items-center gap-2",children:["Submitted MCPs ",(0,t.jsx)(u.default,{})]})})]})}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsx)(o.TabPanel,{children:U?(0,t.jsx)(tI,{mcpServer:ef,onBack:eb,isProxyAdmin:(0,s.isAdminRole)(f),isEditing:q,accessToken:e,userID:N,userRole:f,availableAccessGroups:em},U):(0,t.jsxs)("div",{className:"w-full h-full",children:[(0,t.jsx)("div",{className:"w-full",children:(0,t.jsx)("div",{className:"flex flex-col space-y-4",children:(0,t.jsxs)("div",{className:"flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:"Team"}),(0,t.jsxs)(p.Select,{value:D,onChange:e=>{H(e),eu(e,K)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:ec?"All Available Servers":"All Servers"})}),(0,t.jsx)(t1,{value:"personal",children:(0,t.jsx)("span",{className:"font-medium",children:"Personal"})}),ed.map(e=>(0,t.jsx)(t1,{value:e.team_id,children:(0,t.jsx)("span",{className:"font-medium",children:e.team_alias||e.team_id})},e.team_id))]})]}),(0,t.jsx)("div",{className:"h-6 w-px bg-gray-200"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:["Access Group",(0,t.jsx)(g.Tooltip,{title:"An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers.",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{marginLeft:4,color:"#9ca3af"}})})]}),(0,t.jsxs)(p.Select,{value:K,onChange:e=>{W(e),eu(D,e)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:"All Access Groups"})}),em.map(e=>(0,t.jsx)(t1,{value:e,children:(0,t.jsx)("span",{className:"font-medium",children:e})},e))]})]})]})})}),(0,t.jsx)("div",{className:"w-full mt-6",children:(0,t.jsx)(Z.DataTable,{data:J,columns:ex,renderSubComponent:()=>(0,t.jsx)("div",{}),getRowCanExpand:()=>!1,isLoading:k,noDataMessage:"No MCP servers configured. Click '+ Add New MCP Server' to get started.",loadingMessage:"Loading MCP servers...",enableSorting:!0})})]})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(er,{accessToken:e,userRole:f})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(to,{})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tH,{accessToken:e})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tJ,{accessToken:e})}),(0,s.isAdminRole)(f)&&(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)($,{accessToken:e})})]})]}),ei&&(0,t.jsx)(tX.ByokCredentialModal,{server:ei,open:!!ei,onClose:()=>eo(null),onSuccess:e=>{A(),eo(null)},accessToken:e||""})]}):(console.log("Missing required authentication parameters",{accessToken:e,userRole:f,userID:N}),(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Missing required authentication parameters."}))}],280881)}]); \ No newline at end of file +<<<<<<<< HEAD:litellm/proxy/_experimental/out/_next/static/chunks/0279e5299e9f6e98.js +}'`})})]})]})}):(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Please log in to configure semantic filter settings."})}var tK=e.i(262218);let{Text:tW}=f.Typography,tJ=({accessToken:e})=>{let s,[r,l]=(0,b.useState)(!0),[a,n]=(0,b.useState)(!1),[i,o]=(0,b.useState)([]),[c,d]=(0,b.useState)(null);(0,b.useEffect)(()=>{m(),u()},[e]);let m=async()=>{if(e){l(!0);try{for(let t of(await (0,_.getGeneralSettingsCall)(e)))"mcp_internal_ip_ranges"===t.field_name&&t.field_value&&o(t.field_value)}catch(e){console.error("Failed to load MCP network settings:",e)}finally{l(!1)}}},u=async()=>{if(!e)return;let t=await (0,_.fetchMCPClientIp)(e);t&&d(t)},x=async()=>{if(e){n(!0);try{i.length>0?await (0,_.updateConfigFieldSetting)(e,"mcp_internal_ip_ranges",i):await (0,_.deleteConfigFieldSetting)(e,"mcp_internal_ip_ranges")}catch(e){console.error("Failed to save MCP network settings:",e)}finally{n(!1)}}};if(r)return(0,t.jsx)("div",{className:"flex justify-center py-12",children:(0,t.jsx)(W.Spin,{})});let h=c?4!==(s=c.split(".")).length?c+"/32":`${s[0]}.${s[1]}.${s[2]}.0/24`:null;return(0,t.jsxs)("div",{className:"space-y-6 p-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(tW,{className:"text-lg font-semibold",children:"Private IP Ranges"}),(0,t.jsx)("p",{className:"text-sm text-gray-500 mt-1",children:'Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Callers from any other IP can only see servers marked "Available on Public Internet".'})]}),(0,t.jsxs)(e4.Card,{children:[c&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 rounded-lg",children:[(0,t.jsxs)(tW,{className:"text-sm text-blue-700",children:["Your current IP: ",(0,t.jsx)("span",{className:"font-mono font-medium",children:c})]}),h&&!i.includes(h)&&(0,t.jsxs)("div",{className:"mt-1",children:[(0,t.jsx)(tW,{className:"text-sm text-blue-600",children:"Suggested range: "}),(0,t.jsx)(tK.Tag,{className:"cursor-pointer font-mono",color:"blue",icon:(0,t.jsx)(eE.PlusOutlined,{}),onClick:()=>{!i.includes(h)&&o([...i,h])},children:h})]})]}),(0,t.jsx)("div",{className:"flex items-center mb-2",children:(0,t.jsx)(tW,{className:"font-medium",children:"Your Private Network Ranges"})}),(0,t.jsx)(p.Select,{mode:"tags",value:i,onChange:o,placeholder:"Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8",tokenSeparators:[","],className:"w-full",size:"large",allowClear:!0}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-2",children:"Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used."})]}),(0,t.jsx)("div",{className:"flex justify-end",children:(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tR.SaveOutlined,{}),onClick:x,loading:a,children:"Save"})})]})},{Search:tY}=H.Input,{Text:tG}=f.Typography,tQ=["#3B82F6","#10B981","#F59E0B","#EF4444","#8B5CF6","#EC4899","#06B6D4","#84CC16"],tZ=({isVisible:e,onClose:s,onSelectServer:r,onCustomServer:l,accessToken:a})=>{let[n,i]=(0,b.useState)([]),[o,c]=(0,b.useState)([]),[d,m]=(0,b.useState)(!1),[u,x]=(0,b.useState)(null),[p,g]=(0,b.useState)(""),[f,j]=(0,b.useState)("All");(0,b.useEffect)(()=>{e&&a&&(m(!0),x(null),(0,_.fetchDiscoverableMCPServers)(a).then(e=>{i(e.servers||[]),c(e.categories||[])}).catch(e=>{x(e.message||"Failed to load MCP servers")}).finally(()=>{m(!1)}))},[e,a]),(0,b.useEffect)(()=>{e&&(g(""),j("All"))},[e]);let y=(0,b.useMemo)(()=>{let e=n;if("All"!==f&&(e=e.filter(e=>e.category===f)),p.trim()){let t=p.toLowerCase();e=e.filter(e=>e.name.toLowerCase().includes(t)||e.title.toLowerCase().includes(t)||e.description.toLowerCase().includes(t))}return e},[n,f,p]),v=(0,b.useMemo)(()=>{let e={};for(let t of y){let s=t.category||"Other";e[s]||(e[s]=[]),e[s].push(t)}return e},[y]);return(0,t.jsxs)(h.Modal,{title:(0,t.jsxs)("div",{className:"flex items-center justify-between pb-4 border-b border-gray-100",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[(0,t.jsx)("img",{src:eZ,alt:"MCP Logo",className:"w-8 h-8 object-contain",style:{height:"20px",width:"20px",marginRight:"8px",objectFit:"contain"}}),(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:"Add MCP Server"})]}),(0,t.jsx)("button",{onClick:l,className:"text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium",children:"+ Custom Server"})]}),open:e,onCancel:s,footer:null,width:1e3,className:"top-8",styles:{body:{padding:"24px",maxHeight:"70vh",overflowY:"auto"},header:{padding:"24px 24px 0 24px",border:"none"}},children:[(0,t.jsx)("div",{style:{display:"flex",gap:6,flexWrap:"wrap",marginBottom:12},children:["All",...o].map(e=>{let s=f===e;return(0,t.jsx)("button",{onClick:()=>j(e),style:{padding:"4px 12px",borderRadius:4,border:s?"1px solid #111827":"1px solid #e5e7eb",background:s?"#111827":"#fff",color:s?"#fff":"#4b5563",cursor:"pointer",fontSize:12,fontWeight:s?500:400,lineHeight:"20px"},children:e},e)})}),(0,t.jsx)(tY,{placeholder:"Search servers...",value:p,onChange:e=>g(e.target.value),style:{marginBottom:16},allowClear:!0}),d&&(0,t.jsx)("div",{style:{display:"flex",flexDirection:"column",gap:4},children:Array.from({length:8}).map((e,s)=>(0,t.jsx)("div",{style:{height:36,borderRadius:6,background:"#f9fafb"}},s))}),u&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["Failed to load servers: ",u]})}),!d&&!u&&0===y.length&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["No servers found."," ",(0,t.jsx)("a",{onClick:l,style:{color:"#2563eb",cursor:"pointer"},children:"Add a custom server"})]})}),!d&&!u&&Object.entries(v).map(([e,s])=>(0,t.jsxs)("div",{style:{marginBottom:16},children:[(0,t.jsx)("div",{style:{fontSize:11,fontWeight:500,color:"#9ca3af",textTransform:"uppercase",letterSpacing:"0.05em",padding:"6px 0",borderBottom:"1px solid #f3f4f6",marginBottom:4},children:e}),(0,t.jsx)("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"0 16px"},children:s.map(e=>{var s;let l,a,n=(l=(s=e.title||e.name).charAt(0).toUpperCase(),a=s.split("").reduce((e,t)=>e+t.charCodeAt(0),0)%tQ.length,{initial:l,backgroundColor:tQ[a]});return(0,t.jsxs)("div",{onClick:()=>r(e),style:{display:"flex",alignItems:"center",padding:"8px 10px",borderRadius:6,cursor:"pointer",transition:"background 0.1s ease"},onMouseEnter:e=>{e.currentTarget.style.background="#f9fafb"},onMouseLeave:e=>{e.currentTarget.style.background="transparent"},children:[e.icon_url?(0,t.jsx)("img",{src:e.icon_url,alt:e.title,style:{width:20,height:20,objectFit:"contain",flexShrink:0,marginRight:12},onError:e=>{let t=e.currentTarget;t.style.display="none";let s=t.nextElementSibling;s&&(s.style.display="flex")}}):null,(0,t.jsx)("div",{style:{width:20,height:20,borderRadius:4,backgroundColor:n.backgroundColor,color:"#fff",display:e.icon_url?"none":"flex",alignItems:"center",justifyContent:"center",fontWeight:600,fontSize:11,flexShrink:0,marginRight:12},children:n.initial}),(0,t.jsx)("span",{style:{fontSize:14,fontWeight:400,color:"#111827",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e.title||e.name}),(0,t.jsx)("span",{style:{color:"#d1d5db",fontSize:14,flexShrink:0,marginLeft:8},children:"›"})]},e.name)})})]},e))]})};var tX=e.i(611052);let{Text:t0,Title:t2}=f.Typography,{Option:t1}=p.Select;e.s(["MCPServers",0,({accessToken:e,userRole:f,userID:N})=>{let{data:T,isLoading:k,refetch:A}=(0,j.useMCPServers)(),{data:I,isLoading:P,recheckServerHealth:O,recheckingServerIds:M}=(()=>{let{accessToken:e}=(0,w.default)(),t=(0,v.useQueryClient)(),[s,r]=(0,b.useState)(new Set),l=(0,y.useQuery)({queryKey:S.lists(),queryFn:async()=>await (0,_.fetchMCPServerHealth)(e),enabled:!!e,refetchInterval:3e4}),a=(0,b.useCallback)(async s=>{if(e){r(e=>new Set(e).add(s));try{let r=await (0,_.fetchMCPServerHealth)(e,[s]);t.setQueriesData({queryKey:S.lists()},e=>e?e.map(e=>r.find(t=>t.server_id===e.server_id)??e):r)}finally{r(e=>{let t=new Set(e);return t.delete(s),t})}}},[e,t]);return{...l,recheckServerHealth:a,recheckingServerIds:s}})(),F=(0,b.useMemo)(()=>{if(!T)return[];if(!I)return T;let e=new Map(I.map(e=>[e.server_id,e.status]));return T.map(t=>{let s=e.get(t.server_id);return{...t,status:s||t.status}})},[T,I]),[E,L]=(0,b.useState)(null),[R,U]=(0,b.useState)(!1),[z,B]=(0,b.useState)(null),[q,V]=(0,b.useState)(!1),[D,H]=(0,b.useState)("all"),[K,W]=(0,b.useState)("all"),[J,Y]=(0,b.useState)([]),[Q,X]=(0,b.useState)(!1),[ee,et]=(0,b.useState)(!1),[es,el]=(0,b.useState)(null),[ea,en]=(0,b.useState)(!1),[ei,eo]=(0,b.useState)(null),ec="Internal User"===f;(0,b.useEffect)(()=>{try{let e=(0,eG.getSecureItem)("litellm-mcp-oauth-edit-state");if(!e)return;let t=JSON.parse(e);t?.serverId&&(B(t.serverId),V(!0))}catch(e){console.error("Failed to restore MCP edit view state",e)}},[]);let ed=b.default.useMemo(()=>{if(!F)return[];let e=new Set,t=[];return F.forEach(s=>{s.teams&&s.teams.forEach(s=>{let r=s.team_id;e.has(r)||(e.add(r),t.push(s))})}),t},[F]),em=b.default.useMemo(()=>F?Array.from(new Set(F.flatMap(e=>e.mcp_access_groups).filter(e=>null!=e))):[],[F]),eu=(0,b.useCallback)((e,t)=>{if(!F)return Y([]);let s=F;"personal"===e?Y([]):("all"!==e&&(s=s.filter(t=>t.teams?.some(t=>t.team_id===e))),"all"!==t&&(s=s.filter(e=>e.mcp_access_groups?.some(e=>"string"==typeof e?e===t:e&&e.name===t))),Y([...s].sort((e,t)=>e.created_at||t.created_at?e.created_at?t.created_at?new Date(t.created_at).getTime()-new Date(e.created_at).getTime():-1:1:0)))},[F]);(0,b.useEffect)(()=>{eu(D,K)},[F,D,K,eu]);let ex=b.default.useMemo(()=>{let e,s,r,l;return e=e=>{B(e),V(!1)},s=e=>{B(e),V(!0)},r=eh,l=e=>eo(e),[{accessorKey:"server_id",header:"Server ID",enableSorting:!0,cell:({row:s})=>(0,t.jsxs)("button",{onClick:()=>e(s.original.server_id),className:"font-mono text-blue-600 bg-blue-50 hover:bg-blue-100 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 text-left truncate whitespace-nowrap cursor-pointer max-w-[15ch] transition-colors",children:[s.original.server_id.slice(0,7),"..."]})},{accessorKey:"server_name",header:"Name",enableSorting:!0,cell:({row:e})=>{let s=e.original.mcp_info?.logo_url,r=e.original.server_name;return(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[s?(0,t.jsx)("img",{src:s,alt:`${r??"MCP"} logo`,className:"h-5 w-5 rounded object-contain flex-shrink-0",onError:e=>{e.target.style.display="none"}}):null,(0,t.jsx)("span",{children:r})]})}},{accessorKey:"alias",header:"Alias",enableSorting:!0},{id:"url",header:"URL",cell:({row:e})=>{let s=e.original.url;if(!s)return(0,t.jsx)("span",{className:"text-gray-400",children:"—"});let{maskedUrl:r}=eH(s);return(0,t.jsx)("span",{className:"font-mono text-sm",children:r})}},{accessorKey:"transport",header:"Transport",enableSorting:!0,cell:({row:e})=>{let s=e.original.transport||"http",r=(e.original.spec_path&&"stdio"!==s?"OPENAPI":s).toUpperCase();return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:r})}},{accessorKey:"auth_type",header:"Auth Type",enableSorting:!0,cell:({getValue:e})=>{let s=e()||"none";return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:s})}},{id:"health_status",header:"Health Status",cell:({row:e})=>(0,t.jsx)(tu,{server:e.original,isLoadingHealth:P,isRechecking:M?.has(e.original.server_id),onRecheck:O})},{id:"mcp_access_groups",header:"Access Groups",cell:({row:e})=>{let s=e.original.mcp_access_groups;if(Array.isArray(s)&&s.length>0&&"string"==typeof s[0]){let e=s.join(", ");return(0,t.jsx)(g.Tooltip,{title:e,children:(0,t.jsxs)("div",{className:"flex items-center gap-1 max-w-[200px]",children:[(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[140px]",children:s[0]}),s.length>1&&(0,t.jsxs)("span",{className:"text-xs text-gray-400 font-medium",children:["+",s.length-1]})]})})}return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"})}},{id:"available_on_public_internet",header:"Network Access",cell:({row:e})=>e.original.available_on_public_internet?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Public"]}):(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-orange-500"}),"Internal"]})},{header:"Created",accessorKey:"created_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.created_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.created_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{header:"Updated",accessorKey:"updated_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.updated_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.updated_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{id:"byok_credential",header:"Credential",cell:({row:e})=>{let s=e.original;return s.is_byok?s.has_user_credential?(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200",children:[(0,t.jsx)(tm.CheckOutlined,{style:{fontSize:10}})," Connected"]}),l&&(0,t.jsx)("button",{className:"text-xs text-gray-400 hover:text-blue-600 transition-colors",onClick:()=>l(s),children:"Update"})]}):l?(0,t.jsx)("button",{className:"text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md font-medium transition-colors shadow-sm",onClick:()=>l(s),children:"Connect"}):null:(0,t.jsx)("span",{className:"text-gray-300 text-xs",children:"—"})}},{id:"actions",header:"Actions",cell:({row:e})=>(0,t.jsxs)("div",{className:"flex items-center gap-1",children:[(0,t.jsx)(g.Tooltip,{title:"Edit",children:(0,t.jsx)("button",{onClick:()=>s(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:td.PencilAltIcon,size:"sm"})})}),(0,t.jsx)(g.Tooltip,{title:"Delete",children:(0,t.jsx)("button",{onClick:()=>r(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:G.TrashIcon,size:"sm"})})})]})}]},[f,P,O,M]);function eh(e){L(e),U(!0)}let ep=async()=>{if(null!=E&&null!=e)try{en(!0),await (0,_.deleteMCPServer)(e,E),C.default.success("Deleted MCP Server successfully"),A()}catch(e){console.error("Error deleting the mcp server:",e)}finally{en(!1),U(!1),L(null)}},eg=E?(T||[]).find(e=>e.server_id===E):null,ef=b.default.useMemo(()=>J.find(e=>e.server_id===z)||{server_id:"",server_name:"",alias:"",url:"",transport:"",auth_type:"",created_at:"",created_by:"",updated_at:"",updated_by:""},[J,z]),eb=b.default.useCallback(()=>{V(!1),B(null),A()},[A]);return e&&f&&N?(0,t.jsxs)("div",{className:"w-full h-full p-6",children:[(0,t.jsx)(h.Modal,{open:R,title:"Delete MCP Server?",onOk:ep,okText:ea?"Deleting...":"Delete",onCancel:()=>{U(!1),L(null)},cancelText:"Cancel",cancelButtonProps:{disabled:ea},okButtonProps:{danger:!0},confirmLoading:ea,children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)(t0,{className:"text-gray-600",children:"This action is permanent and cannot be undone. All associated configurations will be removed."}),eg&&(0,t.jsx)("div",{className:"mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200",children:(0,t.jsxs)(x.Descriptions,{column:1,size:"small",colon:!1,children:[eg.server_name&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"Name"}),children:(0,t.jsx)(t0,{strong:!0,className:"text-sm",children:eg.server_name})}),(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"ID"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs",children:eg.server_id})}),eg.url&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"URL"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs break-all",children:eg.url})})]})})]})}),(0,t.jsx)(e5,{userRole:f,accessToken:e,onCreateSuccess:e=>{Y(t=>[...t,e]),X(!1),A()},isModalVisible:Q,setModalVisible:X,availableAccessGroups:em,prefillData:es,onBackToDiscovery:()=>{X(!1),el(null),et(!0)}}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-3",children:[(0,t.jsx)(m.Title,{children:"MCP Servers"}),J.length>0&&(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200",children:J.length})]}),(0,t.jsx)(d.Text,{className:"text-tremor-content mt-1",children:"Configure and manage your MCP servers"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>et(!0),children:"+ Add New MCP Server"}),!(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>{el(null),X(!0)},variant:"secondary",children:"+ Submit MCP Server"})]})]}),(0,t.jsx)(tZ,{isVisible:ee,onClose:()=>et(!1),onSelectServer:e=>{el(e),et(!1),X(!0)},onCustomServer:()=>{el(null),et(!1),X(!0)},accessToken:e}),(0,t.jsxs)(n.TabGroup,{className:"w-full h-full",children:[(0,t.jsx)(i.TabList,{className:"flex justify-between mt-2 w-full items-center",children:(0,t.jsxs)("div",{className:"flex",children:[(0,t.jsx)(a.Tab,{children:"All Servers"}),(0,t.jsx)(a.Tab,{children:"Toolsets"}),(0,t.jsx)(a.Tab,{children:"Connect"}),(0,t.jsx)(a.Tab,{children:"Semantic Filter"}),(0,t.jsx)(a.Tab,{children:"Network Settings"}),(0,s.isAdminRole)(f)&&(0,t.jsx)(a.Tab,{children:(0,t.jsxs)("span",{className:"flex items-center gap-2",children:["Submitted MCPs ",(0,t.jsx)(u.default,{})]})})]})}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsx)(o.TabPanel,{children:z?(0,t.jsx)(tI,{mcpServer:ef,onBack:eb,isProxyAdmin:(0,s.isAdminRole)(f),isEditing:q,accessToken:e,userID:N,userRole:f,availableAccessGroups:em},z):(0,t.jsxs)("div",{className:"w-full h-full",children:[(0,t.jsx)("div",{className:"w-full",children:(0,t.jsx)("div",{className:"flex flex-col space-y-4",children:(0,t.jsxs)("div",{className:"flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:"Team"}),(0,t.jsxs)(p.Select,{value:D,onChange:e=>{H(e),eu(e,K)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:ec?"All Available Servers":"All Servers"})}),(0,t.jsx)(t1,{value:"personal",children:(0,t.jsx)("span",{className:"font-medium",children:"Personal"})}),ed.map(e=>(0,t.jsx)(t1,{value:e.team_id,children:(0,t.jsx)("span",{className:"font-medium",children:e.team_alias||e.team_id})},e.team_id))]})]}),(0,t.jsx)("div",{className:"h-6 w-px bg-gray-200"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:["Access Group",(0,t.jsx)(g.Tooltip,{title:"An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers.",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{marginLeft:4,color:"#9ca3af"}})})]}),(0,t.jsxs)(p.Select,{value:K,onChange:e=>{W(e),eu(D,e)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:"All Access Groups"})}),em.map(e=>(0,t.jsx)(t1,{value:e,children:(0,t.jsx)("span",{className:"font-medium",children:e})},e))]})]})]})})}),(0,t.jsx)("div",{className:"w-full mt-6",children:(0,t.jsx)(Z.DataTable,{data:J,columns:ex,renderSubComponent:()=>(0,t.jsx)("div",{}),getRowCanExpand:()=>!1,isLoading:k,noDataMessage:"No MCP servers configured. Click '+ Add New MCP Server' to get started.",loadingMessage:"Loading MCP servers...",enableSorting:!0})})]})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(er,{accessToken:e,userRole:f})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(to,{})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tH,{accessToken:e})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tJ,{accessToken:e})}),(0,s.isAdminRole)(f)&&(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)($,{accessToken:e})})]})]}),ei&&(0,t.jsx)(tX.ByokCredentialModal,{server:ei,open:!!ei,onClose:()=>eo(null),onSuccess:e=>{A(),eo(null)},accessToken:e||""})]}):(console.log("Missing required authentication parameters",{accessToken:e,userRole:f,userID:N}),(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Missing required authentication parameters."}))}],280881)}]); +======== +}'`})})]})]})}):(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Please log in to configure semantic filter settings."})}var tK=e.i(262218);let{Text:tW}=f.Typography,tJ=({accessToken:e})=>{let s,[r,l]=(0,b.useState)(!0),[a,n]=(0,b.useState)(!1),[i,o]=(0,b.useState)([]),[c,d]=(0,b.useState)(null);(0,b.useEffect)(()=>{m(),u()},[e]);let m=async()=>{if(e){l(!0);try{for(let t of(await (0,_.getGeneralSettingsCall)(e)))"mcp_internal_ip_ranges"===t.field_name&&t.field_value&&o(t.field_value)}catch(e){console.error("Failed to load MCP network settings:",e)}finally{l(!1)}}},u=async()=>{if(!e)return;let t=await (0,_.fetchMCPClientIp)(e);t&&d(t)},x=async()=>{if(e){n(!0);try{i.length>0?await (0,_.updateConfigFieldSetting)(e,"mcp_internal_ip_ranges",i):await (0,_.deleteConfigFieldSetting)(e,"mcp_internal_ip_ranges")}catch(e){console.error("Failed to save MCP network settings:",e)}finally{n(!1)}}};if(r)return(0,t.jsx)("div",{className:"flex justify-center py-12",children:(0,t.jsx)(W.Spin,{})});let h=c?4!==(s=c.split(".")).length?c+"/32":`${s[0]}.${s[1]}.${s[2]}.0/24`:null;return(0,t.jsxs)("div",{className:"space-y-6 p-4",children:[(0,t.jsxs)("div",{children:[(0,t.jsx)(tW,{className:"text-lg font-semibold",children:"Private IP Ranges"}),(0,t.jsx)("p",{className:"text-sm text-gray-500 mt-1",children:'Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Callers from any other IP can only see servers marked "Available on Public Internet".'})]}),(0,t.jsxs)(e4.Card,{children:[c&&(0,t.jsxs)("div",{className:"mb-4 p-3 bg-blue-50 rounded-lg",children:[(0,t.jsxs)(tW,{className:"text-sm text-blue-700",children:["Your current IP: ",(0,t.jsx)("span",{className:"font-mono font-medium",children:c})]}),h&&!i.includes(h)&&(0,t.jsxs)("div",{className:"mt-1",children:[(0,t.jsx)(tW,{className:"text-sm text-blue-600",children:"Suggested range: "}),(0,t.jsx)(tK.Tag,{className:"cursor-pointer font-mono",color:"blue",icon:(0,t.jsx)(eE.PlusOutlined,{}),onClick:()=>{!i.includes(h)&&o([...i,h])},children:h})]})]}),(0,t.jsx)("div",{className:"flex items-center mb-2",children:(0,t.jsx)(tW,{className:"font-medium",children:"Your Private Network Ranges"})}),(0,t.jsx)(p.Select,{mode:"tags",value:i,onChange:o,placeholder:"Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8",tokenSeparators:[","],className:"w-full",size:"large",allowClear:!0}),(0,t.jsx)("p",{className:"text-xs text-gray-400 mt-2",children:"Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used."})]}),(0,t.jsx)("div",{className:"flex justify-end",children:(0,t.jsx)(eb.Button,{type:"primary",icon:(0,t.jsx)(tR.SaveOutlined,{}),onClick:x,loading:a,children:"Save"})})]})},{Search:tY}=H.Input,{Text:tG}=f.Typography,tQ=["#3B82F6","#10B981","#F59E0B","#EF4444","#8B5CF6","#EC4899","#06B6D4","#84CC16"],tZ=({isVisible:e,onClose:s,onSelectServer:r,onCustomServer:l,accessToken:a})=>{let[n,i]=(0,b.useState)([]),[o,c]=(0,b.useState)([]),[d,m]=(0,b.useState)(!1),[u,x]=(0,b.useState)(null),[p,g]=(0,b.useState)(""),[f,j]=(0,b.useState)("All");(0,b.useEffect)(()=>{e&&a&&(m(!0),x(null),(0,_.fetchDiscoverableMCPServers)(a).then(e=>{i(e.servers||[]),c(e.categories||[])}).catch(e=>{x(e.message||"Failed to load MCP servers")}).finally(()=>{m(!1)}))},[e,a]),(0,b.useEffect)(()=>{e&&(g(""),j("All"))},[e]);let y=(0,b.useMemo)(()=>{let e=n;if("All"!==f&&(e=e.filter(e=>e.category===f)),p.trim()){let t=p.toLowerCase();e=e.filter(e=>e.name.toLowerCase().includes(t)||e.title.toLowerCase().includes(t)||e.description.toLowerCase().includes(t))}return e},[n,f,p]),v=(0,b.useMemo)(()=>{let e={};for(let t of y){let s=t.category||"Other";e[s]||(e[s]=[]),e[s].push(t)}return e},[y]);return(0,t.jsxs)(h.Modal,{title:(0,t.jsxs)("div",{className:"flex items-center justify-between pb-4 border-b border-gray-100",children:[(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[(0,t.jsx)("img",{src:eZ,alt:"MCP Logo",className:"w-8 h-8 object-contain",style:{height:"20px",width:"20px",marginRight:"8px",objectFit:"contain"}}),(0,t.jsx)("h2",{className:"text-xl font-semibold text-gray-900",children:"Add MCP Server"})]}),(0,t.jsx)("button",{onClick:l,className:"text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium",children:"+ Custom Server"})]}),open:e,onCancel:s,footer:null,width:1e3,className:"top-8",styles:{body:{padding:"24px",maxHeight:"70vh",overflowY:"auto"},header:{padding:"24px 24px 0 24px",border:"none"}},children:[(0,t.jsx)("div",{style:{display:"flex",gap:6,flexWrap:"wrap",marginBottom:12},children:["All",...o].map(e=>{let s=f===e;return(0,t.jsx)("button",{onClick:()=>j(e),style:{padding:"4px 12px",borderRadius:4,border:s?"1px solid #111827":"1px solid #e5e7eb",background:s?"#111827":"#fff",color:s?"#fff":"#4b5563",cursor:"pointer",fontSize:12,fontWeight:s?500:400,lineHeight:"20px"},children:e},e)})}),(0,t.jsx)(tY,{placeholder:"Search servers...",value:p,onChange:e=>g(e.target.value),style:{marginBottom:16},allowClear:!0}),d&&(0,t.jsx)("div",{style:{display:"flex",flexDirection:"column",gap:4},children:Array.from({length:8}).map((e,s)=>(0,t.jsx)("div",{style:{height:36,borderRadius:6,background:"#f9fafb"}},s))}),u&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["Failed to load servers: ",u]})}),!d&&!u&&0===y.length&&(0,t.jsx)("div",{style:{textAlign:"center",padding:"32px 0",color:"#9ca3af"},children:(0,t.jsxs)(tG,{children:["No servers found."," ",(0,t.jsx)("a",{onClick:l,style:{color:"#2563eb",cursor:"pointer"},children:"Add a custom server"})]})}),!d&&!u&&Object.entries(v).map(([e,s])=>(0,t.jsxs)("div",{style:{marginBottom:16},children:[(0,t.jsx)("div",{style:{fontSize:11,fontWeight:500,color:"#9ca3af",textTransform:"uppercase",letterSpacing:"0.05em",padding:"6px 0",borderBottom:"1px solid #f3f4f6",marginBottom:4},children:e}),(0,t.jsx)("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"0 16px"},children:s.map(e=>{var s;let l,a,n=(l=(s=e.title||e.name).charAt(0).toUpperCase(),a=s.split("").reduce((e,t)=>e+t.charCodeAt(0),0)%tQ.length,{initial:l,backgroundColor:tQ[a]});return(0,t.jsxs)("div",{onClick:()=>r(e),style:{display:"flex",alignItems:"center",padding:"8px 10px",borderRadius:6,cursor:"pointer",transition:"background 0.1s ease"},onMouseEnter:e=>{e.currentTarget.style.background="#f9fafb"},onMouseLeave:e=>{e.currentTarget.style.background="transparent"},children:[e.icon_url?(0,t.jsx)("img",{src:e.icon_url,alt:e.title,style:{width:20,height:20,objectFit:"contain",flexShrink:0,marginRight:12},onError:e=>{let t=e.currentTarget;t.style.display="none";let s=t.nextElementSibling;s&&(s.style.display="flex")}}):null,(0,t.jsx)("div",{style:{width:20,height:20,borderRadius:4,backgroundColor:n.backgroundColor,color:"#fff",display:e.icon_url?"none":"flex",alignItems:"center",justifyContent:"center",fontWeight:600,fontSize:11,flexShrink:0,marginRight:12},children:n.initial}),(0,t.jsx)("span",{style:{fontSize:14,fontWeight:400,color:"#111827",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:e.title||e.name}),(0,t.jsx)("span",{style:{color:"#d1d5db",fontSize:14,flexShrink:0,marginLeft:8},children:"›"})]},e.name)})})]},e))]})};var tX=e.i(611052);let{Text:t0,Title:t2}=f.Typography,{Option:t1}=p.Select;e.s(["MCPServers",0,({accessToken:e,userRole:f,userID:N})=>{let{data:T,isLoading:k,refetch:A}=(0,j.useMCPServers)(),{data:I,isLoading:P,recheckServerHealth:O,recheckingServerIds:M}=(()=>{let{accessToken:e}=(0,w.default)(),t=(0,v.useQueryClient)(),[s,r]=(0,b.useState)(new Set),l=(0,y.useQuery)({queryKey:S.lists(),queryFn:async()=>await (0,_.fetchMCPServerHealth)(e),enabled:!!e,refetchInterval:3e4}),a=(0,b.useCallback)(async s=>{if(e){r(e=>new Set(e).add(s));try{let r=await (0,_.fetchMCPServerHealth)(e,[s]);t.setQueriesData({queryKey:S.lists()},e=>e?e.map(e=>r.find(t=>t.server_id===e.server_id)??e):r)}finally{r(e=>{let t=new Set(e);return t.delete(s),t})}}},[e,t]);return{...l,recheckServerHealth:a,recheckingServerIds:s}})(),F=(0,b.useMemo)(()=>{if(!T)return[];if(!I)return T;let e=new Map(I.map(e=>[e.server_id,e.status]));return T.map(t=>{let s=e.get(t.server_id);return{...t,status:s||t.status}})},[T,I]),[E,L]=(0,b.useState)(null),[R,z]=(0,b.useState)(!1),[U,B]=(0,b.useState)(null),[q,V]=(0,b.useState)(!1),[D,H]=(0,b.useState)("all"),[K,W]=(0,b.useState)("all"),[J,Y]=(0,b.useState)([]),[Q,X]=(0,b.useState)(!1),[ee,et]=(0,b.useState)(!1),[es,el]=(0,b.useState)(null),[ea,en]=(0,b.useState)(!1),[ei,eo]=(0,b.useState)(null),ec="Internal User"===f;(0,b.useEffect)(()=>{try{let e=(0,eG.getSecureItem)("litellm-mcp-oauth-edit-state");if(!e)return;let t=JSON.parse(e);t?.serverId&&(B(t.serverId),V(!0))}catch(e){console.error("Failed to restore MCP edit view state",e)}},[]);let ed=b.default.useMemo(()=>{if(!F)return[];let e=new Set,t=[];return F.forEach(s=>{s.teams&&s.teams.forEach(s=>{let r=s.team_id;e.has(r)||(e.add(r),t.push(s))})}),t},[F]),em=b.default.useMemo(()=>F?Array.from(new Set(F.flatMap(e=>e.mcp_access_groups).filter(e=>null!=e))):[],[F]),eu=(0,b.useCallback)((e,t)=>{if(!F)return Y([]);let s=F;"personal"===e?Y([]):("all"!==e&&(s=s.filter(t=>t.teams?.some(t=>t.team_id===e))),"all"!==t&&(s=s.filter(e=>e.mcp_access_groups?.some(e=>"string"==typeof e?e===t:e&&e.name===t))),Y([...s].sort((e,t)=>e.created_at||t.created_at?e.created_at?t.created_at?new Date(t.created_at).getTime()-new Date(e.created_at).getTime():-1:1:0)))},[F]);(0,b.useEffect)(()=>{eu(D,K)},[F,D,K,eu]);let ex=b.default.useMemo(()=>{let e,s,r,l;return e=e=>{B(e),V(!1)},s=e=>{B(e),V(!0)},r=eh,l=e=>eo(e),[{accessorKey:"server_id",header:"Server ID",enableSorting:!0,cell:({row:s})=>(0,t.jsxs)("button",{onClick:()=>e(s.original.server_id),className:"font-mono text-blue-600 bg-blue-50 hover:bg-blue-100 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 text-left truncate whitespace-nowrap cursor-pointer max-w-[15ch] transition-colors",children:[s.original.server_id.slice(0,7),"..."]})},{accessorKey:"server_name",header:"Name",enableSorting:!0,cell:({row:e})=>{let s=e.original.mcp_info?.logo_url,r=e.original.server_name;return(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[s?(0,t.jsx)("img",{src:s,alt:`${r??"MCP"} logo`,className:"h-5 w-5 rounded object-contain flex-shrink-0",onError:e=>{e.target.style.display="none"}}):null,(0,t.jsx)("span",{children:r})]})}},{accessorKey:"alias",header:"Alias",enableSorting:!0},{id:"url",header:"URL",cell:({row:e})=>{let s=e.original.url;if(!s)return(0,t.jsx)("span",{className:"text-gray-400",children:"—"});let{maskedUrl:r}=eH(s);return(0,t.jsx)("span",{className:"font-mono text-sm",children:r})}},{accessorKey:"transport",header:"Transport",enableSorting:!0,cell:({row:e})=>{let s=e.original.transport||"http",r=(e.original.spec_path&&"stdio"!==s?"OPENAPI":s).toUpperCase();return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:r})}},{accessorKey:"auth_type",header:"Auth Type",enableSorting:!0,cell:({getValue:e})=>{let s=e()||"none";return(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border bg-gray-50 text-gray-700 border-gray-200",children:s})}},{id:"health_status",header:"Health Status",cell:({row:e})=>(0,t.jsx)(tu,{server:e.original,isLoadingHealth:P,isRechecking:M?.has(e.original.server_id),onRecheck:O})},{id:"mcp_access_groups",header:"Access Groups",cell:({row:e})=>{let s=e.original.mcp_access_groups;if(Array.isArray(s)&&s.length>0&&"string"==typeof s[0]){let e=s.join(", ");return(0,t.jsx)(g.Tooltip,{title:e,children:(0,t.jsxs)("div",{className:"flex items-center gap-1 max-w-[200px]",children:[(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 border border-gray-200 truncate max-w-[140px]",children:s[0]}),s.length>1&&(0,t.jsxs)("span",{className:"text-xs text-gray-400 font-medium",children:["+",s.length-1]})]})})}return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"})}},{id:"available_on_public_internet",header:"Network Access",cell:({row:e})=>e.original.available_on_public_internet?(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-green-500"}),"Public"]}):(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium",children:[(0,t.jsx)("span",{className:"h-1.5 w-1.5 rounded-full bg-orange-500"}),"Internal"]})},{header:"Created",accessorKey:"created_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.created_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.created_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{header:"Updated",accessorKey:"updated_at",enableSorting:!0,sortingFn:"datetime",cell:({row:e})=>{let s=e.original;if(!s.updated_at)return(0,t.jsx)("span",{className:"text-xs text-gray-400",children:"—"});let r=new Date(s.updated_at);return(0,t.jsx)(g.Tooltip,{title:r.toLocaleString(),children:(0,t.jsx)("span",{className:"text-xs text-gray-600",children:r.toLocaleDateString()})})}},{id:"byok_credential",header:"Credential",cell:({row:e})=>{let s=e.original;return s.is_byok?s.has_user_credential?(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)("span",{className:"inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200",children:[(0,t.jsx)(tm.CheckOutlined,{style:{fontSize:10}})," Connected"]}),l&&(0,t.jsx)("button",{className:"text-xs text-gray-400 hover:text-blue-600 transition-colors",onClick:()=>l(s),children:"Update"})]}):l?(0,t.jsx)("button",{className:"text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md font-medium transition-colors shadow-sm",onClick:()=>l(s),children:"Connect"}):null:(0,t.jsx)("span",{className:"text-gray-300 text-xs",children:"—"})}},{id:"actions",header:"Actions",cell:({row:e})=>(0,t.jsxs)("div",{className:"flex items-center gap-1",children:[(0,t.jsx)(g.Tooltip,{title:"Edit",children:(0,t.jsx)("button",{onClick:()=>s(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:td.PencilAltIcon,size:"sm"})})}),(0,t.jsx)(g.Tooltip,{title:"Delete",children:(0,t.jsx)("button",{onClick:()=>r(e.original.server_id),className:"p-1.5 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors",children:(0,t.jsx)(tc.Icon,{icon:G.TrashIcon,size:"sm"})})})]})}]},[f,P,O,M]);function eh(e){L(e),z(!0)}let ep=async()=>{if(null!=E&&null!=e)try{en(!0),await (0,_.deleteMCPServer)(e,E),C.default.success("Deleted MCP Server successfully"),A()}catch(e){console.error("Error deleting the mcp server:",e)}finally{en(!1),z(!1),L(null)}},eg=E?(T||[]).find(e=>e.server_id===E):null,ef=b.default.useMemo(()=>J.find(e=>e.server_id===U)||{server_id:"",server_name:"",alias:"",url:"",transport:"",auth_type:"",created_at:"",created_by:"",updated_at:"",updated_by:""},[J,U]),eb=b.default.useCallback(()=>{V(!1),B(null),A()},[A]);return e&&f&&N?(0,t.jsxs)("div",{className:"w-full h-full p-6",children:[(0,t.jsx)(h.Modal,{open:R,title:"Delete MCP Server?",onOk:ep,okText:ea?"Deleting...":"Delete",onCancel:()=>{z(!1),L(null)},cancelText:"Cancel",cancelButtonProps:{disabled:ea},okButtonProps:{danger:!0},confirmLoading:ea,children:(0,t.jsxs)("div",{className:"space-y-4",children:[(0,t.jsx)(t0,{className:"text-gray-600",children:"This action is permanent and cannot be undone. All associated configurations will be removed."}),eg&&(0,t.jsx)("div",{className:"mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200",children:(0,t.jsxs)(x.Descriptions,{column:1,size:"small",colon:!1,children:[eg.server_name&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"Name"}),children:(0,t.jsx)(t0,{strong:!0,className:"text-sm",children:eg.server_name})}),(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"ID"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs",children:eg.server_id})}),eg.url&&(0,t.jsx)(x.Descriptions.Item,{label:(0,t.jsx)("span",{className:"text-gray-500 text-sm",children:"URL"}),children:(0,t.jsx)(t0,{code:!0,className:"text-xs break-all",children:eg.url})})]})})]})}),(0,t.jsx)(e5,{userRole:f,accessToken:e,onCreateSuccess:e=>{Y(t=>[...t,e]),X(!1),A()},isModalVisible:Q,setModalVisible:X,availableAccessGroups:em,prefillData:es,onBackToDiscovery:()=>{X(!1),el(null),et(!0)}}),(0,t.jsxs)("div",{className:"flex items-center justify-between",children:[(0,t.jsxs)("div",{children:[(0,t.jsxs)("div",{className:"flex items-center gap-3",children:[(0,t.jsx)(m.Title,{children:"MCP Servers"}),J.length>0&&(0,t.jsx)("span",{className:"inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200",children:J.length})]}),(0,t.jsx)(d.Text,{className:"text-tremor-content mt-1",children:"Configure and manage your MCP servers"})]}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>et(!0),children:"+ Add New MCP Server"}),!(0,s.isAdminRole)(f)&&(0,t.jsx)(l.Button,{className:"flex-shrink-0",onClick:()=>{el(null),X(!0)},variant:"secondary",children:"+ Submit MCP Server"})]})]}),(0,t.jsx)(tZ,{isVisible:ee,onClose:()=>et(!1),onSelectServer:e=>{el(e),et(!1),X(!0)},onCustomServer:()=>{el(null),et(!1),X(!0)},accessToken:e}),(0,t.jsxs)(n.TabGroup,{className:"w-full h-full",children:[(0,t.jsx)(i.TabList,{className:"flex justify-between mt-2 w-full items-center",children:(0,t.jsxs)("div",{className:"flex",children:[(0,t.jsx)(a.Tab,{children:"All Servers"}),(0,t.jsx)(a.Tab,{children:"Toolsets"}),(0,t.jsx)(a.Tab,{children:"Connect"}),(0,t.jsx)(a.Tab,{children:"Semantic Filter"}),(0,t.jsx)(a.Tab,{children:"Network Settings"}),(0,s.isAdminRole)(f)&&(0,t.jsx)(a.Tab,{children:(0,t.jsxs)("span",{className:"flex items-center gap-2",children:["Submitted MCPs ",(0,t.jsx)(u.default,{})]})})]})}),(0,t.jsxs)(c.TabPanels,{children:[(0,t.jsx)(o.TabPanel,{children:U?(0,t.jsx)(tI,{mcpServer:ef,onBack:eb,isProxyAdmin:(0,s.isAdminRole)(f),isEditing:q,accessToken:e,userID:N,userRole:f,availableAccessGroups:em},U):(0,t.jsxs)("div",{className:"w-full h-full",children:[(0,t.jsx)("div",{className:"w-full",children:(0,t.jsx)("div",{className:"flex flex-col space-y-4",children:(0,t.jsxs)("div",{className:"flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200",children:[(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsx)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:"Team"}),(0,t.jsxs)(p.Select,{value:D,onChange:e=>{H(e),eu(e,K)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:ec?"All Available Servers":"All Servers"})}),(0,t.jsx)(t1,{value:"personal",children:(0,t.jsx)("span",{className:"font-medium",children:"Personal"})}),ed.map(e=>(0,t.jsx)(t1,{value:e.team_id,children:(0,t.jsx)("span",{className:"font-medium",children:e.team_alias||e.team_id})},e.team_id))]})]}),(0,t.jsx)("div",{className:"h-6 w-px bg-gray-200"}),(0,t.jsxs)("div",{className:"flex items-center gap-2",children:[(0,t.jsxs)(d.Text,{className:"text-sm font-medium text-gray-600 whitespace-nowrap",children:["Access Group",(0,t.jsx)(g.Tooltip,{title:"An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers.",children:(0,t.jsx)(r.QuestionCircleOutlined,{style:{marginLeft:4,color:"#9ca3af"}})})]}),(0,t.jsxs)(p.Select,{value:K,onChange:e=>{W(e),eu(D,e)},style:{width:220},size:"middle",children:[(0,t.jsx)(t1,{value:"all",children:(0,t.jsx)("span",{className:"font-medium",children:"All Access Groups"})}),em.map(e=>(0,t.jsx)(t1,{value:e,children:(0,t.jsx)("span",{className:"font-medium",children:e})},e))]})]})]})})}),(0,t.jsx)("div",{className:"w-full mt-6",children:(0,t.jsx)(Z.DataTable,{data:J,columns:ex,renderSubComponent:()=>(0,t.jsx)("div",{}),getRowCanExpand:()=>!1,isLoading:k,noDataMessage:"No MCP servers configured. Click '+ Add New MCP Server' to get started.",loadingMessage:"Loading MCP servers...",enableSorting:!0})})]})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(er,{accessToken:e,userRole:f})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(to,{})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tH,{accessToken:e})}),(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)(tJ,{accessToken:e})}),(0,s.isAdminRole)(f)&&(0,t.jsx)(o.TabPanel,{children:(0,t.jsx)($,{accessToken:e})})]})]}),ei&&(0,t.jsx)(tX.ByokCredentialModal,{server:ei,open:!!ei,onClose:()=>eo(null),onSuccess:e=>{A(),eo(null)},accessToken:e||""})]}):(console.log("Missing required authentication parameters",{accessToken:e,userRole:f,userID:N}),(0,t.jsx)("div",{className:"p-6 text-center text-gray-500",children:"Missing required authentication parameters."}))}],280881)}]); +>>>>>>>> origin/litellm_internal_staging:litellm/proxy/_experimental/out/_next/static/chunks/e1a670efcb966aaa.js diff --git a/litellm/proxy/_experimental/out/_not-found.html b/litellm/proxy/_experimental/out/_not-found/index.html similarity index 100% rename from litellm/proxy/_experimental/out/_not-found.html rename to litellm/proxy/_experimental/out/_not-found/index.html diff --git a/litellm/proxy/_experimental/out/api-reference.html b/litellm/proxy/_experimental/out/api-reference/index.html similarity index 100% rename from litellm/proxy/_experimental/out/api-reference.html rename to litellm/proxy/_experimental/out/api-reference/index.html diff --git a/litellm/proxy/_experimental/out/chat.html b/litellm/proxy/_experimental/out/chat/index.html similarity index 100% rename from litellm/proxy/_experimental/out/chat.html rename to litellm/proxy/_experimental/out/chat/index.html diff --git a/litellm/proxy/_experimental/out/experimental/api-playground.html b/litellm/proxy/_experimental/out/experimental/api-playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/api-playground.html rename to litellm/proxy/_experimental/out/experimental/api-playground/index.html diff --git a/litellm/proxy/_experimental/out/experimental/budgets.html b/litellm/proxy/_experimental/out/experimental/budgets/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/budgets.html rename to litellm/proxy/_experimental/out/experimental/budgets/index.html diff --git a/litellm/proxy/_experimental/out/experimental/caching.html b/litellm/proxy/_experimental/out/experimental/caching/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/caching.html rename to litellm/proxy/_experimental/out/experimental/caching/index.html diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/claude-code-plugins.html rename to litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html diff --git a/litellm/proxy/_experimental/out/experimental/old-usage.html b/litellm/proxy/_experimental/out/experimental/old-usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/old-usage.html rename to litellm/proxy/_experimental/out/experimental/old-usage/index.html diff --git a/litellm/proxy/_experimental/out/experimental/prompts.html b/litellm/proxy/_experimental/out/experimental/prompts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/prompts.html rename to litellm/proxy/_experimental/out/experimental/prompts/index.html diff --git a/litellm/proxy/_experimental/out/experimental/tag-management.html b/litellm/proxy/_experimental/out/experimental/tag-management/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/tag-management.html rename to litellm/proxy/_experimental/out/experimental/tag-management/index.html diff --git a/litellm/proxy/_experimental/out/guardrails.html b/litellm/proxy/_experimental/out/guardrails/index.html similarity index 100% rename from litellm/proxy/_experimental/out/guardrails.html rename to litellm/proxy/_experimental/out/guardrails/index.html diff --git a/litellm/proxy/_experimental/out/login.html b/litellm/proxy/_experimental/out/login/index.html similarity index 100% rename from litellm/proxy/_experimental/out/login.html rename to litellm/proxy/_experimental/out/login/index.html diff --git a/litellm/proxy/_experimental/out/logs.html b/litellm/proxy/_experimental/out/logs/index.html similarity index 100% rename from litellm/proxy/_experimental/out/logs.html rename to litellm/proxy/_experimental/out/logs/index.html diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback.html b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html similarity index 100% rename from litellm/proxy/_experimental/out/mcp/oauth/callback.html rename to litellm/proxy/_experimental/out/mcp/oauth/callback/index.html diff --git a/litellm/proxy/_experimental/out/model-hub.html b/litellm/proxy/_experimental/out/model-hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model-hub.html rename to litellm/proxy/_experimental/out/model-hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub.html rename to litellm/proxy/_experimental/out/model_hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/models-and-endpoints.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html similarity index 100% rename from litellm/proxy/_experimental/out/models-and-endpoints.html rename to litellm/proxy/_experimental/out/models-and-endpoints/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding/index.html similarity index 100% rename from litellm/proxy/_experimental/out/onboarding.html rename to litellm/proxy/_experimental/out/onboarding/index.html diff --git a/litellm/proxy/_experimental/out/organizations.html b/litellm/proxy/_experimental/out/organizations/index.html similarity index 100% rename from litellm/proxy/_experimental/out/organizations.html rename to litellm/proxy/_experimental/out/organizations/index.html diff --git a/litellm/proxy/_experimental/out/playground.html b/litellm/proxy/_experimental/out/playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/playground.html rename to litellm/proxy/_experimental/out/playground/index.html diff --git a/litellm/proxy/_experimental/out/policies.html b/litellm/proxy/_experimental/out/policies/index.html similarity index 100% rename from litellm/proxy/_experimental/out/policies.html rename to litellm/proxy/_experimental/out/policies/index.html diff --git a/litellm/proxy/_experimental/out/settings/admin-settings.html b/litellm/proxy/_experimental/out/settings/admin-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/admin-settings.html rename to litellm/proxy/_experimental/out/settings/admin-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/logging-and-alerts.html rename to litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html diff --git a/litellm/proxy/_experimental/out/settings/router-settings.html b/litellm/proxy/_experimental/out/settings/router-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/router-settings.html rename to litellm/proxy/_experimental/out/settings/router-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/ui-theme.html b/litellm/proxy/_experimental/out/settings/ui-theme/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/ui-theme.html rename to litellm/proxy/_experimental/out/settings/ui-theme/index.html diff --git a/litellm/proxy/_experimental/out/skills.html b/litellm/proxy/_experimental/out/skills/index.html similarity index 100% rename from litellm/proxy/_experimental/out/skills.html rename to litellm/proxy/_experimental/out/skills/index.html diff --git a/litellm/proxy/_experimental/out/teams.html b/litellm/proxy/_experimental/out/teams/index.html similarity index 100% rename from litellm/proxy/_experimental/out/teams.html rename to litellm/proxy/_experimental/out/teams/index.html diff --git a/litellm/proxy/_experimental/out/test-key.html b/litellm/proxy/_experimental/out/test-key/index.html similarity index 100% rename from litellm/proxy/_experimental/out/test-key.html rename to litellm/proxy/_experimental/out/test-key/index.html diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers.html b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/mcp-servers.html rename to litellm/proxy/_experimental/out/tools/mcp-servers/index.html diff --git a/litellm/proxy/_experimental/out/tools/vector-stores.html b/litellm/proxy/_experimental/out/tools/vector-stores/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/vector-stores.html rename to litellm/proxy/_experimental/out/tools/vector-stores/index.html diff --git a/litellm/proxy/_experimental/out/usage.html b/litellm/proxy/_experimental/out/usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/usage.html rename to litellm/proxy/_experimental/out/usage/index.html diff --git a/litellm/proxy/_experimental/out/users.html b/litellm/proxy/_experimental/out/users/index.html similarity index 100% rename from litellm/proxy/_experimental/out/users.html rename to litellm/proxy/_experimental/out/users/index.html diff --git a/litellm/proxy/_experimental/out/virtual-keys.html b/litellm/proxy/_experimental/out/virtual-keys/index.html similarity index 100% rename from litellm/proxy/_experimental/out/virtual-keys.html rename to litellm/proxy/_experimental/out/virtual-keys/index.html diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index 9f034575222..a70c5b3a920 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -84,6 +84,11 @@ LAZY_FEATURES: Tuple[LazyFeature, ...] = ( module_path="litellm.proxy.agent_endpoints.endpoints", path_prefixes=("/v1/agents", "/agents", "/agent/"), ), + LazyFeature( + name="gemini_agents", + module_path="litellm.proxy.google_endpoints.agents_endpoints", + path_prefixes=("/v1beta/agents",), + ), LazyFeature( name="a2a", module_path="litellm.proxy.agent_endpoints.a2a_endpoints", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index eea6974193f..27cdc483d4a 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3171,7 +3171,7 @@ ] }, "post": { - "description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/agents\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent\": {\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }\n }'\n```", + "description": "Create a new agent\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent_name\": \"my-custom-agent\",\n \"agent_card_params\": {\n \"protocolVersion\": \"1.0\",\n \"name\": \"Hello World Agent\",\n \"description\": \"Just a hello world agent\",\n \"url\": \"http://localhost:9999/\",\n \"version\": \"1.0.0\",\n \"defaultInputModes\": [\"text\"],\n \"defaultOutputModes\": [\"text\"],\n \"capabilities\": {\n \"streaming\": true\n },\n \"skills\": [\n {\n \"id\": \"hello_world\",\n \"name\": \"Returns hello world\",\n \"description\": \"just returns hello world\",\n \"tags\": [\"hello world\"],\n \"examples\": [\"hi\", \"hello world\"]\n }\n ]\n },\n \"litellm_params\": {\n \"make_public\": true\n }\n }'\n```", "operationId": "create_agent_v1_agents_post", "requestBody": { "content": { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c989f5dff13..004f33e630a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -481,6 +481,10 @@ class LiteLLMRoutes(enum.Enum): "/v1beta/interactions/{interaction_id}", "/interactions/{interaction_id}/cancel", "/v1beta/interactions/{interaction_id}/cancel", + # Google Managed Agents API + "/v1beta/agents", + "/v1beta/agents/{name}", + "/v1beta/agents/{name}/versions", ] apply_guardrail_routes = [ @@ -2357,6 +2361,30 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): database_connection_timeout: Optional[float] = Field( 60, description="default timeout for a connection to the database" ) + database_connect_timeout: Optional[float] = Field( + None, + description=( + "Prisma `connect_timeout` URL param (seconds). Bounds how long the " + "engine waits to establish a new connection before failing. Defaults " + "to Prisma's built-in value when unset." + ), + ) + database_socket_timeout: Optional[float] = Field( + None, + description=( + "Prisma `socket_timeout` URL param (seconds). When set, an idle/slow " + "connection that has not produced data within this window is closed. " + "This is the main knob for capping idle DB connections from LiteLLM." + ), + ) + database_extra_connection_params: Optional[Dict[str, Any]] = Field( + None, + description=( + "Escape hatch: extra key/value pairs appended verbatim to the Prisma " + "DATABASE_URL / DIRECT_URL query string (e.g. `sslmode`, `pgbouncer`, " + "`statement_cache_size`). Keys here override any default LiteLLM sets." + ), + ) database_type: Optional[Literal["dynamo_db"]] = Field( None, description="to use dynamodb instead of postgres db" ) diff --git a/litellm/proxy/agent_endpoints/utils.py b/litellm/proxy/agent_endpoints/utils.py index 2b968de54be..393f5934fd9 100644 --- a/litellm/proxy/agent_endpoints/utils.py +++ b/litellm/proxy/agent_endpoints/utils.py @@ -2,6 +2,12 @@ from typing import Dict, Mapping, Optional +# Re-export from the canonical SDK location so the proxy and SDK always +# share the same provider-config lookup logic. +from litellm.interactions.agents.utils import ( # noqa: F401 + get_provider_agents_api_config, +) + def merge_agent_headers( *, diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 13381c7a6c9..09bb8057203 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -1187,6 +1187,127 @@ async def get_end_user_object( return None +_END_USER_VALIDATION_NEGATIVE_TTL = 60 +_END_USER_VALIDATION_POSITIVE_TTL = 300 + + +async def resolve_and_validate_end_user_id( + raw_end_user_id: Optional[str], + prisma_client: Optional[PrismaClient], + user_api_key_cache: UserApiKeyCache, + parent_otel_span: Optional[Span] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, + route: str = "", +) -> Optional[str]: + """Optionally drop end-user ids that don't resolve to a known DB row. + + Default: pass-through. LiteLLM's documented pattern is that the `user` + field is an arbitrary caller-supplied identifier, so validation is + opt-in behind ``litellm.validate_end_user_id_in_db`` to preserve + backwards compatibility. + + When the flag is set: accept the id when it matches any of + - LiteLLM_EndUserTable.user_id + - LiteLLM_UserTable.user_id + - LiteLLM_UserTable.user_email (case-insensitive) + + If the id doesn't match but ``litellm.max_end_user_budget_id`` is set, + we still preserve the id so the default end-user budget is applied + downstream; otherwise we return None. + + DB lookups reuse ``get_end_user_object`` / ``get_user_object`` so they + share the same cache as the rest of the auth path instead of adding new + raw Prisma queries. + """ + if raw_end_user_id is None: + return None + if not litellm.validate_end_user_id_in_db: + return raw_end_user_id + if prisma_client is None: + return raw_end_user_id + + cache_key = f"end_user_validation:{raw_end_user_id}" + cached = await user_api_key_cache.async_get_cache(key=cache_key) + if cached == "valid": + return raw_end_user_id + if cached == "invalid": + return raw_end_user_id if litellm.max_end_user_budget_id else None + + is_valid = await _end_user_id_exists_in_db( + end_user_id=raw_end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + ) + + await user_api_key_cache.async_set_cache( + key=cache_key, + value="valid" if is_valid else "invalid", + ttl=( + _END_USER_VALIDATION_POSITIVE_TTL + if is_valid + else _END_USER_VALIDATION_NEGATIVE_TTL + ), + ) + + if is_valid: + return raw_end_user_id + # Preserve id so the caller can still apply litellm.max_end_user_budget_id. + if litellm.max_end_user_budget_id: + return raw_end_user_id + return None + + +async def _end_user_id_exists_in_db( + end_user_id: str, + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + parent_otel_span: Optional[Span] = None, + proxy_logging_obj: Optional[ProxyLogging] = None, + route: str = "", +) -> bool: + """True when the id matches an EndUser, User, or user_email row.""" + try: + end_user_obj = await get_end_user_object( + end_user_id=end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + ) + if end_user_obj is not None: + return True + except litellm.BudgetExceededError: + raise + except Exception as e: + verbose_proxy_logger.debug( + f"end_user validation: get_end_user_object lookup failed: {e}" + ) + + try: + user_obj = await get_user_object( + user_id=end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + check_db_only=False, + user_email=end_user_id if "@" in end_user_id else None, + ) + if user_obj is not None: + return True + except Exception as e: + verbose_proxy_logger.debug( + f"end_user validation: get_user_object lookup failed: {e}" + ) + + return False + + @log_db_metrics async def get_tag_objects_batch( tag_names: List[str], diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 637a4a070c4..c4dcca764b2 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -10,6 +10,7 @@ import litellm from litellm import Router, provider_list from litellm._logging import verbose_proxy_logger from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS +from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.proxy._types import * from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS @@ -1008,12 +1009,47 @@ def _get_customer_id_from_standard_headers( for standard_header in STANDARD_CUSTOMER_ID_HEADERS: for header_name, header_value in request_headers.items(): if header_name.lower() == standard_header.lower(): - user_id_str = str(header_value) if header_value is not None else "" - if user_id_str.strip(): + user_id_str = _coerce_user_id_to_str(header_value) + if user_id_str: return user_id_str return None +def _coerce_user_id_to_str(value: Any) -> Optional[str]: + """Return a usable end-user identifier string, or None if the value isn't one. + + Always drops non-string structured values (dict/list/tuple/set) because + stringifying them produces garbage spend-log rows like + ``"{'device_id': ...}"``. Strings that *decode* to a structured payload + are only rejected when ``litellm.validate_end_user_id_in_db`` is enabled + — operators who currently pass JSON-encoded identifiers keep their + existing behavior until they opt in. See + auth_utils.py:get_end_user_id_from_request_body for the extraction chain. + """ + if value is None: + return None + if isinstance(value, bool): + # bool is an int subclass; handle explicitly to avoid "True"/"False". + return None + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None + # Reject strings that decode to a structured payload (JSON object/array) + # only when the operator has opted into end-user validation. Gating + # behind the flag preserves backwards compatibility for deployments + # that intentionally pass JSON-encoded user identifiers. + if litellm.validate_end_user_id_in_db and stripped[:1] in ("{", "["): + parsed = safe_json_loads(stripped) + if isinstance(parsed, (dict, list)): + return None + return stripped + # dict, list, tuple, set, arbitrary objects -> drop. + return None + + def get_end_user_id_from_request_body( request_body: dict, request_headers: Optional[dict] = None ) -> Optional[str]: @@ -1052,23 +1088,22 @@ def get_end_user_id_from_request_body( if isinstance(custom_header_name_to_check, list): headers_lower = {k.lower(): v for k, v in request_headers.items()} for expected_header in custom_header_name_to_check: - header_value = headers_lower.get(expected_header) - if header_value is not None: - user_id_str = str(header_value) - if user_id_str.strip(): - return user_id_str + user_id_str = _coerce_user_id_to_str(headers_lower.get(expected_header)) + if user_id_str: + return user_id_str elif isinstance(custom_header_name_to_check, str): for header_name, header_value in request_headers.items(): if header_name.lower() == custom_header_name_to_check.lower(): - user_id_str = str(header_value) if header_value is not None else "" - if user_id_str.strip(): + user_id_str = _coerce_user_id_to_str(header_value) + if user_id_str: return user_id_str # Check 3: 'user' field in request_body (commonly OpenAI) - if "user" in request_body and request_body["user"] is not None: - user_from_body_user_field = request_body["user"] - return str(user_from_body_user_field) + if "user" in request_body: + user_id_str = _coerce_user_id_to_str(request_body["user"]) + if user_id_str: + return user_id_str def _as_dict(value: Any) -> dict: # metadata / litellm_metadata can arrive as JSON strings from @@ -1077,32 +1112,30 @@ def get_end_user_id_from_request_body( if isinstance(value, dict): return value if isinstance(value, str): - from litellm.litellm_core_utils.safe_json_loads import safe_json_loads - parsed = safe_json_loads(value) return parsed if isinstance(parsed, dict) else {} return {} # Check 4: 'litellm_metadata.user' in request_body (commonly Anthropic) litellm_metadata = _as_dict(request_body.get("litellm_metadata")) - user_from_litellm_metadata = litellm_metadata.get("user") - if user_from_litellm_metadata is not None: - return str(user_from_litellm_metadata) + user_id_str = _coerce_user_id_to_str(litellm_metadata.get("user")) + if user_id_str: + return user_id_str # Check 5: 'metadata.user_id' in request_body (another common pattern) metadata_dict = _as_dict(request_body.get("metadata")) - user_id_from_metadata_field = metadata_dict.get("user_id") - if user_id_from_metadata_field is not None: - return str(user_id_from_metadata_field) + user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id")) + if user_id_str: + return user_id_str # Check 6: 'safety_identifier' in request body (OpenAI Responses API parameter) # SECURITY NOTE: safety_identifier can be set by any caller in the request body. # Only use this for end-user identification in trusted environments where you control # the calling application. For untrusted callers, prefer using headers or server-side # middleware to set the end_user_id to prevent impersonation. - if request_body.get("safety_identifier") is not None: - user_from_body_user_field = request_body["safety_identifier"] - return str(user_from_body_user_field) + user_id_str = _coerce_user_id_to_str(request_body.get("safety_identifier")) + if user_id_str: + return user_id_str return None diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index bf76f99db69..d364b52c676 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -4,12 +4,15 @@ from typing import Dict, List, Optional, Set import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth from litellm.router import Router from litellm.router_utils.fallback_event_handlers import get_fallback_model_group -from litellm.types.router import LiteLLM_Params +from litellm.types.router import CredentialLiteLLMParams, LiteLLM_Params from litellm.utils import get_valid_models +_CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields) + def _check_wildcard_routing(model: str) -> bool: """ @@ -178,6 +181,7 @@ def get_complete_model_list( model_access_groups: Dict[str, List[str]] = {}, include_model_access_groups: Optional[bool] = False, only_model_access_groups: Optional[bool] = False, + team_id: Optional[str] = None, ) -> List[str]: """Logic for returning complete model list for a given key + team pair""" @@ -222,6 +226,7 @@ def get_complete_model_list( unique_models=unique_models, return_wildcard_routes=return_wildcard_routes, llm_router=llm_router, + team_id=team_id, ) complete_model_list = unique_models + all_wildcard_models @@ -229,6 +234,29 @@ def get_complete_model_list( return complete_model_list +def _hydrate_litellm_credential_name( + litellm_params: Optional[LiteLLM_Params], +) -> Optional[LiteLLM_Params]: + if litellm_params is None or litellm_params.litellm_credential_name is None: + return litellm_params + + credential_values = CredentialAccessor.get_credential_values( + litellm_params.litellm_credential_name + ) + if not credential_values: + return litellm_params + + litellm_params = litellm_params.model_copy() + for key, value in credential_values.items(): + if ( + key in _CREDENTIAL_LITELLM_PARAM_FIELDS + and getattr(litellm_params, key, None) is None + ): + setattr(litellm_params, key, value) + litellm_params.litellm_credential_name = None + return litellm_params + + def get_known_models_from_wildcard( wildcard_model: str, litellm_params: Optional[LiteLLM_Params] = None ) -> List[str]: @@ -247,7 +275,7 @@ def get_known_models_from_wildcard( else: provider = wildcard_provider_prefix - # get all known provider models + litellm_params = _hydrate_litellm_credential_name(litellm_params) wildcard_models = get_provider_models( provider=provider, litellm_params=litellm_params @@ -285,6 +313,7 @@ def _get_wildcard_models( unique_models: List[str], return_wildcard_routes: Optional[bool] = False, llm_router: Optional[Router] = None, + team_id: Optional[str] = None, ) -> List[str]: models_to_remove = set() all_wildcard_models = [] @@ -297,7 +326,9 @@ def _get_wildcard_models( ## get litellm params from model if llm_router is not None: - model_list = llm_router.get_model_list(model_name=model) + model_list = llm_router.get_model_list( + model_name=model, team_id=team_id + ) if model_list: for router_model in model_list: wildcard_models = get_known_models_from_wildcard( diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 30b5d36e14a..6974860a22a 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -12,7 +12,7 @@ import fnmatch import re import secrets from datetime import datetime, timezone -from typing import Any, Iterator, List, Optional, Tuple, Union, cast +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast import fastapi from fastapi import HTTPException, Request, WebSocket, status @@ -44,6 +44,7 @@ from litellm.proxy.auth.auth_checks import ( get_team_object, get_user_object, is_valid_fallback_model, + resolve_and_validate_end_user_id, ) from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler from litellm.proxy.auth.auth_utils import ( @@ -333,8 +334,22 @@ def _apply_budget_limits_to_end_user_params( async def user_api_key_auth_websocket(websocket: WebSocket): # Accept the WebSocket connection - scope_headers = list(websocket.scope.get("headers") or []) - request = Request(scope={"type": "http", "headers": scope_headers}) + ws_scope = websocket.scope or {} + scope_headers = list(ws_scope.get("headers") or []) + # ``get_request_route`` falls back to ``request.url.path`` when + # ``scope["path"]`` is absent. On WebSockets that fallback reads + # ``websocket.url``, which Starlette reconstructs from the (poisonable) + # Host header. Carry the ASGI scope's path / root_path so the lookup + # never reaches the fallback. + synthetic_scope: Dict[str, Any] = { + "type": "http", + "headers": scope_headers, + "path": ws_scope.get("path", ""), + } + for key in ("root_path", "app_root_path"): + if key in ws_scope: + synthetic_scope[key] = ws_scope[key] + request = Request(scope=synthetic_scope) request._url = websocket.url @@ -1057,9 +1072,17 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 _end_user_object = None end_user_params = {} - end_user_id = get_end_user_id_from_request_body( + raw_end_user_id = get_end_user_id_from_request_body( request_data, _safe_get_request_headers(request) ) + end_user_id = await resolve_and_validate_end_user_id( + raw_end_user_id=raw_end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + ) if end_user_id: try: end_user_params["end_user_id"] = end_user_id @@ -1745,7 +1768,7 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached @tracer.wrap() -async def _run_centralized_common_checks( +async def _run_centralized_common_checks( # noqa: PLR0915 user_api_key_auth_obj: UserAPIKeyAuth, request: Request, request_data: dict, @@ -1823,9 +1846,23 @@ async def _run_centralized_common_checks( return parent_otel_span = user_api_key_auth_obj.parent_otel_span - end_user_id = get_end_user_id_from_request_body( - request_data, _safe_get_request_headers(request) - ) + # In the integrated auth flow ``_user_api_key_auth_builder`` has already + # resolved the end-user id and attached it here. Reuse that to avoid a + # second extraction pass; fall back to extracting locally when the + # function is invoked in isolation (e.g. in direct unit tests). + end_user_id = user_api_key_auth_obj.end_user_id + if end_user_id is None: + raw_end_user_id = get_end_user_id_from_request_body( + request_data, _safe_get_request_headers(request) + ) + end_user_id = await resolve_and_validate_end_user_id( + raw_end_user_id=raw_end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + ) fetch_coros = [] if user_api_key_auth_obj.team_id is not None: @@ -2156,11 +2193,33 @@ async def user_api_key_auth( api_key=api_key, ) - end_user_id = get_end_user_id_from_request_body( - request_data, _safe_get_request_headers(request) - ) - if end_user_id is not None: - user_api_key_auth_obj.end_user_id = end_user_id + # Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return + # paths (no master key, /user/auth route, JWT short-circuits) that bypass + # the end-user resolution block. If those paths produced an auth obj + # without an ``end_user_id`` set, fall back to extracting from the request + # body so spend logs are still attributed correctly. Validation honours + # ``litellm.validate_end_user_id_in_db``. + if user_api_key_auth_obj.end_user_id is None: + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + raw_end_user_id = get_end_user_id_from_request_body( + request_data, _safe_get_request_headers(request) + ) + if raw_end_user_id is not None: + resolved_end_user_id = await resolve_and_validate_end_user_id( + raw_end_user_id=raw_end_user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth_obj.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + ) + if resolved_end_user_id is not None: + user_api_key_auth_obj.end_user_id = resolved_end_user_id user_api_key_auth_obj.request_route = normalize_request_route(route) return user_api_key_auth_obj diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 935b96a0e39..166ef7a66d0 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -523,6 +523,10 @@ async def retrieve_batch( # noqa: PLR0915 custom_llm_provider=custom_llm_provider, **data # type: ignore ) + response = await proxy_logging_obj.post_call_success_hook( + data=data, user_api_key_dict=user_api_key_dict, response=response + ) + # FIX: Update the database with the latest state from provider await update_batch_in_database( batch_id=batch_id, @@ -533,19 +537,9 @@ async def retrieve_batch( # noqa: PLR0915 verbose_proxy_logger=verbose_proxy_logger, db_batch_object=db_batch_object, operation="retrieve", + user_api_key_dict=user_api_key_dict, ) - ### CALL HOOKS ### - modify outgoing data - response = await proxy_logging_obj.post_call_success_hook( - data=data, user_api_key_dict=user_api_key_dict, response=response - ) - - # Fix: bug_feb14_batch_retrieve_returns_raw_input_file_id - # Resolve raw provider file IDs (input, output, error) to unified IDs. - if unified_batch_id: - await resolve_input_file_id_to_unified(response, prisma_client) - await resolve_output_file_ids_to_unified(response, prisma_client) - ### ALERTING ### asyncio.create_task( proxy_logging_obj.update_request_status( @@ -917,10 +911,14 @@ async def cancel_batch( **_cancel_batch_data, ) - # FIX: Update the database with the new cancelled state managed_files_obj = proxy_logging_obj.get_proxy_hook("managed_files") from litellm.proxy.proxy_server import prisma_client + response = await proxy_logging_obj.post_call_success_hook( + data=data, user_api_key_dict=user_api_key_dict, response=response + ) + + # FIX: Update the database with the new cancelled state await update_batch_in_database( batch_id=batch_id, unified_batch_id=unified_batch_id, @@ -929,11 +927,7 @@ async def cancel_batch( prisma_client=prisma_client, verbose_proxy_logger=verbose_proxy_logger, operation="cancel", - ) - - ### CALL HOOKS ### - modify outgoing data - response = await proxy_logging_obj.post_call_success_hook( - data=data, user_api_key_dict=user_api_key_dict, response=response + user_api_key_dict=user_api_key_dict, ) ### ALERTING ### diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 038d2d81277..7d2954fd2dd 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -807,6 +807,11 @@ class ProxyBaseLLMRequestProcessing: "aget_interaction", "adelete_interaction", "acancel_interaction", + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", "asend_message", "call_mcp_tool", "acreate_eval", @@ -1074,6 +1079,11 @@ class ProxyBaseLLMRequestProcessing: "aget_interaction", "adelete_interaction", "acancel_interaction", + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", "asend_message", "call_mcp_tool", "acreate_eval", diff --git a/litellm/proxy/common_utils/custom_openapi_spec.py b/litellm/proxy/common_utils/custom_openapi_spec.py index a93749c3952..fa3cb02195b 100644 --- a/litellm/proxy/common_utils/custom_openapi_spec.py +++ b/litellm/proxy/common_utils/custom_openapi_spec.py @@ -324,7 +324,7 @@ class CustomOpenAPISpec: @staticmethod def add_chat_completion_request_schema( - openapi_schema: Dict[str, Any] + openapi_schema: Dict[str, Any], ) -> Dict[str, Any]: """ Add ProxyChatCompletionRequest schema to chat completion endpoints for documentation. @@ -380,7 +380,7 @@ class CustomOpenAPISpec: @staticmethod def add_responses_api_request_schema( - openapi_schema: Dict[str, Any] + openapi_schema: Dict[str, Any], ) -> Dict[str, Any]: """ Add ResponsesAPIRequestParams schema to responses API endpoints for documentation. @@ -410,7 +410,7 @@ class CustomOpenAPISpec: @staticmethod def add_llm_api_request_schema_body( - openapi_schema: Dict[str, Any] + openapi_schema: Dict[str, Any], ) -> Dict[str, Any]: """ Add LLM API request schema bodies to OpenAPI specification for documentation. diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 71abdfa5e9e..2ce3fda6297 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -12,6 +12,33 @@ from litellm.proxy.common_utils.callback_utils import ( ) from litellm.types.router import Deployment +_FORM_CONTENT_TYPES: frozenset[str] = frozenset( + {"application/x-www-form-urlencoded", "multipart/form-data"} +) + + +def _normalize_media_type(content_type: str) -> str: + """Return the bare media type per RFC 7231: strip params, trim, lowercase.""" + if not content_type: + return "" + return content_type.split(";", 1)[0].strip().lower() + + +def _is_form_content_type(content_type: str) -> bool: + """ + True iff Starlette's ``request.form()`` will actually parse this body. + + Substring matching ``"form"`` is unsafe: ``request.form()`` returns empty + ``FormData`` for non-canonical types without consuming the body, leaving + the auth-time pre-read and the handler's read seeing different payloads. + """ + return _normalize_media_type(content_type) in _FORM_CONTENT_TYPES + + +def _is_json_content_type(content_type: str) -> bool: + """True iff the body should be parsed as JSON.""" + return _normalize_media_type(content_type) == "application/json" + async def _read_request_body(request: Optional[Request]) -> Dict: """ @@ -37,8 +64,24 @@ async def _read_request_body(request: Optional[Request]) -> Dict: _request_headers: dict = _safe_get_request_headers(request=request) content_type = _request_headers.get("content-type", "") - if "form" in content_type: - parsed_body = dict(await request.form()) + if _is_form_content_type(content_type): + try: + form_data = await request.form() + except Exception as e: + # ``request.form()`` raises on malformed multipart (missing + # boundary, malformed chunk encoding, …). Surface as 400 so + # the auth-time pre-read does not silently cache ``{}`` while + # a later raw-body re-read sees the original payload — + # banned-param checks must see the same body the handler + # acts on. + verbose_proxy_logger.error(f"Invalid form payload: {e}") + raise ProxyException( + message=f"Invalid form payload: {e}", + type="invalid_request_error", + param="request_body", + code=status.HTTP_400_BAD_REQUEST, + ) + parsed_body = dict(form_data) if "metadata" in parsed_body and isinstance(parsed_body["metadata"], str): parsed_body["metadata"] = json.loads(parsed_body["metadata"]) else: @@ -257,7 +300,7 @@ async def get_form_data(request: Request) -> Dict[str, Any]: async def convert_upload_files_to_file_data( - form_data: Dict[str, Any] + form_data: Dict[str, Any], ) -> Dict[str, Any]: """ Convert FastAPI UploadFile objects to file data tuples for litellm. @@ -306,18 +349,13 @@ async def get_request_body(request: Request) -> Dict[str, Any]: Read the request body and parse it as JSON. """ if request.method == "POST": - if request.headers.get("content-type", "") == "application/json": + content_type = request.headers.get("content-type", "") + if _is_json_content_type(content_type): return await _read_request_body(request) - elif "multipart/form-data" in request.headers.get( - "content-type", "" - ) or "application/x-www-form-urlencoded" in request.headers.get( - "content-type", "" - ): + elif _is_form_content_type(content_type): return await get_form_data(request) else: - raise ValueError( - f"Unsupported content type: {request.headers.get('content-type')}" - ) + raise ValueError(f"Unsupported content type: {content_type}") return {} diff --git a/litellm/proxy/common_utils/openai_endpoint_utils.py b/litellm/proxy/common_utils/openai_endpoint_utils.py index c4bfe11aec1..905967fa465 100644 --- a/litellm/proxy/common_utils/openai_endpoint_utils.py +++ b/litellm/proxy/common_utils/openai_endpoint_utils.py @@ -1,5 +1,5 @@ """ -Contains utils used by OpenAI compatible endpoints +Contains utils used by OpenAI compatible endpoints """ from typing import Optional, Set diff --git a/litellm/proxy/config_management_endpoints/pass_through_endpoints.py b/litellm/proxy/config_management_endpoints/pass_through_endpoints.py index 5ff02b8bce0..4ebd989dc53 100644 --- a/litellm/proxy/config_management_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/config_management_endpoints/pass_through_endpoints.py @@ -1,5 +1,5 @@ """ -What is this? +What is this? CRUD endpoints for managing pass-through endpoints """ diff --git a/litellm/proxy/container_endpoints/endpoints.py b/litellm/proxy/container_endpoints/endpoints.py index 9650604bf81..fc1f77bb684 100644 --- a/litellm/proxy/container_endpoints/endpoints.py +++ b/litellm/proxy/container_endpoints/endpoints.py @@ -328,7 +328,7 @@ async def retrieve_container( custom_llm_provider=custom_llm_provider, ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id, original_container_id, custom_llm_provider, @@ -433,7 +433,7 @@ async def delete_container( custom_llm_provider=custom_llm_provider, ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id, original_container_id, custom_llm_provider, diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index 4284cdd5d4a..7eeb11fc372 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -196,10 +196,12 @@ async def _process_binary_request( ) data: Dict[str, Any] = { "file_id": file_id, - **get_container_forwarding_params( - container_id=container_id, - original_container_id=original_container_id, - custom_llm_provider=resolved_provider, + **( + await get_container_forwarding_params( + container_id=container_id, + original_container_id=original_container_id, + custom_llm_provider=resolved_provider, + ) ), } processor = ProxyBaseLLMRequestProcessing(data=data) @@ -316,7 +318,7 @@ async def _process_multipart_upload_request( ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id=container_id, original_container_id=original_container_id, custom_llm_provider=resolved_provider, @@ -396,7 +398,7 @@ async def _process_request( ) ) data.update( - get_container_forwarding_params( + await get_container_forwarding_params( container_id=path_params["container_id"], original_container_id=original_container_id, custom_llm_provider=resolved_provider, diff --git a/litellm/proxy/container_endpoints/ownership.py b/litellm/proxy/container_endpoints/ownership.py index 568eca523ae..57de6c4a63d 100644 --- a/litellm/proxy/container_endpoints/ownership.py +++ b/litellm/proxy/container_endpoints/ownership.py @@ -23,6 +23,13 @@ CONTAINER_OBJECT_PURPOSE = "container" _NEGATIVE_OWNER_SENTINEL = "__litellm_container_no_owner__" _CONTAINER_OWNER_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60) +# Caches the stored ``unified_object_id`` (the encoded container ID +# captured at create time) so ``get_container_forwarding_params`` can +# recover the deployment ``model_id`` for native upstream IDs without +# re-hitting Prisma on every retrieve/delete. +_NEGATIVE_STORED_ID_SENTINEL = "__litellm_container_no_stored_id__" +_CONTAINER_STORED_ID_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60) + # Per-caller-scope cache for ``GET /v1/containers`` list filtering. Without # this, every list call issues a fresh ``find_many`` against # ``litellm_managedobjecttable``. The cache key is the sorted owner-scope @@ -56,7 +63,7 @@ def decode_container_id_for_ownership( return original_container_id, custom_llm_provider -def get_container_forwarding_params( +async def get_container_forwarding_params( container_id: str, original_container_id: str, custom_llm_provider: str ) -> Dict[str, str]: params = { @@ -65,6 +72,20 @@ def get_container_forwarding_params( } decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) model_id = decoded.get("model_id") + if not (isinstance(model_id, str) and model_id): + # Native upstream IDs (e.g. Azure ``cntr_``) carry no LiteLLM + # routing payload, so decoding the user-supplied id yields no + # ``model_id``. Recover it from the encoded ``unified_object_id`` + # captured on the ownership row at create time — when the router + # selected a specific deployment that ID embeds the model_id. + stored_id = await _get_stored_container_id( + original_container_id, custom_llm_provider + ) + if stored_id and stored_id != container_id: + stored_decoded = ResponsesAPIRequestUtils._decode_container_id(stored_id) + stored_model_id = stored_decoded.get("model_id") + if isinstance(stored_model_id, str) and stored_model_id: + model_id = stored_model_id if isinstance(model_id, str) and model_id: params["model_id"] = model_id return params @@ -168,6 +189,7 @@ async def record_container_owner( ) _CONTAINER_OWNER_CACHE.set_cache(model_object_id, owner) + _CONTAINER_STORED_ID_CACHE.set_cache(model_object_id, container_id) # Drop the caller's own list-cache entry so the just-created container # shows up on their next ``GET /v1/containers``. Other callers with # disjoint scope tuples have their own entries; intersecting-scope @@ -207,9 +229,60 @@ async def _get_container_owner( _CONTAINER_OWNER_CACHE.set_cache( model_object_id, owner if owner is not None else _NEGATIVE_OWNER_SENTINEL ) + stored_id = getattr(row, "unified_object_id", None) if row is not None else None + _CONTAINER_STORED_ID_CACHE.set_cache( + model_object_id, + ( + stored_id + if isinstance(stored_id, str) and stored_id + else _NEGATIVE_STORED_ID_SENTINEL + ), + ) return owner +async def _get_stored_container_id( + original_container_id: str, custom_llm_provider: str +) -> Optional[str]: + """Return the ``unified_object_id`` stored at create time, if any. + + Used by :func:`get_container_forwarding_params` to recover the + deployment ``model_id`` for native upstream container IDs: the stored + value is the encoded form produced by ``encode_container_id_in_response`` + when the router selected a specific deployment. + """ + model_object_id = _container_model_object_id( + original_container_id, custom_llm_provider + ) + + cached = _CONTAINER_STORED_ID_CACHE.get_cache(model_object_id) + if cached == _NEGATIVE_STORED_ID_SENTINEL: + return None + if isinstance(cached, str) and cached: + return cached + + prisma_client = await _get_prisma_client() + if prisma_client is None: + return None + + row = await prisma_client.db.litellm_managedobjecttable.find_first( + where={ + "model_object_id": model_object_id, + "file_purpose": CONTAINER_OBJECT_PURPOSE, + } + ) + stored_id = getattr(row, "unified_object_id", None) if row is not None else None + _CONTAINER_STORED_ID_CACHE.set_cache( + model_object_id, + ( + stored_id + if isinstance(stored_id, str) and stored_id + else _NEGATIVE_STORED_ID_SENTINEL + ), + ) + return stored_id if isinstance(stored_id, str) and stored_id else None + + async def assert_user_can_access_container( container_id: str, user_api_key_dict: UserAPIKeyAuth, diff --git a/litellm/proxy/db/create_views.py b/litellm/proxy/db/create_views.py index d84cebcf05a..97525a528d0 100644 --- a/litellm/proxy/db/create_views.py +++ b/litellm/proxy/db/create_views.py @@ -34,8 +34,7 @@ async def create_missing_views(db: _db): # noqa: PLR0915 if not any(marker in error_msg for marker in _VIEW_NOT_FOUND_MARKERS): raise # If an error occurs, the view does not exist, so create it - await db.execute_raw( - """ + await db.execute_raw(""" CREATE VIEW "LiteLLM_VerificationTokenView" AS SELECT v.*, @@ -47,8 +46,7 @@ async def create_missing_views(db: _db): # noqa: PLR0915 FROM "LiteLLM_VerificationToken" v LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id LEFT JOIN "LiteLLM_ProjectTable" p ON v.project_id = p.project_id; - """ - ) + """) verbose_logger.debug("LiteLLM_VerificationTokenView Created!") diff --git a/litellm/proxy/db/spend_counter_reseed.py b/litellm/proxy/db/spend_counter_reseed.py index 19ec6699390..e7c5fa3f72c 100644 --- a/litellm/proxy/db/spend_counter_reseed.py +++ b/litellm/proxy/db/spend_counter_reseed.py @@ -178,15 +178,28 @@ class SpendCounterReseed: if db_spend is None: return None # Warm even when 0 so subsequent reads hit cache, not DB. + # + # Seed via SET NX (cross-pod safe): only one pod initializes the + # Redis key with db_spend; concurrent seeders read the winner's + # value. INCRBYFLOAT-of-db_spend from N pods would multiply the + # counter (N x db_spend) and trigger spurious budget alerts. + current_value: float = float(db_spend) try: if spend_counter_cache.redis_cache is not None: - current_value = ( - await spend_counter_cache.redis_cache.async_increment( - key=counter_key, - value=db_spend, - refresh_ttl=True, - ) + seeded = await spend_counter_cache.redis_cache.async_set_cache( + key=counter_key, + value=db_spend, + nx=True, ) + if seeded: + current_value = float(db_spend) + else: + cached = await spend_counter_cache.redis_cache.async_get_cache( + key=counter_key + ) + current_value = ( + float(cached) if cached is not None else float(db_spend) + ) spend_counter_cache.in_memory_cache.set_cache( key=counter_key, value=current_value, @@ -202,7 +215,7 @@ class SpendCounterReseed: ) if require_cache_warm: raise - return db_spend + return current_value @staticmethod async def window_from_spend_logs( diff --git a/litellm/proxy/google_endpoints/agents_endpoints.py b/litellm/proxy/google_endpoints/agents_endpoints.py new file mode 100644 index 00000000000..779284023a0 --- /dev/null +++ b/litellm/proxy/google_endpoints/agents_endpoints.py @@ -0,0 +1,445 @@ +""" +Google AI Studio Managed Agents API Proxy Endpoints. + +Exposes Gemini's /v1beta/agents surface through the LiteLLM proxy so that +user curl commands transfer 1-to-1 by swapping the host + auth header. + +Routes: + POST /v1beta/agents -> acreate_agent + GET /v1beta/agents -> alist_agents + GET /v1beta/agents/{name} -> aget_agent + DELETE /v1beta/agents/{name} -> adelete_agent + GET /v1beta/agents/{name}/versions -> alist_agent_versions + +These are distinct from the A2A agent registry at /v1/agents. +""" + +import json + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import ORJSONResponse + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.common_utils.http_parsing_utils import ( + _read_request_body, + _safe_get_request_query_params, +) + +router = APIRouter(tags=["gemini managed agents"]) + + +def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: + return ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) + + +def _enforce_caller_supplied_provider_key( + data: dict, + user_api_key_dict: UserAPIKeyAuth, +) -> None: + """ + SECURITY: refuse to use the proxy's shared GOOGLE_API_KEY / GEMINI_API_KEY + env fallback for non-admin callers on Gemini managed-agent CRUD endpoints. + + These endpoints are part of ``llm_api_routes`` so any authenticated LLM key + can reach them, but unlike ``/v1beta/models/...:generateContent`` they are + *not* routed through ``model_list`` — the only credential source is either + the per-request ``litellm_params_template`` or the env var fallback. Without + this guard, any ordinary proxy user could list, create, or delete managed + agents inside the operator's Gemini project using the operator's key. + + Proxy admins (master key) keep the env-fallback convenience for ops use. + """ + if _is_proxy_admin(user_api_key_dict): + return + if data.get("api_key"): + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=( + "Gemini managed-agent endpoints require a caller-supplied " + "Gemini api_key (via 'litellm_params_template'). Falling back to " + "the proxy's GOOGLE_API_KEY / GEMINI_API_KEY env vars is only " + "permitted for proxy admins." + ), + ) + + +def _merge_query_params_into_data(data: dict, request: Request) -> dict: + """ + For GET/DELETE endpoints that cannot carry a JSON body, read a + JSON-encoded ``litellm_params_template`` query parameter and merge its + contents into *data*, without overwriting keys that are already present + (e.g. path params like ``name`` or the fixed ``custom_llm_provider``). + + This mirrors the ``litellm_params_template`` handling in + ``create_gemini_agent`` and is the supported way for multi-tenant + callers to supply per-request credentials on non-POST endpoints: + + .. code-block:: bash + + curl "http://localhost:4000/v1beta/agents?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\ + -H "Authorization: Bearer sk-..." + + Credentials MUST NOT be passed as plain flat query parameters (e.g. + ``?api_key=AIza...``) because URL query strings appear verbatim in + web-server access logs, CDN edge logs, browser history, and Referer + headers. Use the ``litellm_params_template`` JSON body field on POST + requests, or the JSON-encoded query parameter above for GET/DELETE. + """ + query_params = _safe_get_request_query_params(request) + if not query_params: + return data + + raw_template = query_params.get("litellm_params_template") + if raw_template: + try: + template = ( + json.loads(raw_template) + if isinstance(raw_template, str) + else raw_template + ) + except (json.JSONDecodeError, ValueError): + template = {} + if isinstance(template, dict): + for key, value in template.items(): + data.setdefault(key, value) + + return data + + +def _proxy_server_imports(): + from litellm.proxy.proxy_server import ( # noqa: PLC0415 + general_settings, + llm_router, + proxy_config, + proxy_logging_obj, + select_data_generator, + user_api_base, + user_max_tokens, + user_model, + user_request_timeout, + user_temperature, + version, + ) + + return dict( + general_settings=general_settings, + llm_router=llm_router, + proxy_config=proxy_config, + proxy_logging_obj=proxy_logging_obj, + select_data_generator=select_data_generator, + user_api_base=user_api_base, + user_max_tokens=user_max_tokens, + user_model=user_model, + user_request_timeout=user_request_timeout, + user_temperature=user_temperature, + version=version, + ) + + +@router.post( + "/v1beta/agents", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, +) +async def create_gemini_agent( + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Create a named custom agent on the Gemini side. + + Example: + ```bash + curl -X POST "http://localhost:4000/v1beta/agents" \\ + -H "Authorization: Bearer sk-..." \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "my-custom-slides-agent", + "base_agent": "waverunner", + "instructions": "You are a helpful assistant that creates slides.", + "base_environment": { + "type": "remote", + "sources": [ + {"type": "gcs", "source": "gs://eap-templates/slides-skill", + "target": "/.agents/skills/slides-skill"} + ] + } + }' + ``` + """ + srv = _proxy_server_imports() + data = await _read_request_body(request=request) + # Merge litellm_params_template (e.g. custom_llm_provider, api_key) into the request + litellm_params_template = data.pop("litellm_params_template", None) or {} + if isinstance(litellm_params_template, dict): + for key, value in litellm_params_template.items(): + if key not in data: + data[key] = value + data.setdefault("custom_llm_provider", "gemini") + _enforce_caller_supplied_provider_key(data, user_api_key_dict) + + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="acreate_agent", + proxy_logging_obj=srv["proxy_logging_obj"], + llm_router=srv["llm_router"], + general_settings=srv["general_settings"], + proxy_config=srv["proxy_config"], + select_data_generator=srv["select_data_generator"], + model=None, + user_model=srv["user_model"], + user_temperature=srv["user_temperature"], + user_request_timeout=srv["user_request_timeout"], + user_max_tokens=srv["user_max_tokens"], + user_api_base=srv["user_api_base"], + version=srv["version"], + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=srv["proxy_logging_obj"], + version=srv["version"], + ) + + +@router.get( + "/v1beta/agents", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, +) +async def list_gemini_agents( + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + List all custom agents on the Gemini side. + + Pass per-request Gemini credentials via the JSON-encoded + ``litellm_params_template`` query parameter. Flat query parameters + (e.g. ``?api_key=AIza...``) are intentionally ignored — see + ``_merge_query_params_into_data`` for the rationale. + + ```bash + curl "http://localhost:4000/v1beta/agents?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\ + -H "Authorization: Bearer sk-..." + ``` + """ + srv = _proxy_server_imports() + data: dict = {"custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + _enforce_caller_supplied_provider_key(data, user_api_key_dict) + + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="alist_agents", + proxy_logging_obj=srv["proxy_logging_obj"], + llm_router=srv["llm_router"], + general_settings=srv["general_settings"], + proxy_config=srv["proxy_config"], + select_data_generator=srv["select_data_generator"], + model=None, + user_model=srv["user_model"], + user_temperature=srv["user_temperature"], + user_request_timeout=srv["user_request_timeout"], + user_max_tokens=srv["user_max_tokens"], + user_api_base=srv["user_api_base"], + version=srv["version"], + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=srv["proxy_logging_obj"], + version=srv["version"], + ) + + +@router.get( + "/v1beta/agents/{name}", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, +) +async def get_gemini_agent( + request: Request, + name: str, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get a specific custom agent by name. + + Pass per-request Gemini credentials via the JSON-encoded + ``litellm_params_template`` query parameter. Flat query parameters + (e.g. ``?api_key=AIza...``) are intentionally ignored — see + ``_merge_query_params_into_data`` for the rationale. + + ```bash + curl "http://localhost:4000/v1beta/agents/my-custom-slides-agent?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\ + -H "Authorization: Bearer sk-..." + ``` + """ + srv = _proxy_server_imports() + data = {"name": name, "custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + _enforce_caller_supplied_provider_key(data, user_api_key_dict) + + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="aget_agent", + proxy_logging_obj=srv["proxy_logging_obj"], + llm_router=srv["llm_router"], + general_settings=srv["general_settings"], + proxy_config=srv["proxy_config"], + select_data_generator=srv["select_data_generator"], + model=None, + user_model=srv["user_model"], + user_temperature=srv["user_temperature"], + user_request_timeout=srv["user_request_timeout"], + user_max_tokens=srv["user_max_tokens"], + user_api_base=srv["user_api_base"], + version=srv["version"], + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=srv["proxy_logging_obj"], + version=srv["version"], + ) + + +@router.delete( + "/v1beta/agents/{name}", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, +) +async def delete_gemini_agent( + request: Request, + name: str, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Delete a custom agent by name. + + Pass per-request Gemini credentials via the JSON-encoded + ``litellm_params_template`` query parameter. Flat query parameters + (e.g. ``?api_key=AIza...``) are intentionally ignored — see + ``_merge_query_params_into_data`` for the rationale. + + ```bash + curl -X DELETE "http://localhost:4000/v1beta/agents/my-custom-slides-agent?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\ + -H "Authorization: Bearer sk-..." + ``` + """ + srv = _proxy_server_imports() + data = {"name": name, "custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + _enforce_caller_supplied_provider_key(data, user_api_key_dict) + + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="adelete_agent", + proxy_logging_obj=srv["proxy_logging_obj"], + llm_router=srv["llm_router"], + general_settings=srv["general_settings"], + proxy_config=srv["proxy_config"], + select_data_generator=srv["select_data_generator"], + model=None, + user_model=srv["user_model"], + user_temperature=srv["user_temperature"], + user_request_timeout=srv["user_request_timeout"], + user_max_tokens=srv["user_max_tokens"], + user_api_base=srv["user_api_base"], + version=srv["version"], + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=srv["proxy_logging_obj"], + version=srv["version"], + ) + + +@router.get( + "/v1beta/agents/{name}/versions", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, +) +async def list_gemini_agent_versions( + request: Request, + name: str, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + List versions of a custom agent. + + Pass per-request Gemini credentials via the JSON-encoded + ``litellm_params_template`` query parameter. Flat query parameters + (e.g. ``?api_key=AIza...``) are intentionally ignored — see + ``_merge_query_params_into_data`` for the rationale. + + ```bash + curl "http://localhost:4000/v1beta/agents/my-custom-slides-agent/versions?litellm_params_template=%7B%22api_key%22%3A%22AIza...%22%7D" \\ + -H "Authorization: Bearer sk-..." + ``` + """ + srv = _proxy_server_imports() + data = {"name": name, "custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + _enforce_caller_supplied_provider_key(data, user_api_key_dict) + + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="alist_agent_versions", + proxy_logging_obj=srv["proxy_logging_obj"], + llm_router=srv["llm_router"], + general_settings=srv["general_settings"], + proxy_config=srv["proxy_config"], + select_data_generator=srv["select_data_generator"], + model=None, + user_model=srv["user_model"], + user_temperature=srv["user_temperature"], + user_request_timeout=srv["user_request_timeout"], + user_max_tokens=srv["user_max_tokens"], + user_api_base=srv["user_api_base"], + version=srv["version"], + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=srv["proxy_logging_obj"], + version=srv["version"], + ) diff --git a/litellm/proxy/google_endpoints/endpoints.py b/litellm/proxy/google_endpoints/endpoints.py index 967ac9f0ac4..1f503247bf4 100644 --- a/litellm/proxy/google_endpoints/endpoints.py +++ b/litellm/proxy/google_endpoints/endpoints.py @@ -285,7 +285,7 @@ async def create_interaction( general_settings=general_settings, proxy_config=proxy_config, select_data_generator=select_data_generator, - model=data.get("model") or data.get("agent"), + model=data.get("model"), user_model=user_model, user_temperature=user_temperature, user_request_timeout=user_request_timeout, diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 7cad1352a79..766ef0cf9f6 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -10,7 +10,6 @@ every text fragment. from typing import Any, Callable, Dict, FrozenSet, Iterator, List - # Call types whose body carries free-form chat / prompt text that # text-content guardrails (banned keywords, content moderation, secret # detection, …) should inspect. The proxy ingress passes ``route_type`` diff --git a/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py index c4aaea709ba..1e3dd906b9f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py @@ -4,7 +4,6 @@ from litellm.types.guardrails import SupportedGuardrailIntegrations from .akto import AktoGuardrail - if TYPE_CHECKING: from litellm.types.guardrails import Guardrail, LitellmParams diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index bb1db3d62d2..765c419479e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -63,6 +63,7 @@ from litellm.types.utils import ( CallTypesLiteral, Choices, GuardrailStatus, + GuardrailTracingDetail, Message, ModelResponse, ModelResponseStream, @@ -509,6 +510,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # Add guardrail information to request trace ######################################################### _json_response = httpx_response.json() + tracing_detail = self._build_tracing_detail(_json_response) + # Raw Bedrock JSON is passed here; match/regex redaction runs once inside # CustomGuardrail.add_standard_logging_guardrail_information_to_request_data. self.add_standard_logging_guardrail_information_to_request_data( @@ -522,6 +525,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), event_type=event_type, + tracing_detail=tracing_detail or None, ) ######################################################### if httpx_response.status_code == 200: @@ -640,6 +644,55 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return (status_code, err) return (status_code, message) + def _build_tracing_detail( + self, response: BedrockGuardrailResponse + ) -> GuardrailTracingDetail: + """ + Build the tracing detail from the raw Bedrock response, before + redaction, so downstream loggers (OTEL, Langfuse, ...) get the + actual category names rather than the "[REDACTED]" sentinel that + replaces customWords.match later. Bedrock's top-level ``action`` + field ("GUARDRAIL_INTERVENED" or "NONE") is also surfaced so the + OTEL integration can expose it as a queryable span attribute + without re-parsing the redacted guardrail_response blob. + """ + tracing_detail: GuardrailTracingDetail = {} + violation_categories = self._extract_violation_category_names(response) + if violation_categories: + tracing_detail["violation_categories"] = violation_categories + bedrock_action = response.get("action") + if isinstance(bedrock_action, str): + tracing_detail["guardrail_action"] = bedrock_action + return tracing_detail + + def _extract_violation_category_names( + self, response: BedrockGuardrailResponse + ) -> List[str]: + """ + Flatten the BLOCKED assessments into a list of human-readable category + names suitable for queryable OTEL / standard-logging attributes. + + SECURITY: only emits the non-sensitive policy *label* (topic name, + content-filter type, PII entity type, named-regex name). The raw + ``match`` field is intentionally NOT used — it carries the user's + original input that triggered the rule (e.g. a credit-card number + that hit a regex, or the literal custom word). Surfacing it to + telemetry would re-introduce the sensitive content the guardrail + was supposed to keep out. Entries that only have a ``match`` (bare + customWords, unnamed regexes) are therefore skipped — operators + can still see the count in ``_extract_blocked_assessments`` which + feeds the HTTP error detail. + """ + names: List[str] = [] + for block in self._extract_blocked_assessments(response): + for match in block.get("matches", []) or []: + # Allow-list non-sensitive labels only. Never fall back to + # `match.get("match")` — that's user-submitted content. + label = match.get("name") or match.get("type") + if isinstance(label, str) and label: + names.append(label) + return names + def _extract_blocked_assessments( self, response: BedrockGuardrailResponse ) -> List[dict]: diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py index 5502076829f..0f299f4c5f7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py +++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py @@ -92,6 +92,8 @@ from litellm.types.utils import CallTypesLiteral # Module-level singleton for the JWKS discovery endpoint to access. _mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None +_MCP_JWT_CALL_TYPES = frozenset({"call_mcp_tool", "list_mcp_tools"}) + # Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at). _jwks_cache: Dict[str, tuple] = {} _JWKS_CACHE_TTL = 3600 # 1 hour @@ -603,17 +605,23 @@ class MCPJWTSigner(CustomGuardrail): # FR-10: Scope building # ------------------------------------------------------------------ - def _build_scope(self, raw_tool_name: str) -> str: + def _build_scope( + self, + raw_tool_name: str, + call_type: Optional[CallTypesLiteral] = None, + ) -> str: """ Build the JWT scope string. When allowed_scopes is configured: join them verbatim. Otherwise auto-generate minimal, least-privilege scopes: - Tool call → mcp:tools/call mcp:tools/:call - - No tool → mcp:tools/call mcp:tools/list + - No tool → mcp:tools/list NOTE: tools/list is intentionally NOT granted on tool-call JWTs to prevent callers from enumerating tools they didn't ask to use. + Conversely, tools/call is NOT granted on tools/list-only JWTs so an + intercepted list token cannot be replayed to invoke tools. """ if self.allowed_scopes is not None: return " ".join(self.allowed_scopes) @@ -623,8 +631,14 @@ class MCPJWTSigner(CustomGuardrail): ) if tool_name: scopes = ["mcp:tools/call", f"mcp:tools/{tool_name}:call"] + elif call_type == "call_mcp_tool": + # Tool-call request reached the signer without a tool name (e.g. + # missing mcp_tool_name in hook data). Fall back to a generic + # tools/call scope so the upstream server still accepts the + # invocation rather than rejecting it as a tools/list-only token. + scopes = ["mcp:tools/call"] else: - scopes = ["mcp:tools/call", "mcp:tools/list"] + scopes = ["mcp:tools/list"] return " ".join(scopes) # ------------------------------------------------------------------ @@ -673,6 +687,7 @@ class MCPJWTSigner(CustomGuardrail): user_api_key_dict: UserAPIKeyAuth, data: dict, jwt_claims: Optional[Dict[str, Any]] = None, + call_type: Optional[CallTypesLiteral] = None, ) -> Dict[str, Any]: """ Build JWT claims for the outbound MCP access token. @@ -713,7 +728,7 @@ class MCPJWTSigner(CustomGuardrail): # scope (FR-10) raw_tool_name: str = data.get("mcp_tool_name", "") - claims["scope"] = self._build_scope(raw_tool_name) + claims["scope"] = self._build_scope(raw_tool_name, call_type=call_type) # optional_claims passthrough (FR-15) claims = self._passthrough_optional_claims(claims, jwt_claims) @@ -779,16 +794,20 @@ class MCPJWTSigner(CustomGuardrail): Verifies the incoming token (when configured), validates required claims, then signs an outbound JWT and injects it as the Authorization header. - All non-MCP call types pass through unchanged. + Signs outbound MCP tool calls and tools/list requests. """ - if call_type != "call_mcp_tool": + if call_type not in _MCP_JWT_CALL_TYPES: return data + hook_data = dict(data) + if call_type == "list_mcp_tools": + hook_data["mcp_tool_name"] = "" + # ------------------------------------------------------------------ # FR-5: Verify incoming token before re-signing # ------------------------------------------------------------------ jwt_claims: Optional[Dict[str, Any]] = None - raw_token: Optional[str] = data.get("incoming_bearer_token") + raw_token: Optional[str] = hook_data.get("incoming_bearer_token") if self.access_token_discovery_uri and raw_token: # Three-dot pattern → JWT; otherwise opaque. @@ -837,7 +856,9 @@ class MCPJWTSigner(CustomGuardrail): # ------------------------------------------------------------------ # Build outbound access token # ------------------------------------------------------------------ - claims = self._build_claims(user_api_key_dict, data, jwt_claims) + claims = self._build_claims( + user_api_key_dict, hook_data, jwt_claims, call_type=call_type + ) signed_token = jwt.encode( claims, @@ -848,7 +869,7 @@ class MCPJWTSigner(CustomGuardrail): # Merge into existing extra_headers — a prior guardrail in the chain may # have already injected tracing headers or correlation IDs. - existing_headers: Dict[str, str] = data.get("extra_headers") or {} + existing_headers: Dict[str, str] = hook_data.get("extra_headers") or {} new_headers: Dict[str, str] = { **existing_headers, "Authorization": f"Bearer {signed_token}", @@ -875,17 +896,74 @@ class MCPJWTSigner(CustomGuardrail): claims, self._kid ) - data["extra_headers"] = new_headers + hook_data["extra_headers"] = new_headers verbose_proxy_logger.debug( "MCPJWTSigner: signed JWT sub=%s act=%s tool=%s exp=%d " - "verified=%s channel=%s", + "verified=%s channel=%s call_type=%s", claims.get("sub"), claims.get("act", {}).get("sub"), - data.get("mcp_tool_name"), + hook_data.get("mcp_tool_name"), claims["exp"], jwt_claims is not None, bool(self.channel_token_audience), + call_type, ) - return data + return hook_data + + +async def inject_mcp_jwt_headers_for_upstream( + user_api_key_dict: Optional[UserAPIKeyAuth], + extra_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[Dict[str, str]] = None, + *, + for_list_tools: bool = False, + mcp_tool_name: str = "", +) -> Dict[str, str]: + """ + Sign outbound MCP headers when MCPJWTSigner is configured. + + Used by tools/list paths that do not go through proxy pre_call_hook. + """ + merged = dict(extra_headers or {}) + signer = get_mcp_jwt_signer() + if signer is None or user_api_key_dict is None: + return merged + + normalized_raw = {k.lower(): v for k, v in (raw_headers or {}).items()} + incoming_bearer_token: Optional[str] = None + auth_hdr = normalized_raw.get("authorization", "") + if auth_hdr.lower().startswith("bearer "): + incoming_bearer_token = auth_hdr[len("bearer ") :] + + hook_data: Dict[str, Any] = { + "mcp_tool_name": "" if for_list_tools else mcp_tool_name, + "incoming_bearer_token": incoming_bearer_token, + "extra_headers": merged, + } + call_type: CallTypesLiteral = ( + "list_mcp_tools" if for_list_tools else "call_mcp_tool" + ) + try: + from litellm.proxy.proxy_server import ( # noqa: PLC0415 + proxy_logging_obj as _proxy_logging, + ) + + shared_cache = ( + _proxy_logging.internal_usage_cache.dual_cache + if _proxy_logging is not None + else DualCache() + ) + except Exception: + shared_cache = DualCache() + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=shared_cache, + data=hook_data, + call_type=call_type, + ) + if isinstance(result, dict) and result.get("extra_headers"): + merged.update(result["extra_headers"]) + return merged diff --git a/litellm/proxy/guardrails/guardrail_hooks/rubrik/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/rubrik/__init__.py new file mode 100644 index 00000000000..ab347130a30 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/rubrik/__init__.py @@ -0,0 +1,35 @@ +"""Rubrik guardrail integration for LiteLLM.""" + +from typing import TYPE_CHECKING + +from litellm.integrations.rubrik import RubrikLogger +from litellm.types.guardrails import SupportedGuardrailIntegrations + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail( + litellm_params: "LitellmParams", guardrail: "Guardrail" +) -> RubrikLogger: + import litellm + + rubrik_callback = RubrikLogger( + api_key=litellm_params.api_key, + api_base=litellm_params.api_base, + guardrail_name=guardrail.get("guardrail_name", ""), + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + ) + + litellm.logging_callback_manager.add_litellm_callback(rubrik_callback) + return rubrik_callback + + +guardrail_initializer_registry = { + SupportedGuardrailIntegrations.RUBRIK.value: initialize_guardrail, +} + +guardrail_class_registry = { + SupportedGuardrailIntegrations.RUBRIK.value: RubrikLogger, +} diff --git a/litellm/proxy/hooks/litellm_skills/__init__.py b/litellm/proxy/hooks/litellm_skills/__init__.py index 057cf3d8b38..1507b652ab4 100644 --- a/litellm/proxy/hooks/litellm_skills/__init__.py +++ b/litellm/proxy/hooks/litellm_skills/__init__.py @@ -6,7 +6,7 @@ The actual skill logic is in litellm/llms/litellm_proxy/skills/. Usage: from litellm.proxy.hooks.litellm_skills import SkillsInjectionHook - + # Register hook in proxy litellm.callbacks.append(SkillsInjectionHook()) """ diff --git a/litellm/proxy/management_endpoints/budget_management_endpoints.py b/litellm/proxy/management_endpoints/budget_management_endpoints.py index 60dc7827a6f..2eda1b30c5d 100644 --- a/litellm/proxy/management_endpoints/budget_management_endpoints.py +++ b/litellm/proxy/management_endpoints/budget_management_endpoints.py @@ -1,9 +1,9 @@ """ BUDGET MANAGEMENT -All /budget management endpoints +All /budget management endpoints -/budget/new +/budget/new /budget/info /budget/update /budget/delete diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 4889f0b7f80..1fd8320db20 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -1,9 +1,9 @@ """ CUSTOMER MANAGEMENT -All /customer management endpoints +All /customer management endpoints -/customer/new +/customer/new /customer/info /customer/update /customer/delete diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 472306eb818..f2d8ec8fb55 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -546,7 +546,7 @@ async def _update_existing_team_model_assignment( """ def _get_team_public_model_name( - model_info: Optional[Union[dict, str]] + model_info: Optional[Union[dict, str]], ) -> Optional[str]: if isinstance(model_info, dict): value = model_info.get("team_public_model_name") diff --git a/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py b/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py index 191212d6f0b..04e44c623d1 100644 --- a/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py +++ b/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py @@ -7,7 +7,7 @@ variables. Environment Variables: - MICROSOFT_AUTHORIZATION_ENDPOINT: Custom authorization endpoint URL -- MICROSOFT_TOKEN_ENDPOINT: Custom token endpoint URL +- MICROSOFT_TOKEN_ENDPOINT: Custom token endpoint URL - MICROSOFT_USERINFO_ENDPOINT: Custom userinfo endpoint URL If these are not set, the default Microsoft endpoints are used. diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 86c4d6dcd9a..0b2f93d817a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4381,9 +4381,7 @@ async def list_team( except Exception as e: team_exception = """Invalid team object for team_id: {}. team_object={}. Error: {} - """.format( - team.team_id, team.model_dump(), str(e) - ) + """.format(team.team_id, team.model_dump(), str(e)) verbose_proxy_logger.exception(team_exception) continue # Sort the responses by team_alias diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 6e2e2bedac1..ff3bbf47389 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -1798,7 +1798,10 @@ async def cli_sso_callback( from fastapi.responses import HTMLResponse - verify_url = str(request.url_for("cli_sso_complete", login_id=key)) + verify_url = get_custom_url( + request_base_url=str(request.base_url), + route=f"sso/cli/complete/{key}", + ) html_content = _render_cli_sso_verification_page( verify_url=verify_url, browser_complete_token=browser_complete_token, diff --git a/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py b/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py index 872b6fa2250..ebd276fbee5 100644 --- a/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py +++ b/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py @@ -3,7 +3,7 @@ User Agent Analytics Endpoints This module provides optimized endpoints for tracking user agent activity metrics including: - Daily Active Users (DAU) by tags for configurable number of days -- Weekly Active Users (WAU) by tags for configurable number of weeks +- Weekly Active Users (WAU) by tags for configurable number of weeks - Monthly Active Users (MAU) by tags for configurable number of months - Summary analytics by tags diff --git a/litellm/proxy/ocr_endpoints/endpoints.py b/litellm/proxy/ocr_endpoints/endpoints.py index 4f31c762df1..e32fee6afc5 100644 --- a/litellm/proxy/ocr_endpoints/endpoints.py +++ b/litellm/proxy/ocr_endpoints/endpoints.py @@ -178,6 +178,24 @@ async def _parse_ocr_request(request: Request) -> Dict[str, Any]: "For JSON requests, use 'document_url' or 'image_url' document types." ) + # Security: reject provider-native file IDs (e.g. reducto://) received via + # JSON. These IDs are not scoped to the LiteLLM proxy user/key, so an + # authenticated user who obtains another user's file ID could submit it + # here and receive the OCR result using the proxy's shared provider + # credentials. Force callers to upload fresh content per request via + # multipart/form-data or an inline base64 data URI, both of which produce + # a server-mediated upload bound to the current request. + if isinstance(doc, dict): + for url_field in ("document_url", "image_url"): + url_value = doc.get(url_field) + if isinstance(url_value, str) and url_value.startswith("reducto://"): + raise ValueError( + "reducto:// file IDs are not accepted through the proxy " + "OCR API; upload the file in the same request via " + "multipart/form-data with a 'file' field, or pass an " + "inline base64 data URI as the document URL." + ) + return data diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 30c78ed5ba7..0415bb456ec 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -727,6 +727,76 @@ async def resolve_output_file_ids_to_unified(response, prisma_client) -> None: pass +async def ensure_batch_response_managed_file_ids( + response, + managed_files_obj, + prisma_client, + verbose_proxy_logger, + user_api_key_dict=None, + db_batch_object=None, +) -> None: + """Normalize batch file IDs to managed unified IDs before DB persistence.""" + await resolve_input_file_id_to_unified(response, prisma_client) + await resolve_output_file_ids_to_unified(response, prisma_client) + + if managed_files_obj is None: + return + + hidden_params = getattr(response, "_hidden_params", None) or {} + model_id = hidden_params.get("model_id") + if not model_id: + return + + model_name = hidden_params.get("model_name") + unified_file_id = hidden_params.get("unified_file_id") + if not model_name and isinstance(unified_file_id, str): + decoded_unified_file_id = ( + _is_base64_encoded_unified_file_id(unified_file_id) or unified_file_id + ) + target_model_names = get_models_from_unified_file_id(decoded_unified_file_id) + if target_model_names: + model_name = ",".join(target_model_names) + + if user_api_key_dict is None and db_batch_object is not None: + from litellm.proxy._types import UserAPIKeyAuth + + user_api_key_dict = UserAPIKeyAuth( + user_id=getattr(db_batch_object, "created_by", None) or "default-user-id", + team_id=getattr(db_batch_object, "team_id", None), + ) + if user_api_key_dict is None: + return + + for file_attr in ("output_file_id", "error_file_id"): + raw_file_id = getattr(response, file_attr, None) + if not raw_file_id or _is_base64_encoded_unified_file_id(raw_file_id): + continue + try: + new_unified_file_id = managed_files_obj.get_unified_output_file_id( + output_file_id=raw_file_id, + model_id=model_id, + model_name=model_name, + ) + await managed_files_obj.store_unified_file_id( + file_id=new_unified_file_id, + file_object=None, + litellm_parent_otel_span=getattr( + user_api_key_dict, "parent_otel_span", None + ), + model_mappings={model_id: raw_file_id}, + user_api_key_dict=user_api_key_dict, + ) + setattr(response, file_attr, new_unified_file_id) + verbose_proxy_logger.debug( + f"Converted batch {file_attr} {raw_file_id!r} to managed ID before DB write" + ) + except Exception as e: + verbose_proxy_logger.warning( + f"Failed to convert batch {file_attr}={raw_file_id!r} to managed ID " + f"before DB write: {e}" + ) + + async def get_batch_from_database( batch_id: str, unified_batch_id: Union[str, Literal[False]], @@ -800,6 +870,7 @@ async def update_batch_in_database( verbose_proxy_logger, db_batch_object=None, operation: str = "update", + user_api_key_dict=None, ): """ Update batch status and object in ManagedObjectTable. @@ -813,6 +884,7 @@ async def update_batch_in_database( verbose_proxy_logger: Logger instance db_batch_object: Optional existing database object (for comparison) operation: Description of operation ("update", "cancel", etc.) + user_api_key_dict: Optional auth context for creating managed file IDs """ import litellm.utils @@ -823,6 +895,18 @@ async def update_batch_in_database( if not prisma_client: return + # Always normalize the response's file IDs to unified managed IDs + # (mutates in place) so the caller returns unified IDs to the user + # even when we skip the DB update below for an unchanged status. + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=managed_files_obj, + prisma_client=prisma_client, + verbose_proxy_logger=verbose_proxy_logger, + user_api_key_dict=user_api_key_dict, + db_batch_object=db_batch_object, + ) + # Only update if status has changed (when db_batch_object is provided) if db_batch_object and response.status == db_batch_object.status: return diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py index a104f962630..e7696e5a18a 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py @@ -18,7 +18,6 @@ from litellm.litellm_core_utils.litellm_logging import ( from litellm.proxy._types import PassThroughEndpointLoggingTypedDict from litellm.types.utils import StandardPassThroughResponseObject - CURSOR_AGENT_ENDPOINTS: Dict[str, str] = { "POST /v0/agents": "cursor:agent:create", "GET /v0/agents": "cursor:agent:list", diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 5fc8c44b2d8..e4c5dabb50b 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -38,6 +38,35 @@ class LiteLLMDatabaseConnectionPool(Enum): database_connection_pool_timeout = 60 +def _build_db_connection_url_params( + connection_limit: int, + pool_timeout: Optional[Union[int, float]], + connect_timeout: Optional[Union[int, float]] = None, + socket_timeout: Optional[Union[int, float]] = None, + extra_params: Optional[dict] = None, +) -> dict: + """Build the Prisma DATABASE_URL query params controlling connection pool behavior. + + `connect_timeout` / `socket_timeout` map to the Prisma URL params of the same + name (https://www.prisma.io/docs/orm/overview/databases/postgresql) and are + omitted when None so Prisma's defaults apply. `extra_params` is an + untyped passthrough — keys it provides win over the named arguments above, + so it can be used to override any default we set here. + """ + params: dict = { + "connection_limit": connection_limit, + } + if pool_timeout is not None: + params["pool_timeout"] = pool_timeout + if connect_timeout is not None: + params["connect_timeout"] = connect_timeout + if socket_timeout is not None: + params["socket_timeout"] = socket_timeout + if extra_params: + params.update(extra_params) + return params + + def append_query_params(url: Optional[str], params: dict) -> str: from litellm._logging import verbose_proxy_logger @@ -292,9 +321,7 @@ class ProxyInitializationHelpers: _endpoint_str = ( f"curl --location 'http://0.0.0.0:{port}/chat/completions' \\" ) - curl_command = ( - _endpoint_str - + """ + curl_command = _endpoint_str + """ --header 'Content-Type: application/json' \\ --data ' { "model": "gpt-3.5-turbo", @@ -307,7 +334,6 @@ class ProxyInitializationHelpers: }' \n """ - ) print() # noqa print( # noqa '\033[1;34mLiteLLM: Test your local proxy with: "litellm --test" This runs an openai.ChatCompletion request to your proxy [In a new terminal tab]\033[0m\n' @@ -383,11 +409,9 @@ class ProxyInitializationHelpers: with open(os.devnull, "w") as devnull: subprocess.Popen(command, stdout=devnull, stderr=devnull) except Exception as e: - print( # noqa - f""" + print(f""" LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve` - """ - ) # noqa + """) # noqa # noqa @staticmethod def _is_port_in_use(port): @@ -807,6 +831,9 @@ def run_server( # noqa: PLR0915 db_connection_pool_limit = 100 # Starts optional due to config fallback checks; guaranteed non-None before use. db_connection_timeout: Optional[Union[int, float]] = 60 + db_connect_timeout: Optional[Union[int, float]] = None + db_socket_timeout: Optional[Union[int, float]] = None + db_extra_connection_params: Optional[dict] = None general_settings = {} ### GET DB TOKEN FOR IAM AUTH ### @@ -924,6 +951,11 @@ def run_server( # noqa: PLR0915 db_connection_timeout = ( LiteLLMDatabaseConnectionPool.database_connection_pool_timeout.value ) + db_connect_timeout = general_settings.get("database_connect_timeout") + db_socket_timeout = general_settings.get("database_socket_timeout") + db_extra_connection_params = general_settings.get( + "database_extra_connection_params" + ) if database_url and database_url.startswith("os.environ/"): original_dir = os.getcwd() # set the working directory to where this script is @@ -963,27 +995,26 @@ def run_server( # noqa: PLR0915 try: from litellm.secret_managers.main import get_secret + connection_url_params = _build_db_connection_url_params( + connection_limit=db_connection_pool_limit, + pool_timeout=db_connection_timeout, + connect_timeout=db_connect_timeout, + socket_timeout=db_socket_timeout, + extra_params=db_extra_connection_params, + ) if os.getenv("DATABASE_URL", None) is not None: - ### add connection pool + pool timeout args - params = { - "connection_limit": db_connection_pool_limit, - "pool_timeout": db_connection_timeout, - } database_url = get_secret("DATABASE_URL", default_value=None) modified_url = append_query_params( - str(database_url) if database_url else None, params + str(database_url) if database_url else None, + connection_url_params, ) os.environ["DATABASE_URL"] = modified_url if os.getenv("DIRECT_URL", None) is not None: - ### add connection pool + pool timeout args - params = { - "connection_limit": db_connection_pool_limit, - "pool_timeout": db_connection_timeout, - } database_url = os.getenv("DIRECT_URL") - modified_url = append_query_params(database_url, params) + modified_url = append_query_params( + database_url, connection_url_params + ) os.environ["DIRECT_URL"] = modified_url - ### subprocess.run(["prisma"], capture_output=True) is_prisma_runnable = True except FileNotFoundError: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 5d89d3fa9c5..759534a32a1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2710,11 +2710,9 @@ def run_ollama_serve(): with open(os.devnull, "w") as devnull: subprocess.Popen(command, stdout=devnull, stderr=devnull) except Exception as e: - verbose_proxy_logger.debug( - f""" + verbose_proxy_logger.debug(f""" LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve` - """ - ) + """) def _get_process_rss_mb() -> Optional[float]: @@ -4328,6 +4326,19 @@ class ProxyConfig: "health_check_concurrency", None ) health_check_details = general_settings.get("health_check_details", True) + ### INTERACTIONS API SCHEMA ### + _use_legacy_interactions_schema = general_settings.get( + "use_legacy_interactions_schema" + ) + if _use_legacy_interactions_schema is not None: + if isinstance(_use_legacy_interactions_schema, str): + litellm.use_legacy_interactions_schema = ( + _use_legacy_interactions_schema.lower() == "true" + ) + else: + litellm.use_legacy_interactions_schema = bool( + _use_legacy_interactions_schema + ) # Health-check-driven routing (opt-in, passes through to Router later) _enable_hc_routing = general_settings.get( "enable_health_check_routing", False @@ -6917,6 +6928,15 @@ async def async_data_generator( # noqa: PLR0915 if isinstance(chunk, BaseModel): chunk = _serialize_streaming_chunk(chunk) + elif isinstance(chunk, bytes): + # Some upstream streaming iterators (e.g. AsyncGoogleGenAIGenerateContentStreamingIterator + # for /v1beta/.../streamGenerateContent) yield raw SSE bytes from Gemini. + # Decode to str so the f-string below does not emit a Python b'...' literal, + # and pass already-formatted SSE through unchanged to avoid double "data:" prefix. + chunk = chunk.decode("utf-8", errors="replace") + if chunk.startswith(("data:", "event:", ":")): + yield chunk if chunk.endswith("\n\n") else chunk + "\n\n" + continue elif isinstance(chunk, str) and chunk.startswith("data: "): error_message = chunk break diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 06fd35448f1..8f6f7084a0c 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -94,6 +94,12 @@ ROUTE_ENDPOINT_MAPPING = { "aget_interaction": "/interactions/{interaction_id}", "adelete_interaction": "/interactions/{interaction_id}", "acancel_interaction": "/interactions/{interaction_id}/cancel", + # Google Managed Agents API routes + "acreate_agent": "/v1beta/agents", + "alist_agents": "/v1beta/agents", + "aget_agent": "/v1beta/agents/{name}", + "adelete_agent": "/v1beta/agents/{name}", + "alist_agent_versions": "/v1beta/agents/{name}/versions", # OpenAI Evals API routes "acreate_eval": "/evals", "alist_evals": "/evals", @@ -311,6 +317,11 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin "aget_interaction", "adelete_interaction", "acancel_interaction", + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", "asend_message", "call_mcp_tool", "acancel_batch", @@ -468,6 +479,15 @@ async def route_request( # noqa: PLR0915 - Complex routing function, refactorin "acancel_interaction", ]: return getattr(llm_router, f"{route_type}")(**data) + # Managed Agents API: these don't need model routing + if route_type in [ + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", + ]: + return getattr(llm_router, f"{route_type}")(**data) if route_type in [ "avideo_list", "avideo_status", diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index d030fabe8b5..e3019801aae 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3184,16 +3184,14 @@ async def provider_budgets() -> ProviderBudgetResponse: async def get_spend_by_tags( prisma_client: PrismaClient, start_date=None, end_date=None ): - response = await prisma_client.db.query_raw( - """ + response = await prisma_client.db.query_raw(""" SELECT jsonb_array_elements_text(request_tags) AS individual_request_tag, COUNT(*) AS log_count, SUM(spend) AS total_spend FROM "LiteLLM_SpendLogs" GROUP BY individual_request_tag; - """ - ) + """) return response diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 32c887f17b2..032ab6c63b2 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -2979,8 +2979,7 @@ class PrismaClient: required_view = "LiteLLM_VerificationTokenView" expected_views_str = ", ".join(f"'{view}'" for view in expected_views) pg_schema = os.getenv("DATABASE_SCHEMA", "public") - ret = await self.db.query_raw( - f""" + ret = await self.db.query_raw(f""" WITH existing_views AS ( SELECT viewname FROM pg_views @@ -2992,8 +2991,7 @@ class PrismaClient: (SELECT COUNT(*) FROM existing_views) AS view_count, ARRAY_AGG(viewname) AS view_names FROM existing_views - """ - ) + """) expected_total_views = len(expected_views) if ret[0]["view_count"] == expected_total_views: verbose_proxy_logger.info("All necessary views exist!") @@ -3002,8 +3000,7 @@ class PrismaClient: ## check if required view exists ## if ret[0]["view_names"] and required_view not in ret[0]["view_names"]: await self.health_check() # make sure we can connect to db - await self.db.execute_raw( - """ + await self.db.execute_raw(""" CREATE VIEW "LiteLLM_VerificationTokenView" AS SELECT v.*, @@ -3013,8 +3010,7 @@ class PrismaClient: t.rpm_limit AS team_rpm_limit FROM "LiteLLM_VerificationToken" v LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id; - """ - ) + """) verbose_proxy_logger.info( "LiteLLM_VerificationTokenView Created in DB!" @@ -6068,6 +6064,8 @@ async def get_available_models_for_user( include_model_access_groups=include_model_access_groups, ) + effective_team_id = team_id or user_api_key_dict.team_id + # Get complete model list all_models = get_complete_model_list( key_models=key_models, @@ -6080,6 +6078,7 @@ async def get_available_models_for_user( model_access_groups=model_access_groups, include_model_access_groups=include_model_access_groups, only_model_access_groups=only_model_access_groups, + team_id=effective_team_id, ) return all_models diff --git a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py index 8ce1bedcf90..b47f6a747db 100644 --- a/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py +++ b/litellm/proxy/vertex_ai_endpoints/langfuse_endpoints.py @@ -1,5 +1,5 @@ """ -What is this? +What is this? Logging Pass-Through Endpoints """ diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py index f730a089624..03a2f339bea 100644 --- a/litellm/responses/litellm_completion_transformation/handler.py +++ b/litellm/responses/litellm_completion_transformation/handler.py @@ -65,8 +65,7 @@ class LiteLLMCompletionTransformationHandler: litellm_completion_response: Union[ ModelResponse, litellm.CustomStreamWrapper ] = litellm.completion( - **litellm_completion_request, - **kwargs, + **completion_args, ) if isinstance(litellm_completion_response, ModelResponse): diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 4ee9235af7d..35680889d86 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1115,6 +1115,7 @@ def responses( stream=stream, extra_headers=extra_headers, extra_body=extra_body, + timeout=timeout if timeout is not None else request_timeout, **kwargs, ) diff --git a/litellm/responses/sse_output_recovery.py b/litellm/responses/sse_output_recovery.py new file mode 100644 index 00000000000..5c18770a611 --- /dev/null +++ b/litellm/responses/sse_output_recovery.py @@ -0,0 +1,136 @@ +""" +Shared helpers for recovering Responses API output items from raw SSE chunks. + +The same recovery logic is needed in multiple places (e.g. the ChatGPT +Responses transformation and the LiteLLM Responses-to-Chat-Completions +bridge). Keep the implementation in a single module so a fix in one +caller automatically applies to all of them. +""" + +import json +from typing import Any, Dict, Optional + +from litellm.constants import STREAM_SSE_DONE_STRING + +_MAX_CONTENT_INDEX = 1024 + + +def parse_sse_json_chunk(chunk: str) -> Optional[Dict[str, Any]]: + """Parse a single raw SSE line into a JSON object dict. + + Returns ``None`` for empty lines, ``event:`` lines, ``[DONE]`` markers, + invalid JSON, or non-dict payloads. Centralizes the parsing step that + feeds into the recovery helpers in this module so behavior stays + consistent across all callers. + """ + # Import locally to avoid a circular import with the streaming handler. + from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper + + stripped_chunk = ( + CustomStreamWrapper._strip_sse_data_from_chunk(chunk.strip()) or "" + ).strip() + if ( + not stripped_chunk + or stripped_chunk == STREAM_SSE_DONE_STRING + or stripped_chunk.startswith("event:") + ): + return None + try: + parsed_chunk = json.loads(stripped_chunk) + except json.JSONDecodeError: + return None + if not isinstance(parsed_chunk, dict): + return None + return parsed_chunk + + +def record_output_item_chunk( + parsed_chunk: Dict[str, Any], + output_items: Dict[int, Dict[str, Any]], +) -> None: + """Record an OUTPUT_ITEM_DONE chunk into ``output_items`` keyed by + ``output_index`` (falling back to the next free slot when missing). + """ + item = parsed_chunk.get("item") + if not isinstance(item, dict): + return + try: + output_index_raw = parsed_chunk.get("output_index") + if output_index_raw is None: + raise ValueError("missing output_index") + output_index = int(output_index_raw) + except (TypeError, ValueError): + output_index = len(output_items) + output_items[output_index] = item + + +def record_output_text_chunk( + parsed_chunk: Dict[str, Any], + output_items: Dict[int, Dict[str, Any]], + text_only_items: Dict[int, Dict[str, Any]], +) -> None: + """Record an OUTPUT_TEXT_DONE chunk as a synthetic message item in + ``text_only_items``. Real OUTPUT_ITEM_DONE events already captured in + ``output_items`` take precedence at the same ``output_index``. + """ + text = parsed_chunk.get("text") + if not isinstance(text, str): + return + + try: + output_index_raw = parsed_chunk.get("output_index") + if output_index_raw is None: + raise ValueError("missing output_index") + output_index = int(output_index_raw) + except (TypeError, ValueError): + output_index = len(text_only_items) + + if output_index in output_items: + return + + item = text_only_items.get(output_index) + if item is None: + item = { + "type": "message", + "id": parsed_chunk.get("item_id") or f"msg_{output_index}", + "role": "assistant", + "status": "completed", + "content": [], + } + text_only_items[output_index] = item + + content = item.setdefault("content", []) + if not isinstance(content, list): + return + + try: + content_index_raw = parsed_chunk.get("content_index") + if content_index_raw is None: + raise ValueError("missing content_index") + content_index = int(content_index_raw) + except (TypeError, ValueError): + content_index = len(content) + + if content_index < 0 or content_index > _MAX_CONTENT_INDEX: + return + + while len(content) <= content_index: + content.append( + { + "type": "output_text", + "text": "", + "annotations": [], + } + ) + + content_item = content[content_index] + if not isinstance(content_item, dict): + content_item = {} + content[content_index] = content_item + + content_item["type"] = "output_text" + content_item["text"] = text + if parsed_chunk.get("annotations") is not None: + content_item["annotations"] = parsed_chunk["annotations"] + else: + content_item.setdefault("annotations", []) diff --git a/litellm/router.py b/litellm/router.py index 420c9b8a816..debccb0e83f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -208,6 +208,15 @@ if TYPE_CHECKING: from litellm.router_strategy.quality_router.quality_router import ( QualityRouter, ) + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseInputParam, + ResponsesAPIResponse, + ) Span = Union[_Span, Any] else: @@ -839,7 +848,7 @@ class Router: @staticmethod def _normalize_strategy( - strategy: Union[RoutingStrategy, str, None] + strategy: Union[RoutingStrategy, str, None], ) -> Optional[str]: if strategy is None: return None @@ -1579,6 +1588,44 @@ class Router: cancel_interaction, call_type="cancel_interaction" ) + def _initialize_managed_agents_endpoints(self): + """Initialize Google Managed Agents API endpoints (v1beta/agents).""" + from litellm.interactions.agents import acreate as acreate_agent + from litellm.interactions.agents import adelete as adelete_agent + from litellm.interactions.agents import aget as aget_agent + from litellm.interactions.agents import alist as alist_agents + from litellm.interactions.agents import alist_versions as alist_agent_versions + from litellm.interactions.agents import create as create_agent + from litellm.interactions.agents import delete as delete_agent + from litellm.interactions.agents import get as get_agent + from litellm.interactions.agents import list as list_agents + from litellm.interactions.agents import list_versions as list_agent_versions + + self.acreate_agent = self.factory_function( + acreate_agent, call_type="acreate_agent" + ) + self.create_agent = self.factory_function( + create_agent, call_type="create_agent" + ) + self.alist_agents = self.factory_function( + alist_agents, call_type="alist_agents" + ) + self.list_agents = self.factory_function(list_agents, call_type="list_agents") + self.aget_agent = self.factory_function(aget_agent, call_type="aget_agent") + self.get_agent = self.factory_function(get_agent, call_type="get_agent") + self.adelete_agent = self.factory_function( + adelete_agent, call_type="adelete_agent" + ) + self.delete_agent = self.factory_function( + delete_agent, call_type="delete_agent" + ) + self.alist_agent_versions = self.factory_function( + alist_agent_versions, call_type="alist_agent_versions" + ) + self.list_agent_versions = self.factory_function( + list_agent_versions, call_type="list_agent_versions" + ) + def _initialize_specialized_endpoints(self): """Helper to initialize specialized router endpoints (vector store, OCR, search, video, container, skills, interactions).""" self._initialize_vector_store_endpoints() @@ -1591,6 +1638,7 @@ class Router: self._initialize_container_endpoints() self._initialize_skills_endpoints() self._initialize_interactions_endpoints() + self._initialize_managed_agents_endpoints() def initialize_router_endpoints(self): self._initialize_core_endpoints() @@ -2207,6 +2255,388 @@ class Router: return FallbackStreamWrapper(stream_with_fallbacks()) + @staticmethod + def _extract_partial_responses_usage( + source_iterator: "BaseResponsesAPIStreamingIterator", + ) -> Optional["ResponseAPIUsage"]: + """ + Best-effort: pull partial token usage from a Responses-API streaming + iterator that errored mid-stream, normalized to ResponseAPIUsage so + the caller can combine without crossing token-naming conventions. + + Two sources, in priority order: + 1. The bridge path (LiteLLMCompletionStreamingIterator) accumulates + chat-completion chunks while streaming — feed them through + stream_chunk_builder to recover chat Usage, then translate + (prompt_tokens → input_tokens, completion_tokens → output_tokens). + 2. The native path (ResponsesAPIStreamingIterator) only has a + completed_response object if the stream reached + RESPONSE_COMPLETED before erroring — uncommon mid-stream but + worth checking. Already ResponseAPIUsage-shaped. + + Returns None when no partial usage is recoverable. + """ + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ) + + # Bridge subclass is the only iterator that accumulates chat-completion + # chunks. isinstance narrows the type so we can read the attribute + # directly instead of getattr-ing on the base class. + if isinstance(source_iterator, LiteLLMCompletionStreamingIterator): + chunks = source_iterator.collected_chat_completion_chunks + if chunks: + try: + from litellm.main import stream_chunk_builder + + built = stream_chunk_builder(chunks=chunks) + # stream_chunk_builder returns ModelResponse | + # TextCompletionResponse | None. ModelResponse sets .usage + # in __init__ rather than declaring it as a class field, so + # static narrowing doesn't expose it. Mirror the sync path + # (_completion_streaming_iterator) and pull via getattr. + chat = getattr(built, "usage", None) if built is not None else None + if chat is not None: + # getattr-with-default because the test path may + # substitute a SimpleNamespace lacking some fields; + # real Usage instances always have them. + prompt = int(getattr(chat, "prompt_tokens", 0) or 0) + completion = int(getattr(chat, "completion_tokens", 0) or 0) + total = int( + getattr(chat, "total_tokens", prompt + completion) + or (prompt + completion) + ) + return ResponseAPIUsage( + input_tokens=prompt, + output_tokens=completion, + total_tokens=total, + ) + except Exception: + # Builder is best-effort — fall through to native path. + pass + + # Native path: completed_response is set only if RESPONSE_COMPLETED + # arrived before the error (uncommon mid-stream but worth checking). + # Already ResponseAPIUsage-shaped — return as-is. + completed = source_iterator.completed_response + if isinstance( + completed, + (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent), + ): + return completed.response.usage + return None + + @staticmethod + def _combine_responses_fallback_usage( + fallback_item: "BaseLiteLLMOpenAIResponseObject", + partial_usage: "ResponseAPIUsage", + ) -> None: + """ + Merge partial-stream usage with fallback-stream usage on a + Responses-API streaming event. + + Only mutates events that carry a `response` with a `usage` field + (response.completed / response.failed / response.incomplete). Other + events pass through unchanged. + + Both inputs are ResponseAPIUsage-shaped (see + _extract_partial_responses_usage which normalizes the bridge path), + so we can sum input_tokens / output_tokens / total_tokens directly + and produce a clean ResponseAPIUsage — no token-naming split, no + setattr bypass. + """ + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ) + + if not isinstance( + fallback_item, + (ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent), + ): + return + response = fallback_item.response + if response.usage is None: + return + + fb = response.usage + response.usage = ResponseAPIUsage( + input_tokens=(partial_usage.input_tokens or 0) + (fb.input_tokens or 0), + output_tokens=(partial_usage.output_tokens or 0) + (fb.output_tokens or 0), + total_tokens=(partial_usage.total_tokens or 0) + (fb.total_tokens or 0), + ) + + @staticmethod + def _build_responses_continuation_input( + input_val: Optional[Union[str, "ResponseInputParam"]], + generated_content: str, + ) -> "ResponseInputParam": + """ + Convert Responses-API input + partial assistant output into a + continuation input that asks the fallback model to pick up where the + prior assistant message stopped. + + Best effort across providers. The chat-completions path uses + Anthropic's `prefix: True` prefill trick on the assistant message; + the Responses-API input schema has no direct equivalent, so we + append an instruction (developer role) plus a prior assistant + message containing the partial output. Providers without prefill + semantics (OpenAI, Vertex) treat this as conversational context + and may regenerate — same trade-off as the chat-completions path + for non-Anthropic fallbacks. + """ + # base/continuation are List[Any] because ResponseInputParam items + # are a wide Union of TypedDicts (EasyInputMessageParam, Message, + # ResponseOutputMessageParam, ...) — annotating as List[Dict[str, Any]] + # rejects the list() spread of input_val. We cast the combined list to + # ResponseInputParam at the return. + base: List[Any] + if isinstance(input_val, str): + base = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": input_val}], + } + ] + elif isinstance(input_val, list): + base = list(input_val) + else: + base = [] + continuation: List[Any] = [ + { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": ( + "The previous assistant response was interrupted " + "mid-stream. Continue exactly where it stopped — " + "do not repeat any of its content. Your response " + "must read as a seamless continuation." + ), + } + ], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": generated_content}], + }, + ] + return cast("ResponseInputParam", base + continuation) + + async def _aresponses_streaming_iterator( + self, + response: "BaseResponsesAPIStreamingIterator", + initial_kwargs: Dict[str, Any], + ) -> "BaseResponsesAPIStreamingIterator": + """ + Wrap a Responses-API streaming iterator so MidStreamFallbackError + triggers the Router's fallback chain (parity with + _acompletion_streaming_iterator for the chat-completions path). + + The Responses-API streaming path goes through + _ageneric_api_call_with_fallbacks rather than _acompletion, so the + returned iterator is never wrapped by the chat completions + fallback handler. Without this wrapper, MidStreamFallbackError + raised mid-stream from the underlying CustomStreamWrapper (used by + LiteLLMCompletionStreamingIterator when the Responses API is + served via the completion bridge) propagates unhandled and the + configured cross-provider fallback never fires. + + Full parity with the chat-completions path: + - Pre-first-chunk: retry with the original input unchanged. + - Partial content: inject a developer instruction + prior + assistant message carrying the generated text so the fallback + model continues rather than restarts. + - Usage combining: merge partial-stream usage onto the fallback's + response.completed event so accounting reflects both attempts. + - Stream cleanup: shielded aclose() on both source and fallback + iterators on terminate. + """ + from litellm.exceptions import MidStreamFallbackError + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + source_iterator = response + + class FallbackResponsesStreamWrapper(BaseResponsesAPIStreamingIterator): + """ + Subclasses BaseResponsesAPIStreamingIterator only for isinstance + compatibility (proxy + interactions code paths check the type). + Bypasses the parent constructor and delegates iteration to an + async generator. + """ + + def __init__(self, async_generator: AsyncGenerator): + import time + from datetime import datetime + + self._async_generator = async_generator + # Mirror every attribute BaseResponsesAPIStreamingIterator.__init__ + # would have set. The wrapper bypasses super().__init__ (it has no + # httpx.Response of its own and no provider config to drive), so + # we copy from source_iterator where applicable and use safe + # defaults elsewhere. This keeps inherited methods (e.g. + # _check_max_streaming_duration, _handle_failure) safe to call. + # + # The bridge path (LiteLLMCompletionStreamingIterator used by + # Anthropic/Bedrock/Vertex) does not call super().__init__ and + # is missing many of these attributes — use getattr fallbacks + # so wrapper construction never raises AttributeError. The + # bridge stores the logging object as `litellm_logging_obj`. + self.response = getattr(source_iterator, "response", None) + self.model = getattr(source_iterator, "model", None) + self.logging_obj = getattr( + source_iterator, + "logging_obj", + getattr(source_iterator, "litellm_logging_obj", None), + ) + self.finished = False + self.responses_api_provider_config = getattr( + source_iterator, "responses_api_provider_config", None + ) + self.completed_response = None + self.start_time = getattr(source_iterator, "start_time", datetime.now()) + self._failure_handled = False + self._completed_response_cached = False + self._completed_response_logged = False + self._completed_response_cache_hit = None + self._persist_completed_response_before_logging = True + self._stream_created_time = time.time() + self.litellm_metadata = getattr( + source_iterator, "litellm_metadata", None + ) + self.custom_llm_provider = getattr( + source_iterator, "custom_llm_provider", None + ) + self.request_data = getattr(source_iterator, "request_data", {}) or {} + self.call_type = getattr(source_iterator, "call_type", None) + # Preserve hidden params so response headers (model_id, + # api_base, additional_headers) keep flowing. + self._hidden_params = dict( + getattr(source_iterator, "_hidden_params", None) or {} + ) + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._async_generator.__anext__() + + async def aclose(self): + # async generators always expose aclose — no defensive check needed. + await self._async_generator.aclose() + + async def stream_with_fallbacks(): + fallback_response = None + try: + async for item in source_iterator: + yield item + except MidStreamFallbackError as e: + partial_usage = Router._extract_partial_responses_usage(source_iterator) + try: + model_group = cast(str, initial_kwargs.get("model")) + fallbacks: Optional[List] = initial_kwargs.get( + "fallbacks", self.fallbacks + ) + context_window_fallbacks: Optional[List] = initial_kwargs.get( + "context_window_fallbacks", self.context_window_fallbacks + ) + content_policy_fallbacks: Optional[List] = initial_kwargs.get( + "content_policy_fallbacks", self.content_policy_fallbacks + ) + # Re-enter via the per-attempt helper so the fallback chain + # picks deployments through + # _ageneric_api_call_with_fallbacks_helper. + # original_generic_function is preserved by the caller so + # the helper knows what underlying API to invoke per attempt. + initial_kwargs["original_function"] = ( + self._ageneric_api_call_with_fallbacks_helper + ) + if e.is_pre_first_chunk or not e.generated_content: + # No content generated before the error — retry with the + # original input. Adding a continuation prompt would + # waste tokens and confuse the model. + pass + else: + initial_kwargs["input"] = ( + Router._build_responses_continuation_input( + initial_kwargs.get("input"), + e.generated_content, + ) + ) + # The Responses-API path stores observability metadata + # under "litellm_metadata" (not the default "metadata") — + # see _ageneric_api_call_with_fallbacks. Mirroring that + # here ensures model_group, model_group_alias, and trace + # ids land in the same key litellm.aresponses reads from. + self._update_kwargs_before_fallbacks( + model=model_group, + kwargs=initial_kwargs, + metadata_variable_name="litellm_metadata", + ) + fallback_response = ( + await self.async_function_with_fallbacks_common_utils( + e=e, + disable_fallbacks=False, + fallbacks=fallbacks, + context_window_fallbacks=context_window_fallbacks, + content_policy_fallbacks=content_policy_fallbacks, + model_group=model_group, + args=(), + kwargs=initial_kwargs, + ) + ) + + if hasattr(fallback_response, "__aiter__"): + async for fallback_item in fallback_response: # type: ignore + if partial_usage is not None: + Router._combine_responses_fallback_usage( + fallback_item, partial_usage + ) + yield fallback_item + else: + yield fallback_response + except Exception as fallback_error: + verbose_router_logger.error( + f"Responses streaming fallback also failed: {fallback_error}" + ) + raise fallback_error + finally: + with anyio.CancelScope(shield=True): + if hasattr(source_iterator, "aclose"): + try: + await source_iterator.aclose() # type: ignore[func-returns-value] + except BaseException as exc: + verbose_router_logger.debug( + "stream_with_fallbacks(aresponses): error closing source: %s", + exc, + ) + if fallback_response is not None and hasattr( + fallback_response, "aclose" + ): + try: + await fallback_response.aclose() + except BaseException as exc: + verbose_router_logger.debug( + "stream_with_fallbacks(aresponses): error closing fallback: %s", + exc, + ) + + return FallbackResponsesStreamWrapper(stream_with_fallbacks()) + def _completion_streaming_iterator( # noqa: PLR0915 self, model_response: CustomStreamWrapper, @@ -4253,6 +4683,61 @@ class Router: self.fail_calls[model] += 1 raise e + async def _aresponses_with_streaming_fallbacks( + self, original_function: Callable, **kwargs: Any + ) -> Union["ResponsesAPIResponse", "BaseResponsesAPIStreamingIterator"]: + """ + _ageneric_api_call_with_fallbacks for the Responses API, with the + addition of mid-stream fallback handling. + + When stream=True and the underlying call returns a + BaseResponsesAPIStreamingIterator, wrap it with + _aresponses_streaming_iterator so MidStreamFallbackError raised + during iteration triggers the Router's cross-provider fallback chain. + """ + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + from litellm.litellm_core_utils.core_helpers import safe_deep_copy + + # Snapshot the request kwargs before _ageneric_api_call_with_fallbacks + # mutates them. A shallow copy alone is not enough: the primary + # attempt mutates nested dicts in place — notably `litellm_metadata`, + # which `_update_kwargs_with_deployment` populates with + # deployment-specific fields (`deployment`, `model_info`, `api_base`, + # tags, etc.). Without an explicit copy of that dict, the shallow + # copy would still share its reference, leaking primary-deployment + # metadata into the mid-stream fallback request. + # + # We avoid deep-copying the full kwargs because it can contain + # non-deepcopyable objects (logging handles, async clients, etc.); + # `safe_deep_copy` deep-copies the metadata dicts key-by-key with a + # fallback to the original reference for any non-picklable value. + # The original_generic_function is preserved so the per-attempt + # helper knows which underlying API to call on fallback. + fallback_kwargs: Dict[str, Any] = kwargs.copy() + if isinstance(fallback_kwargs.get("litellm_metadata"), dict): + fallback_kwargs["litellm_metadata"] = safe_deep_copy( + fallback_kwargs["litellm_metadata"] + ) + if isinstance(fallback_kwargs.get("metadata"), dict): + fallback_kwargs["metadata"] = safe_deep_copy(fallback_kwargs["metadata"]) + fallback_kwargs["original_generic_function"] = original_function + + response = await self._ageneric_api_call_with_fallbacks( + original_function=original_function, **kwargs + ) + + if kwargs.get("stream") and isinstance( + response, BaseResponsesAPIStreamingIterator + ): + return await self._aresponses_streaming_iterator( + response=response, + initial_kwargs=fallback_kwargs, + ) + return response + def _generic_api_call_with_fallbacks( self, model: str, original_function: Callable, **kwargs ): @@ -5322,6 +5807,16 @@ class Router: "delete_interaction", "acancel_interaction", "cancel_interaction", + "acreate_agent", + "create_agent", + "alist_agents", + "list_agents", + "aget_agent", + "get_agent", + "adelete_agent", + "delete_agent", + "alist_agent_versions", + "list_agent_versions", ] = "assistants", ): """ @@ -5406,6 +5901,27 @@ class Router: return vector_store_file_sync_wrapper + if call_type in ( + "create_agent", + "list_agents", + "get_agent", + "delete_agent", + "list_agent_versions", + ): + + def managed_agents_sync_wrapper( + custom_llm_provider: Optional[str] = None, + client: Optional[Any] = None, + **kwargs, + ): + if custom_llm_provider and "custom_llm_provider" not in kwargs: + kwargs["custom_llm_provider"] = custom_llm_provider + if "custom_llm_provider" not in kwargs: + kwargs["custom_llm_provider"] = "gemini" + return original_function(**kwargs) + + return managed_agents_sync_wrapper + # Handle asynchronous call types async def async_wrapper( custom_llm_provider: Optional[str] = None, @@ -5441,9 +5957,13 @@ class Router: custom_llm_provider=custom_llm_provider, **kwargs, ) + elif call_type == "aresponses": + return await self._aresponses_with_streaming_fallbacks( + original_function=original_function, + **kwargs, + ) elif call_type in ( "anthropic_messages", - "aresponses", "_arealtime", "_aresponses_websocket", "acreate_fine_tuning_job", @@ -5469,8 +5989,6 @@ class Router: "alist_skills", "aget_skill", "adelete_skill", - "acreate_interaction", - "create_interaction", ): return await self._ageneric_api_call_with_fallbacks( original_function=original_function, @@ -5530,6 +6048,8 @@ class Router: **kwargs, ) elif call_type in ( + "acreate_interaction", + "create_interaction", "aget_interaction", "adelete_interaction", "acancel_interaction", @@ -5539,6 +6059,18 @@ class Router: custom_llm_provider=custom_llm_provider, **kwargs, ) + elif call_type in ( + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", + ): + return await self._init_managed_agents_api_endpoints( + original_function=original_function, + custom_llm_provider=custom_llm_provider, + **kwargs, + ) return async_wrapper @@ -5588,6 +6120,7 @@ class Router: from litellm.responses.utils import ResponsesAPIRequestUtils container_id = kwargs.get("container_id") + _forwarded_model_id = kwargs.get("model_id") if isinstance(container_id, str): decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) original_id = decoded.get("response_id", container_id) @@ -5596,7 +6129,14 @@ class Router: decoded_provider = decoded.get("custom_llm_provider") if decoded_provider and kwargs.get("custom_llm_provider") == "openai": kwargs["custom_llm_provider"] = decoded_provider - model_id = decoded.get("model_id") + # Fall back to the model_id forwarded by the proxy when the container_id + # is a native upstream ID (e.g. Azure hex cntr_) that carries no LiteLLM + # routing payload, so deployment credentials (api_base, api_key) are applied. + model_id = decoded.get("model_id") or ( + _forwarded_model_id.strip() + if isinstance(_forwarded_model_id, str) and _forwarded_model_id.strip() + else None + ) if model_id: kwargs["model"] = model_id return await self._ageneric_api_call_with_fallbacks( @@ -5643,6 +6183,34 @@ class Router: if custom_llm_provider and "custom_llm_provider" not in kwargs: kwargs["custom_llm_provider"] = custom_llm_provider # Default to gemini for interactions API + if "custom_llm_provider" not in kwargs: + kwargs["custom_llm_provider"] = "gemini" + # If the proxy accidentally passed agent name as model, clear it + if kwargs.get("agent") and kwargs.get("model") == kwargs.get("agent"): + kwargs["model"] = None + # Model-based interactions use deployment routing + fallbacks; agent-only calls + # must not enter model-group lookup (agent name is not a LiteLLM deployment). + if kwargs.get("model"): + return await self._ageneric_api_call_with_fallbacks( + original_function=original_function, + **kwargs, + ) + return await original_function(**kwargs) + + async def _init_managed_agents_api_endpoints( + self, + original_function: Callable, + custom_llm_provider: Optional[str] = None, + **kwargs, + ): + """ + Initialize the Managed Agents API endpoints on the router (v1beta/agents). + + CRUD operations for Gemini managed agents don't need model-based routing, + so we call the original function directly with the custom_llm_provider. + """ + if custom_llm_provider and "custom_llm_provider" not in kwargs: + kwargs["custom_llm_provider"] = custom_llm_provider if "custom_llm_provider" not in kwargs: kwargs["custom_llm_provider"] = "gemini" return await original_function(**kwargs) @@ -7210,6 +7778,38 @@ class Router: _shared_model_info = { k: v for k, v in _model_info.items() if k not in _custom_pricing_fields } + _existing_shared_mode = ( + cast(Optional[dict], litellm.model_cost.get(_model_name, {})) or {} + ).get("mode") + _deployment_mode = _shared_model_info.get("mode") + # Keep the built-in bridge mode stable for shared backend keys. + # Multiple aliases can point at the same provider/model backend, + # but their deployment-level overrides should not downgrade the + # backend from responses -> chat via last-write-wins registration. + # Only preserve in that specific direction so legitimate upgrades + # (e.g. chat -> responses) and unrelated mode changes still apply, + # and so a missing deployment mode does not silently clear the + # existing shared backend mode. + _is_responses_to_chat_downgrade = ( + _existing_shared_mode == "responses" and _deployment_mode == "chat" + ) + _would_clear_existing_mode = ( + _existing_shared_mode is not None and _deployment_mode is None + ) + if _is_responses_to_chat_downgrade or _would_clear_existing_mode: + if _deployment_mode is not None: + verbose_router_logger.warning( + "Router: preserving existing mode=%s for shared backend " + "key %s instead of the deployment-specified mode=%s " + "(prevents alias registration from downgrading the " + "shared backend mode).", + _existing_shared_mode, + _model_name, + _deployment_mode, + ) + _shared_model_info["mode"] = _existing_shared_mode + + # Always register the (possibly mode-preserved) shared backend info. _backend_alias_cost = {_model_name: _shared_model_info} if "responses/" in _model_name: _stripped_model_name = _model_name.replace("responses/", "") diff --git a/litellm/router_strategy/adaptive_router/hooks.py b/litellm/router_strategy/adaptive_router/hooks.py index 9e346006ac1..99fe5e26f7f 100644 --- a/litellm/router_strategy/adaptive_router/hooks.py +++ b/litellm/router_strategy/adaptive_router/hooks.py @@ -103,7 +103,7 @@ def _last_user_content(messages: Optional[List[Dict[str, Any]]]) -> Optional[str def _recent_tool_results( - messages: Optional[List[Dict[str, Any]]] + messages: Optional[List[Dict[str, Any]]], ) -> List[Dict[str, Any]]: """Extract the current turn's tool result payloads from the request messages. diff --git a/litellm/router_strategy/adaptive_router/signals.py b/litellm/router_strategy/adaptive_router/signals.py index a48bdea1eb6..5e33a64d27f 100644 --- a/litellm/router_strategy/adaptive_router/signals.py +++ b/litellm/router_strategy/adaptive_router/signals.py @@ -24,7 +24,6 @@ from litellm.router_strategy.adaptive_router.config import ( TOOL_CALL_HISTORY_MAX, ) - # ---- Public types --------------------------------------------------------- diff --git a/litellm/router_strategy/budget_limiter.py b/litellm/router_strategy/budget_limiter.py index be27b852478..da41577e99a 100644 --- a/litellm/router_strategy/budget_limiter.py +++ b/litellm/router_strategy/budget_limiter.py @@ -10,11 +10,11 @@ This means you can use this with weighted-pick, lowest-latency, simple-shuffle, Example: ``` openai: - budget_limit: 0.000000000001 - time_period: 1d + budget_limit: 0.000000000001 + time_period: 1d anthropic: - budget_limit: 100 - time_period: 7d + budget_limit: 100 + time_period: 7d ``` """ diff --git a/litellm/router_utils/get_retry_from_policy.py b/litellm/router_utils/get_retry_from_policy.py index ec326ebb50d..162d6428f85 100644 --- a/litellm/router_utils/get_retry_from_policy.py +++ b/litellm/router_utils/get_retry_from_policy.py @@ -1,5 +1,5 @@ """ -Get num retries for an exception. +Get num retries for an exception. - Account for retry policy by exception type. """ diff --git a/litellm/router_utils/pattern_match_deployments.py b/litellm/router_utils/pattern_match_deployments.py index 17b453d6031..48f85a83411 100644 --- a/litellm/router_utils/pattern_match_deployments.py +++ b/litellm/router_utils/pattern_match_deployments.py @@ -34,7 +34,7 @@ class PatternUtils: @staticmethod def sorted_patterns( - patterns: Dict[str, List[Dict]] + patterns: Dict[str, List[Dict]], ) -> List[Tuple[str, List[Dict]]]: """ Cached property for patterns sorted by specificity. diff --git a/litellm/router_utils/router_callbacks/track_deployment_metrics.py b/litellm/router_utils/router_callbacks/track_deployment_metrics.py index 1f226879d03..9039b0df8e6 100644 --- a/litellm/router_utils/router_callbacks/track_deployment_metrics.py +++ b/litellm/router_utils/router_callbacks/track_deployment_metrics.py @@ -1,5 +1,5 @@ """ -Helper functions to get/set num success and num failures per deployment +Helper functions to get/set num success and num failures per deployment set_deployment_failures_for_current_minute diff --git a/litellm/secret_managers/aws_secret_manager.py b/litellm/secret_managers/aws_secret_manager.py index fbe951e6492..60d0a713eff 100644 --- a/litellm/secret_managers/aws_secret_manager.py +++ b/litellm/secret_managers/aws_secret_manager.py @@ -4,7 +4,7 @@ This is a file for the AWS Secret Manager Integration Relevant issue: https://github.com/BerriAI/litellm/issues/1883 Requires: -* `os.environ["AWS_REGION_NAME"], +* `os.environ["AWS_REGION_NAME"], * `pip install boto3>=1.28.57` """ diff --git a/litellm/secret_managers/aws_secret_manager_v2.py b/litellm/secret_managers/aws_secret_manager_v2.py index c1b4d019dcf..4461e34396e 100644 --- a/litellm/secret_managers/aws_secret_manager_v2.py +++ b/litellm/secret_managers/aws_secret_manager_v2.py @@ -10,7 +10,7 @@ Handles Async Operations for: Relevant issue: https://github.com/BerriAI/litellm/issues/1883 Requires: -* `os.environ["AWS_REGION_NAME"], +* `os.environ["AWS_REGION_NAME"], * `pip install boto3>=1.28.57` """ diff --git a/litellm/types/agents.py b/litellm/types/agents.py index efb2e73bfb5..8556b6bac93 100644 --- a/litellm/types/agents.py +++ b/litellm/types/agents.py @@ -228,6 +228,66 @@ class ListAgentsResponse(BaseModel): agents: List[AgentResponse] +class AgentCreateResponse(LiteLLMPydanticObjectBase): + """ + Response from a provider-side agent creation or get call (e.g. Gemini v1beta/agents). + + Gemini returns ``"id"`` as the agent identifier; we surface both ``id`` + (Gemini's value) and ``name`` (the user-supplied name, equal to ``id`` for + Gemini) so callers can use either. All extra fields returned by the + provider (e.g. ``base_agent``, ``system_instruction``, ``base_environment``) + are preserved via extra="allow". + """ + + id: Optional[str] = None + name: Optional[str] = None + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class AgentDeleteResult(LiteLLMPydanticObjectBase): + """Result of a provider-side agent deletion (e.g. Gemini DELETE /v1beta/agents/{name}). + + Gemini returns an empty body ``{}`` on success; we synthesise ``name`` and + ``deleted`` so callers always get a consistent response object. + """ + + name: str + deleted: bool = True + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class AgentListResponse(LiteLLMPydanticObjectBase): + """Response from listing agents on the provider side (e.g. Gemini GET /v1beta/agents). + + Gemini returns ``{"agents": [{"id": "..."}, ...]}``; each item is kept as + a plain dict so no fields are silently dropped. + """ + + agents: List[Dict[str, Any]] = [] + next_page_token: Optional[str] = None + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class AgentVersionsResponse(LiteLLMPydanticObjectBase): + """Response from listing versions of an agent (e.g. Gemini GET /v1beta/agents/{name}/versions). + + Gemini returns ``{"agentVersions": [...]}``; each version has a ``name`` + field of the form ``agents/{agent_id}/versions/{uuid}``. + """ + + agent_versions: List[Dict[str, Any]] = [] + next_page_token: Optional[str] = None + model_config = {"extra": "allow"} + + _hidden_params: dict = PrivateAttr(default_factory=dict) + + class AgentMakePublicResponse(BaseModel): message: str public_agent_groups: List[str] diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 751113400d3..0a51ce3d456 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -100,6 +100,7 @@ class SupportedGuardrailIntegrations(Enum): MCP_JWT_SIGNER = "mcp_jwt_signer" LLM_AS_A_JUDGE = "llm_as_a_judge" QOSTODIAN_NEXUS = "qostodian_nexus" + RUBRIK = "rubrik" class Role(Enum): diff --git a/litellm/types/interactions/__init__.py b/litellm/types/interactions/__init__.py index a3acdc4cb1f..78d0b04ef3b 100644 --- a/litellm/types/interactions/__init__.py +++ b/litellm/types/interactions/__init__.py @@ -36,8 +36,13 @@ from litellm.types.interactions.generated import ( GoogleSearchResultContent, ImageContent, Interaction, + InteractionCompleted, + InteractionCreated, InteractionEvent, + InteractionEnvironment, + InteractionInProgress, InteractionInput, + InteractionRequiresAction, InteractionsAPIOptionalRequestParams, InteractionsAPIResponse, InteractionsAPIStreamingResponse, @@ -49,6 +54,9 @@ from litellm.types.interactions.generated import ( McpServerToolResultContent, ModelOption, ResponseModality, + StepDelta, + StepStart, + StepStop, ) from litellm.types.interactions.generated import ( Status3 as InteractionStatus, # Main request/response types; Content types; Turn for multi-turn conversations; Tool types; Config types; Usage; Status enum; Events for streaming; Agent configs; Model/Agent options; Response modality; Annotation; LiteLLM types; Backwards compat aliases @@ -114,7 +122,16 @@ __all__ = [ "AgentOption", "ResponseModality", "Annotation", + # New schema SSE event types (Api-Revision: 2026-05-20) + "StepStart", + "StepDelta", + "StepStop", + "InteractionCreated", + "InteractionInProgress", + "InteractionCompleted", + "InteractionRequiresAction", # LiteLLM types + "InteractionEnvironment", "InteractionInput", "InteractionsAPIResponse", "InteractionsAPIStreamingResponse", diff --git a/litellm/types/interactions/generated.py b/litellm/types/interactions/generated.py index ed626b0b7c8..d546e897891 100644 --- a/litellm/types/interactions/generated.py +++ b/litellm/types/interactions/generated.py @@ -1151,9 +1151,114 @@ class InteractionEvent(BaseModel): ) +# --------------------------------------------------------------- +# New schema SSE event types (Api-Revision: 2026-05-20) +# These replace the legacy content.* / interaction.start|complete +# events and will become the only events after June 8, 2026. +# --------------------------------------------------------------- + + +class StepStart(BaseModel): + """Emitted when a new step begins (replaces content.start).""" + + event_type: Literal["step.start"] = "step.start" + index: Optional[int] = None + step: Optional[Dict[str, Any]] = Field( + None, + description="The initial step data (type, content, signature, etc.).", + ) + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class StepDelta(BaseModel): + """Emitted for incremental step content (replaces content.delta).""" + + event_type: Literal["step.delta"] = "step.delta" + index: Optional[int] = None + delta: Optional[Dict[str, Any]] = Field( + None, + description="Incremental content delta (e.g. text, arguments_delta for function calls).", + ) + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class StepStop(BaseModel): + """Emitted when a step finishes (replaces content.stop).""" + + event_type: Literal["step.stop"] = "step.stop" + index: Optional[int] = None + status: Optional[str] = Field( + None, + description="Step completion status (e.g. 'done').", + ) + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class InteractionCreated(BaseModel): + """Emitted when the interaction is first created (replaces interaction.start).""" + + event_type: Literal["interaction.created"] = "interaction.created" + interaction: Optional[Dict[str, Any]] = None + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class InteractionInProgress(BaseModel): + """Emitted while the interaction is running.""" + + event_type: Literal["interaction.in_progress"] = "interaction.in_progress" + interaction_id: Optional[str] = None + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class InteractionCompleted(BaseModel): + """Emitted when the interaction finishes (replaces interaction.complete).""" + + event_type: Literal["interaction.completed"] = "interaction.completed" + interaction: Optional[Dict[str, Any]] = None + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + +class InteractionRequiresAction(BaseModel): + """Emitted when the interaction is paused waiting for a tool result.""" + + event_type: Literal["interaction.requires_action"] = "interaction.requires_action" + interaction_id: Optional[str] = None + event_id: Optional[str] = Field( + None, + description="The event_id token to be used to resume the interaction stream.", + ) + + class InteractionSseEvent( RootModel[ Union[ + # New schema events (Api-Revision: 2026-05-20) + StepStart, + StepDelta, + StepStop, + InteractionCreated, + InteractionInProgress, + InteractionCompleted, + InteractionRequiresAction, + # Legacy schema events (Api-Revision: 2026-05-07, removed June 8 2026) InteractionEvent, InteractionStatusUpdate, ContentStart, @@ -1164,6 +1269,15 @@ class InteractionSseEvent( ] ): root: Union[ + # New schema events (Api-Revision: 2026-05-20) + StepStart, + StepDelta, + StepStop, + InteractionCreated, + InteractionInProgress, + InteractionCompleted, + InteractionRequiresAction, + # Legacy schema events (Api-Revision: 2026-05-07, removed June 8 2026) InteractionEvent, InteractionStatusUpdate, ContentStart, @@ -1193,6 +1307,11 @@ class InteractionsAPIResponse(BaseLiteLLMOpenAIResponseObject): Response from the Interactions API. Wraps the API response with LiteLLM-specific hidden params. + + Schema notes: + - New schema (Api-Revision: 2026-05-20, default): response contains ``steps``. + - Legacy schema (Api-Revision: 2026-05-07, removed June 8 2026): response contains ``outputs``. + Both fields are kept here so callers work with either schema. """ id: Optional[str] = None @@ -1203,7 +1322,10 @@ class InteractionsAPIResponse(BaseLiteLLMOpenAIResponseObject): created: Optional[str] = None updated: Optional[str] = None role: Optional[str] = None + # Legacy schema field (Api-Revision: 2026-05-07). Remove after June 8, 2026. outputs: Optional[List[Dict[str, Any]]] = None + # New schema field (Api-Revision: 2026-05-20). + steps: Optional[List[Dict[str, Any]]] = None usage: Optional[Dict[str, Any]] = None _hidden_params: dict = PrivateAttr(default_factory=dict) @@ -1213,7 +1335,12 @@ class InteractionsAPIStreamingResponse(BaseLiteLLMOpenAIResponseObject): """ Streaming response chunk from the Interactions API. - Event types per OpenAPI spec: + New schema event types (Api-Revision: 2026-05-20): + - interaction.created, interaction.in_progress, interaction.completed, + interaction.requires_action + - step.start, step.delta, step.stop + + Legacy event types (Api-Revision: 2026-05-07, removed June 8 2026): - interaction.start, interaction.status_update, interaction.complete - content.start, content.delta, content.stop - error @@ -1228,9 +1355,17 @@ class InteractionsAPIStreamingResponse(BaseLiteLLMOpenAIResponseObject): created: Optional[str] = None updated: Optional[str] = None role: Optional[str] = None + # Legacy schema field (Api-Revision: 2026-05-07). Remove after June 8, 2026. outputs: Optional[List[Dict[str, Any]]] = None + # New schema field (Api-Revision: 2026-05-20). + steps: Optional[List[Dict[str, Any]]] = None usage: Optional[Dict[str, Any]] = None delta: Optional[Dict[str, Any]] = None + # New schema streaming fields + index: Optional[int] = None + step: Optional[Dict[str, Any]] = None + interaction_id: Optional[str] = None + interaction: Optional[Dict[str, Any]] = None _hidden_params: dict = PrivateAttr(default_factory=dict) @@ -1257,3 +1392,6 @@ class CancelInteractionResult(BaseLiteLLMOpenAIResponseObject): InteractionTool = Tool InteractionToolChoiceConfig = ToolChoiceConfig InteractionsAPIOptionalRequestParams = Dict[str, Any] + +# Agent interaction execution environment +InteractionEnvironment = Union[str, Dict[str, Any]] diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 87bf11a9026..a1d53978761 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -14,13 +14,19 @@ from litellm.types.llms.openai import EmbeddingInput GeminiEmbeddingInput = Union[EmbeddingInput, List[List[str]]] -class FunctionResponse(TypedDict): - name: str +class FunctionResponse(TypedDict, total=False): + # `id` correlates this response with the originating `functionCall` part. + # Supported on Google AI Studio Gemini 3.5+; Vertex AI rejects this field. + id: str + name: Required[str] response: Optional[dict] -class FunctionCall(TypedDict): - name: str +class FunctionCall(TypedDict, total=False): + # `id` correlates the corresponding `functionResponse` on Google AI Studio + # Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field. + id: str + name: Required[str] args: Optional[dict] @@ -45,8 +51,11 @@ class PartType(TypedDict, total=False): media_resolution: Literal["low", "medium", "high"] -class HttpxFunctionCall(TypedDict): - name: str +class HttpxFunctionCall(TypedDict, total=False): + # `id` correlates the corresponding `functionResponse` on Google AI Studio + # Gemini 3.5+. Vertex AI and older Gemini models omit/reject this field. + id: str + name: Required[str] args: dict diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6084f14e2df..282baff07fe 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -147,6 +147,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_low_reasoning_effort: Optional[bool] supports_xhigh_reasoning_effort: Optional[bool] supports_max_reasoning_effort: Optional[bool] + supports_output_config: Optional[bool] class SearchContextCostPerQuery(TypedDict, total=False): @@ -243,6 +244,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): float ] # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) ocr_cost_per_page: Optional[float] # for OCR models + ocr_cost_per_credit: Optional[float] # for OCR models priced by credit annotation_cost_per_page: Optional[float] # for OCR models search_context_cost_per_query: Optional[ SearchContextCostPerQuery @@ -260,6 +262,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): "chat", "audio_transcription", "responses", + "ocr", ] ] tpm: Optional[int] @@ -2765,6 +2768,20 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False): risk_score: Optional[float] """Risk score 0-10 indicating how risky the request was (higher = riskier). Computed by the guardrail provider.""" + violation_categories: Optional[List[str]] + """Names of the policy items that intervened on this request (e.g. Bedrock + topic-policy topic names, content-policy filter types, PII entity types). + Populated by the provider hook before redaction so downstream loggers + (OTEL, Langfuse, ...) can filter by violation category without parsing + the raw guardrail_response blob. Empty/absent when the guardrail allowed + the request through.""" + + guardrail_action: Optional[str] + """Provider's raw top-level action string (e.g. Bedrock's ``GUARDRAIL_INTERVENED`` + or ``NONE``). Populated by the provider hook so the OTEL integration can + surface it as a queryable span attribute without parsing the raw + guardrail_response blob.""" + class EvalVerdict(TypedDict, total=False): criterion_name: str @@ -2806,6 +2823,8 @@ class GuardrailTracingDetail(TypedDict, total=False): patterns_checked: Optional[int] alert_recipients: Optional[List[str]] risk_score: Optional[float] + violation_categories: Optional[List[str]] + guardrail_action: Optional[str] StandardLoggingPayloadStatus = Literal["success", "failure"] @@ -3219,6 +3238,7 @@ class LlmProviders(str, Enum): ANTHROPIC_TEXT = "anthropic_text" BYTEZ = "bytez" REPLICATE = "replicate" + REDUCTO = "reducto" RUNWAYML = "runwayml" AWS_POLLY = "aws_polly" HUGGINGFACE = "huggingface" diff --git a/litellm/utils.py b/litellm/utils.py index 001c89fee4c..2487d39bd0d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5387,6 +5387,16 @@ def _strip_model_name(model: str, custom_llm_provider: Optional[str]) -> str: # Global case-insensitive lookup map for model_cost (built eagerly at module import) _model_cost_lowercase_map: Optional[Dict[str, str]] = None +# Monotonic counter bumped on every model_cost mutation. Consumers that +# memoize derived state (e.g. provider-specific indices) can include this +# value in their cache key so they invalidate even when key add+remove or +# in-place value replacement leaves len/id unchanged. +_model_cost_mutation_generation: int = 0 + + +def get_model_cost_mutation_generation() -> int: + return _model_cost_mutation_generation + def _invalidate_model_cost_lowercase_map() -> None: """Invalidate the case-insensitive lookup map for model_cost. @@ -5394,8 +5404,9 @@ def _invalidate_model_cost_lowercase_map() -> None: Call this whenever litellm.model_cost is modified to ensure the map is rebuilt. Also clears related LRU caches that depend on model_cost data. """ - global _model_cost_lowercase_map + global _model_cost_lowercase_map, _model_cost_mutation_generation _model_cost_lowercase_map = None + _model_cost_mutation_generation += 1 # Clear LRU caches that depend on model_cost data get_model_info.cache_clear() @@ -5986,6 +5997,7 @@ def _get_model_info_helper( # noqa: PLR0915 tpm=_model_info.get("tpm", None), rpm=_model_info.get("rpm", None), ocr_cost_per_page=_model_info.get("ocr_cost_per_page", None), + ocr_cost_per_credit=_model_info.get("ocr_cost_per_credit", None), annotation_cost_per_page=_model_info.get( "annotation_cost_per_page", None ), @@ -9241,6 +9253,18 @@ class ProviderConfigManager: return get_vertex_ai_ocr_config(model=model) + if provider == litellm.LlmProviders.REDUCTO: + from litellm.llms.reducto.ocr.transformation import ( + ReductoParseLegacyConfig, + ReductoParseV3Config, + ) + + if model == "parse-v3": + return ReductoParseV3Config() + if model == "parse-legacy": + return ReductoParseLegacyConfig() + return None + MistralOCRConfig = getattr(sys.modules[__name__], "MistralOCRConfig") PROVIDER_TO_CONFIG_MAP = { litellm.LlmProviders.MISTRAL: MistralOCRConfig, diff --git a/litellm/vector_store_files/utils.py b/litellm/vector_store_files/utils.py index ffe73516bda..1ee5b47e306 100644 --- a/litellm/vector_store_files/utils.py +++ b/litellm/vector_store_files/utils.py @@ -21,7 +21,7 @@ class VectorStoreFileRequestUtils: @staticmethod def get_create_request_params( - params: Dict[str, Any] + params: Dict[str, Any], ) -> VectorStoreFileCreateRequest: filtered = VectorStoreFileRequestUtils._filter_params( params=params, model=VectorStoreFileCreateRequest @@ -37,7 +37,7 @@ class VectorStoreFileRequestUtils: @staticmethod def get_update_request_params( - params: Dict[str, Any] + params: Dict[str, Any], ) -> VectorStoreFileUpdateRequest: filtered = VectorStoreFileRequestUtils._filter_params( params=params, model=VectorStoreFileUpdateRequest diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 94f0f1e78d3..31a5993a240 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1011,6 +1011,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -1041,6 +1042,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -1071,6 +1073,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -1100,6 +1103,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -1129,6 +1133,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -1328,6 +1333,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { @@ -1358,6 +1364,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { @@ -1388,6 +1395,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { @@ -1417,6 +1425,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { @@ -1446,6 +1455,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "jp.anthropic.claude-sonnet-4-6": { @@ -1475,6 +1485,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { @@ -1996,6 +2007,7 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -2093,6 +2105,7 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { @@ -9643,6 +9656,7 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { @@ -9840,6 +9854,7 @@ "us": 1.1, "fast": 6.0 }, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -9875,7 +9890,8 @@ "fast": 6.0 }, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "supports_output_config": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9910,7 +9926,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "supports_output_config": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9945,7 +9962,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "supports_output_config": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -13982,6 +14000,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/glm-5p1": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://fireworks.ai/models/fireworks/glm-5p1", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false + }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "fireworks_ai", @@ -14248,6 +14281,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/glm-5p1": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://fireworks.ai/models/fireworks/glm-5p1", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": false + }, "fireworks_ai/kimi-k2p5": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 6e-07, @@ -14957,6 +15005,64 @@ "web_search_billing_unit": "per_query", "supports_service_tier": true }, + "gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -15645,6 +15751,64 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_audio_token": 1e-06, + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -16963,6 +17127,66 @@ "web_search_billing_unit": "per_query", "supports_service_tier": true }, + "gemini/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 250000, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, @@ -17022,6 +17246,67 @@ }, "web_search_billing_unit": "per_query" }, + "gemini/gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "rpm": 2000, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 800000, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -17207,6 +17492,65 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-3.5-flash": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "input_cost_per_token_priority": 2.7e-06, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 1.62e-05, + "cache_read_input_token_cost_priority": 2.7e-07, + "supports_service_tier": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, @@ -24141,6 +24485,21 @@ "supports_tool_choice": true, "supports_vision": true }, + "mistral/ministral-8b-2512": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "mistral/mistral-tiny": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", @@ -26985,6 +27344,58 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -27794,10 +28205,10 @@ "supports_tool_choice": true }, "openrouter/xiaomi/mimo-v2-flash": { - "input_cost_per_token": 9e-08, - "output_cost_per_token": 2.9e-07, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, "cache_creation_input_token_cost": 0.0, - "cache_read_input_token_cost": 0.0, + "cache_read_input_token_cost": 1e-08, "litellm_provider": "openrouter", "max_input_tokens": 262144, "max_output_tokens": 16384, @@ -27807,7 +28218,43 @@ "supports_tool_choice": true, "supports_reasoning": true, "supports_vision": false, - "supports_prompt_caching": false + "supports_prompt_caching": true + }, + "openrouter/xiaomi/mimo-v2.5-pro": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false, + "supports_response_schema": true, + "supports_prompt_caching": true + }, + "openrouter/xiaomi/mimo-v2.5": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2e-06, + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 8e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_audio_input": true, + "supports_video_input": true, + "supports_response_schema": true, + "supports_prompt_caching": true }, "openrouter/z-ai/glm-4.7": { "input_cost_per_token": 4e-07, @@ -28538,14 +28985,16 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "supports_output_config": true }, "perplexity/anthropic/claude-opus-4-7": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "supports_output_config": true }, "perplexity/anthropic/claude-opus-4-5": { "litellm_provider": "perplexity", @@ -28759,6 +29208,24 @@ "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, + "reducto/parse-legacy": { + "litellm_provider": "reducto", + "mode": "ocr", + "ocr_cost_per_credit": 0.015, + "source": "https://reducto.ai/pricing", + "supported_endpoints": [ + "/v1/ocr" + ] + }, + "reducto/parse-v3": { + "litellm_provider": "reducto", + "mode": "ocr", + "ocr_cost_per_credit": 0.015, + "source": "https://reducto.ai/pricing", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "recraft/recraftv2": { "litellm_provider": "recraft", "mode": "image_generation", @@ -32938,6 +33405,7 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -32966,6 +33434,7 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_output_config": true, "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, @@ -33079,6 +33548,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { @@ -33461,6 +33931,64 @@ }, "web_search_billing_unit": "per_query" }, + "vertex_ai/gemini-3.1-flash-lite": { + "cache_read_input_token_cost": 4.5e-08, + "cache_read_input_token_cost_per_audio_token": 9e-08, + "input_cost_per_audio_token": 9e-07, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.7e-06, + "output_cost_per_token": 2.7e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query", + "supports_service_tier": true + }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -40133,6 +40661,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_output_config": true, "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 1d577213a1b..388752b032e 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1904,6 +1904,23 @@ "rerank": false } }, + "reducto": { + "display_name": "Reducto (`reducto`)", + "url": "https://docs.litellm.ai/docs/providers/reducto", + "endpoints": { + "chat_completions": false, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "ocr": true + } + }, "replicate": { "display_name": "Replicate (`replicate`)", "url": "https://docs.litellm.ai/docs/providers/replicate", diff --git a/pyproject.toml b/pyproject.toml index f63770105dc..f2686047f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.86.0" +version = "1.87.0" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -33,8 +33,9 @@ Homepage = "https://litellm.ai" Repository = "https://github.com/BerriAI/litellm" Documentation = "https://docs.litellm.ai" -# Dependencies pinned from the published `litellm[proxy]==1.83.0` resolution. -# Docker and CI should prefer `uv.lock` rather than maintaining parallel installers. +# Optional extras retain exact pins because they are consumed by Docker images +# where exact reproducibility matters. The core SDK uses ranges so downstream +# consumers can coexist with other packages without forced downgrades. [project.optional-dependencies] proxy = [ "gunicorn==23.0.0", @@ -56,7 +57,7 @@ proxy = [ "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "mcp==1.26.0", - "litellm-proxy-extras==0.4.72", + "litellm-proxy-extras==0.4.73", "litellm-enterprise==0.1.41", "RestrictedPython==8.1", "rich==13.9.4", @@ -131,7 +132,7 @@ litellm-proxy = "litellm.proxy.client.cli:cli" dev = [ "diff-cover==9.7.2", "flake8==7.3.0", - "black==24.10.0", + "black==26.3.1", "mypy==1.19.0", "pytest==9.0.3", "pytest-mock==3.15.1", @@ -251,7 +252,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.86.0" +version = "1.87.0" version_files = [ "pyproject.toml:^version", ] @@ -287,6 +288,7 @@ paths_to_mutate = [ ] tests_dir = [ "tests/test_litellm/proxy/management_endpoints/", + "tests/proxy_behavior/management/", ] also_copy = [ "litellm/", @@ -312,3 +314,4 @@ pytest_add_cli_args = [ [tool.coverage.run] source = ["litellm"] relative_files = true + diff --git a/tests/code_coverage_tests/liccheck.ini b/tests/code_coverage_tests/liccheck.ini index 3d53ecede7b..0d1a6f0b045 100644 --- a/tests/code_coverage_tests/liccheck.ini +++ b/tests/code_coverage_tests/liccheck.ini @@ -156,6 +156,7 @@ pytest: >=9.0.3 # MIT license pytest-postgresql: >=7.0.2 # LGPLv3+ license pytest-xdist: >=3.8.0 # MIT License ruff: >=0.15.3 # MIT License +black: >=26.3.1 # MIT License manually verified (uses PEP 639 License-Expression: MIT, not the legacy License field, so liccheck reports it as unknown) types-requests: >=2.32.4.20260107 # Apache 2.0 license (typeshed) types-pyyaml: >=6.0.12.20250915 # Apache 2.0 license (typeshed) fakeredis: >=2.34.1 # BSD license diff --git a/tests/llm_responses_api_testing/test_anthropic_responses_api.py b/tests/llm_responses_api_testing/test_anthropic_responses_api.py index 6537f67acb9..68ff22e8938 100644 --- a/tests/llm_responses_api_testing/test_anthropic_responses_api.py +++ b/tests/llm_responses_api_testing/test_anthropic_responses_api.py @@ -3,7 +3,7 @@ import sys import pytest import asyncio from typing import Optional -from unittest.mock import patch, AsyncMock +from unittest.mock import patch, AsyncMock, MagicMock from litellm.responses.litellm_completion_transformation.handler import ( LiteLLMCompletionTransformationHandler, ) @@ -130,6 +130,26 @@ def test_multiturn_tool_calls(): print("follow_up_response=", follow_up_response) +def test_response_api_handler_merges_metadata_and_service_tier_without_error(): + """Sync path must merge kwargs like async; double-splat raises TypeError.""" + handler = LiteLLMCompletionTransformationHandler() + + with patch("litellm.completion", new_callable=MagicMock) as mock_completion: + mock_completion.return_value = ModelResponse( + id="id", created=0, model="test", object="chat.completion", choices=[] + ) + handler.response_api_handler( + model="test", + input="hi", + responses_api_request={}, + metadata={"trace": "abc"}, + service_tier="auto", + ) + assert mock_completion.call_count == 1 + assert mock_completion.call_args.kwargs["metadata"] == {"trace": "abc"} + assert mock_completion.call_args.kwargs["service_tier"] == "auto" + + @pytest.mark.asyncio async def test_async_response_api_handler_merges_trace_id_without_error(): handler = LiteLLMCompletionTransformationHandler() @@ -158,3 +178,39 @@ async def test_async_response_api_handler_merges_trace_id_without_error(): assert ( mock_acompletion.call_args.kwargs["litellm_trace_id"] == "session-trace" ) + + +@pytest.mark.asyncio +async def test_aresponses_forwards_timeout_to_acompletion(): + """Regression test: timeout passed to aresponses() must reach acompletion() + on the completion transformation path (Anthropic, Bedrock, Vertex etc.). + + Previously, `timeout` was a named param of `responses()` but was NOT + forwarded to `litellm_completion_transformation_handler.response_api_handler`, + so it was silently dropped — `Router(timeout=N)` was a no-op for Anthropic + and similar providers, with calls falling back to the provider SDK default + (~600s for Anthropic). + """ + with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = ModelResponse( + id="id", + created=0, + model="anthropic/claude-sonnet-4-5", + object="chat.completion", + choices=[], + ) + + await litellm.aresponses( + model="anthropic/claude-sonnet-4-5", + input="hello", + timeout=42, + api_key="sk-ant-fake", + ) + + assert mock_acompletion.call_count == 1 + forwarded_timeout = mock_acompletion.call_args.kwargs.get("timeout") + assert forwarded_timeout == 42, ( + f"timeout was not forwarded to acompletion (got {forwarded_timeout!r}); " + "this means Router(timeout=N) silently fails for providers on the " + "completion transformation path." + ) diff --git a/tests/llm_translation/realtime/base_realtime_tests.py b/tests/llm_translation/realtime/base_realtime_tests.py index 1d55f13b00d..1a2c6ff6a9c 100644 --- a/tests/llm_translation/realtime/base_realtime_tests.py +++ b/tests/llm_translation/realtime/base_realtime_tests.py @@ -10,7 +10,7 @@ import json import os import sys from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Tuple, Union import pytest import websockets @@ -79,7 +79,7 @@ class RealTimeWebSocketClient: def _is_initial_event(self, msg_type: str) -> bool: """Check if message type is an initial connection event""" - # OpenAI sends "session.created", xAI sends "conversation.created" + # OpenAI and xAI send "session.created"; some providers send "conversation.created" return msg_type in ["session.created", "conversation.created"] async def receive_text(self): @@ -153,8 +153,14 @@ class BaseRealtimeTest(ABC): pass @abstractmethod - def get_initial_event_type(self) -> str: - """Return the expected initial event type (e.g., 'session.created' or 'conversation.created')""" + def get_initial_event_type(self) -> Union[str, Tuple[str, ...]]: + """Return the expected initial event type(s). + + May return a single event type (e.g. ``'session.created'``) or a tuple + of acceptable types when the upstream provider can legitimately emit + more than one initial event (e.g. xAI's Grok Voice Agent has shipped + both ``conversation.created`` and ``session.created``). + """ pass def get_skip_reason(self) -> str: @@ -229,9 +235,14 @@ class BaseRealtimeTest(ABC): # Verify initial event initial_event = websocket_client.messages_received[0] + expected_event_type = self.get_initial_event_type() + if isinstance(expected_event_type, str): + allowed_event_types: Tuple[str, ...] = (expected_event_type,) + else: + allowed_event_types = tuple(expected_event_type) assert ( - initial_event["type"] == self.get_initial_event_type() - ), f"Expected {self.get_initial_event_type()}, got {initial_event.get('type')}" + initial_event["type"] in allowed_event_types + ), f"Expected one of {allowed_event_types}, got {initial_event.get('type')}" @pytest.mark.asyncio async def test_realtime_with_query_params(self): diff --git a/tests/llm_translation/realtime/test_xai_realtime.py b/tests/llm_translation/realtime/test_xai_realtime.py index 0bb7a59bb1a..8ffcb3db30d 100644 --- a/tests/llm_translation/realtime/test_xai_realtime.py +++ b/tests/llm_translation/realtime/test_xai_realtime.py @@ -7,6 +7,7 @@ Uses the base test class to ensure consistent behavior across providers. import os import sys +from typing import Tuple import pytest @@ -19,10 +20,12 @@ class TestXAIRealtime(BaseRealtimeTest): """ E2E tests for xAI Realtime API. - xAI's Grok Voice Agent API is OpenAI-compatible but uses: - - Different initial event: "conversation.created" instead of "session.created" - - Different endpoint: wss://api.x.ai/v1/realtime + xAI's Grok Voice Agent API is OpenAI-compatible: + - Endpoint: wss://api.x.ai/v1/realtime - Model: grok-4-1-fast-non-reasoning + - Initial event: historically "conversation.created"; xAI has since shipped + "session.created" (matching OpenAI). Accept either to avoid spurious + failures whenever xAI flips the wire format. """ def get_model(self) -> str: @@ -31,5 +34,5 @@ class TestXAIRealtime(BaseRealtimeTest): def get_api_key_env_var(self) -> str: return "XAI_API_KEY" - def get_initial_event_type(self) -> str: - return "conversation.created" + def get_initial_event_type(self) -> Tuple[str, ...]: + return ("conversation.created", "session.created") diff --git a/tests/llm_translation/test_gpt4o_audio.py b/tests/llm_translation/test_gpt4o_audio.py index 169fe855163..a50d07406d4 100644 --- a/tests/llm_translation/test_gpt4o_audio.py +++ b/tests/llm_translation/test_gpt4o_audio.py @@ -59,7 +59,7 @@ async def test_audio_output_from_model(stream): litellm.set_verbose = False try: completion = await litellm.acompletion( - model="gpt-4o-audio-preview", + model="gpt-audio-1.5", modalities=["text", "audio"], audio={"voice": "alloy", "format": "pcm16"}, messages=[{"role": "user", "content": "response in 1 word - yes or no"}], @@ -69,8 +69,14 @@ async def test_audio_output_from_model(stream): print(e) pytest.skip("Skipping test due to timeout") except Exception as e: - if "openai-internal" in str(e): - pytest.skip("Skipping test due to openai-internal error") + err = str(e).lower() + if ( + "model_not_found" in err + or "does not exist" in err + or "openai-internal" in err + ): + pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}") + raise if stream is True: await check_streaming_response(completion) @@ -85,7 +91,7 @@ async def test_audio_output_from_model(stream): @pytest.mark.asyncio @pytest.mark.parametrize("stream", [True, False]) -@pytest.mark.parametrize("model", ["gpt-4o-audio-preview"]) # "gpt-4o-audio-preview", +@pytest.mark.parametrize("model", ["gpt-audio-1.5"]) async def test_audio_input_to_model(stream, model): # Fetch the audio file and convert it to a base64 encoded string audio_format = "pcm16" @@ -121,9 +127,14 @@ async def test_audio_input_to_model(stream, model): print(e) pytest.skip("Skipping test due to timeout") except Exception as e: - if "openai-internal" in str(e): - pytest.skip("Skipping test due to openai-internal error") - raise e + err = str(e).lower() + if ( + "model_not_found" in err + or "does not exist" in err + or "openai-internal" in err + ): + pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}") + raise if stream is True: await check_streaming_response(completion) else: diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py index 6a746041f15..acb79a7577d 100644 --- a/tests/local_testing/conftest.py +++ b/tests/local_testing/conftest.py @@ -22,6 +22,18 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm +# ``litellm.model_cost`` is loaded at import time from the URL pinned to +# ``main`` (``LITELLM_MODEL_COST_MAP_URL``). The in-tree backup ships with +# this branch and can include pricing entries that main has not yet picked +# up (e.g. an upstream provider rotates a model id and the test cassette +# records the new name). Backfill any entries that are missing from the +# remote-fetched map so cost-calculator lookups in tests succeed against +# the cassette state the branch is being tested with. +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + +for _k, _v in GetModelCostMap.load_local_model_cost_map().items(): + litellm.model_cost.setdefault(_k, _v) + from tests._vcr_conftest_common import ( # noqa: E402,F401 VerboseReporterState, _pin_multipart_boundary, diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index 9782bf3c2af..2382b8a5197 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -4223,7 +4223,13 @@ def test_gemini_google_maps_tool_simple(): ) print(f"Response: {response.model_dump_json(indent=4)}") assert response.choices[0].message.content is not None - except litellm.RateLimitError: + except (litellm.RateLimitError, litellm.InternalServerError): + # Transient Vertex-side failures (rate limiting, 500 INTERNAL from the + # Google Maps grounding backend) are not LiteLLM bugs — don't fail CI. pass + except litellm.InternalServerError: + pytest.skip( + "Google Maps Platform returned a transient 500 (upstream flake); skipping." + ) except Exception as e: pytest.fail(f"Error occurred: {e}") diff --git a/tests/local_testing/test_custom_callback_input.py b/tests/local_testing/test_custom_callback_input.py index 545039e60ba..6a4ec9206f7 100644 --- a/tests/local_testing/test_custom_callback_input.py +++ b/tests/local_testing/test_custom_callback_input.py @@ -1125,7 +1125,7 @@ def test_standard_logging_payload_audio(turn_off_message_logging, stream): ) as mock_client: try: response = litellm.completion( - model="gpt-4o-audio-preview", + model="gpt-audio-1.5", modalities=["text", "audio"], audio={"voice": "alloy", "format": "pcm16"}, messages=[ @@ -1134,8 +1134,14 @@ def test_standard_logging_payload_audio(turn_off_message_logging, stream): stream=stream, ) except Exception as e: - if "openai-internal" in str(e): - pytest.skip("Skipping test due to openai-internal error") + err = str(e).lower() + if ( + "model_not_found" in err + or "does not exist" in err + or "openai-internal" in err + ): + pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}") + raise if stream: for chunk in response: diff --git a/tests/local_testing/test_stream_chunk_builder.py b/tests/local_testing/test_stream_chunk_builder.py index 24fdf49c16c..38e04b93f18 100644 --- a/tests/local_testing/test_stream_chunk_builder.py +++ b/tests/local_testing/test_stream_chunk_builder.py @@ -649,7 +649,7 @@ def test_stream_chunk_builder_openai_audio_output_usage(): try: completion = client.chat.completions.create( - model="gpt-4o-audio-preview", + model="gpt-audio-1.5", modalities=["text", "audio"], audio={"voice": "alloy", "format": "pcm16"}, messages=[{"role": "user", "content": "response in 1 word - yes or no"}], @@ -657,8 +657,14 @@ def test_stream_chunk_builder_openai_audio_output_usage(): stream_options={"include_usage": True}, ) except Exception as e: - if "openai-internal" in str(e): - pytest.skip("Skipping test due to openai-internal error") + err = str(e).lower() + if ( + "model_not_found" in err + or "does not exist" in err + or "openai-internal" in err + ): + pytest.skip(f"Skipping - upstream gpt-audio-1.5 unavailable: {e}") + raise chunks = [] for chunk in completion: diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 409f4fad99a..809b13aeea6 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -382,6 +382,11 @@ async def test_mcp_http_transport_tool_not_found(): } ) + # Mapping populated for this server but not for the requested tool + test_manager.tool_name_to_mcp_server_name_mapping["gmail_send_email"] = ( + "test_http_server" + ) + # Try to call a tool that doesn't exist in mapping with pytest.raises(ValueError, match="Tool nonexistent_tool not found"): await test_manager.call_tool( @@ -881,6 +886,7 @@ async def test_get_tools_from_mcp_servers(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ): if server.server_id == "server1_id": return [mock_tool_1] @@ -1856,6 +1862,7 @@ async def test_get_tools_for_single_server(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ) # Verify the result diff --git a/tests/proxy_behavior/__init__.py b/tests/proxy_behavior/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/proxy_behavior/management/__init__.py b/tests/proxy_behavior/management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/proxy_behavior/management/actors.py b/tests/proxy_behavior/management/actors.py new file mode 100644 index 00000000000..6c2f1a61ce1 --- /dev/null +++ b/tests/proxy_behavior/management/actors.py @@ -0,0 +1,279 @@ +"""Read-world seed for the authz matrix tests: 2 orgs, 3 teams, 9 actors.""" + +import enum +import uuid +from dataclasses import dataclass +from typing import Any, Dict + +from prisma import Json + +from litellm.proxy._types import LitellmUserRoles +from litellm.proxy.utils import PrismaClient, hash_token + + +class Actor(str, enum.Enum): + PROXY_ADMIN = "proxy_admin" + ORG_ADMIN = "org_admin" + TEAM_ADMIN = "team_admin" + INTERNAL_USER = "internal_user" + OWNER = "owner" + UNRELATED_SAME_ORG = "unrelated_same_org" + CROSS_ORG_USER = "cross_org_user" + SERVICE_ACCOUNT = "service_account" + ORG_B_ADMIN = "org_b_admin" + + +PREFIX = "behavior-pin-" +ORG_A = PREFIX + "org-a" +ORG_B = PREFIX + "org-b" +TEAM_ALPHA = PREFIX + "team-alpha" +TEAM_BETA = PREFIX + "team-beta" +TEAM_GAMMA = PREFIX + "team-gamma" +BUDGET_ID = PREFIX + "budget" + + +@dataclass(frozen=True) +class SeededKey: + user_id: str + cleartext: str + hashed: str + + +@dataclass(frozen=True) +class World: + org_a_id: str + org_b_id: str + team_alpha_id: str + team_beta_id: str + team_gamma_id: str + keys: Dict[Actor, SeededKey] + + +def _new_clear_key() -> str: + return "sk-" + uuid.uuid4().hex + + +def _actor_profile() -> Dict[Actor, Dict[str, Any]]: + return { + Actor.PROXY_ADMIN: { + "user_role": LitellmUserRoles.PROXY_ADMIN.value, + "team_id": None, + "organization_id": None, + }, + Actor.ORG_ADMIN: { + "user_role": LitellmUserRoles.ORG_ADMIN.value, + "team_id": None, + "organization_id": ORG_A, + }, + Actor.TEAM_ADMIN: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_ALPHA, + "organization_id": ORG_A, + }, + Actor.INTERNAL_USER: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_ALPHA, + "organization_id": ORG_A, + }, + Actor.OWNER: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_ALPHA, + "organization_id": ORG_A, + }, + Actor.UNRELATED_SAME_ORG: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_ALPHA, + "organization_id": ORG_A, + }, + Actor.CROSS_ORG_USER: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_BETA, + "organization_id": ORG_B, + }, + Actor.SERVICE_ACCOUNT: { + "user_role": LitellmUserRoles.INTERNAL_USER.value, + "team_id": TEAM_ALPHA, + "organization_id": ORG_A, + }, + Actor.ORG_B_ADMIN: { + "user_role": LitellmUserRoles.ORG_ADMIN.value, + "team_id": None, + "organization_id": ORG_B, + }, + } + + +async def _wipe_world(prisma: PrismaClient) -> None: + await prisma.db.litellm_verificationtoken.delete_many( + where={"user_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_organizationmembership.delete_many( + where={"user_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_teammembership.delete_many( + where={"user_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_usertable.delete_many( + where={"user_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_teamtable.delete_many( + where={"team_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_organizationtable.delete_many( + where={"organization_id": {"startswith": PREFIX}} + ) + await prisma.db.litellm_budgettable.delete_many(where={"budget_id": BUDGET_ID}) + + +async def seed_world(prisma: PrismaClient) -> World: + await _wipe_world(prisma) + + await prisma.db.litellm_budgettable.create( + data={ + "budget_id": BUDGET_ID, + "created_by": "behavior-pin-seeder", + "updated_by": "behavior-pin-seeder", + } + ) + + for org_id, alias in [(ORG_A, "alpha"), (ORG_B, "beta")]: + await prisma.db.litellm_organizationtable.create( + data={ + "organization_id": org_id, + "organization_alias": alias, + "budget_id": BUDGET_ID, + "created_by": "behavior-pin-seeder", + "updated_by": "behavior-pin-seeder", + } + ) + + profiles = _actor_profile() + user_ids: Dict[Actor, str] = {actor: PREFIX + actor.value for actor in Actor} + + for actor, profile in profiles.items(): + teams_list = [profile["team_id"]] if profile["team_id"] else [] + await prisma.db.litellm_usertable.create( + data={ + "user_id": user_ids[actor], + "user_role": profile["user_role"], + "team_id": profile["team_id"], + "organization_id": profile["organization_id"], + "teams": teams_list, + } + ) + + # _get_user_in_team in key_management_endpoints.py walks members_with_roles + # (a JSON list of {user_id, role}), not the String[] members column — + # populate both to match what /team/new produces. + await prisma.db.litellm_teamtable.create( + data={ + "team_id": TEAM_ALPHA, + "team_alias": "alpha-1", + "organization_id": ORG_A, + "admins": [user_ids[Actor.TEAM_ADMIN]], + "members": [ + user_ids[Actor.TEAM_ADMIN], + user_ids[Actor.INTERNAL_USER], + user_ids[Actor.OWNER], + user_ids[Actor.UNRELATED_SAME_ORG], + user_ids[Actor.SERVICE_ACCOUNT], + ], + "members_with_roles": Json( + [ + {"user_id": user_ids[Actor.TEAM_ADMIN], "role": "admin"}, + {"user_id": user_ids[Actor.INTERNAL_USER], "role": "user"}, + {"user_id": user_ids[Actor.OWNER], "role": "user"}, + {"user_id": user_ids[Actor.UNRELATED_SAME_ORG], "role": "user"}, + {"user_id": user_ids[Actor.SERVICE_ACCOUNT], "role": "user"}, + ] + ), + } + ) + await prisma.db.litellm_teamtable.create( + data={ + "team_id": TEAM_BETA, + "team_alias": "beta-1", + "organization_id": ORG_B, + "admins": [], + "members": [user_ids[Actor.CROSS_ORG_USER]], + "members_with_roles": Json( + [ + {"user_id": user_ids[Actor.CROSS_ORG_USER], "role": "user"}, + ] + ), + } + ) + # TEAM_GAMMA: ORG_A team with no actor members — the "same-org, + # not-my-team" read target. + await prisma.db.litellm_teamtable.create( + data={ + "team_id": TEAM_GAMMA, + "team_alias": "gamma-1", + "organization_id": ORG_A, + "admins": [], + "members": [], + "members_with_roles": Json([]), + } + ) + + for actor, org_id, role in [ + (Actor.ORG_ADMIN, ORG_A, "org_admin"), + (Actor.TEAM_ADMIN, ORG_A, "internal_user"), + (Actor.INTERNAL_USER, ORG_A, "internal_user"), + (Actor.OWNER, ORG_A, "internal_user"), + (Actor.UNRELATED_SAME_ORG, ORG_A, "internal_user"), + (Actor.SERVICE_ACCOUNT, ORG_A, "internal_user"), + (Actor.CROSS_ORG_USER, ORG_B, "internal_user"), + (Actor.ORG_B_ADMIN, ORG_B, "org_admin"), + ]: + await prisma.db.litellm_organizationmembership.create( + data={ + "user_id": user_ids[actor], + "organization_id": org_id, + "user_role": role, + } + ) + + for actor, team_id in [ + (Actor.TEAM_ADMIN, TEAM_ALPHA), + (Actor.INTERNAL_USER, TEAM_ALPHA), + (Actor.OWNER, TEAM_ALPHA), + (Actor.UNRELATED_SAME_ORG, TEAM_ALPHA), + (Actor.SERVICE_ACCOUNT, TEAM_ALPHA), + (Actor.CROSS_ORG_USER, TEAM_BETA), + ]: + await prisma.db.litellm_teammembership.create( + data={"user_id": user_ids[actor], "team_id": team_id} + ) + + keys: Dict[Actor, SeededKey] = {} + for actor, profile in profiles.items(): + cleartext = _new_clear_key() + hashed = hash_token(cleartext) + token_data: Dict[str, Any] = { + "token": hashed, + "key_name": PREFIX + actor.value + "-key", + "user_id": user_ids[actor], + # LiteLLM_VerificationTokenView's models field rejects NULL even + # though the column is nullable in Postgres. + "models": [], + } + if profile["team_id"]: + token_data["team_id"] = profile["team_id"] + if profile["organization_id"]: + token_data["organization_id"] = profile["organization_id"] + if actor == Actor.SERVICE_ACCOUNT: + token_data["metadata"] = Json({"service_account_id": user_ids[actor]}) + await prisma.db.litellm_verificationtoken.create(data=token_data) + keys[actor] = SeededKey( + user_id=user_ids[actor], cleartext=cleartext, hashed=hashed + ) + + return World( + org_a_id=ORG_A, + org_b_id=ORG_B, + team_alpha_id=TEAM_ALPHA, + team_beta_id=TEAM_BETA, + team_gamma_id=TEAM_GAMMA, + keys=keys, + ) diff --git a/tests/proxy_behavior/management/conftest.py b/tests/proxy_behavior/management/conftest.py new file mode 100644 index 00000000000..3432f4ad6cf --- /dev/null +++ b/tests/proxy_behavior/management/conftest.py @@ -0,0 +1,206 @@ +"""Session-scoped async ASGI client for HTTP-boundary behavior tests.""" + +import os +import tempfile +import uuid +from dataclasses import dataclass +from typing import Any, AsyncIterator, Dict, Optional + +import httpx +import pytest_asyncio +import yaml +from prisma import Json + + +MASTER_KEY = "sk-1234" +SCRATCH_PREFIX = "scratch-" + + +def _write_minimal_proxy_config() -> str: + config = { + "general_settings": {"master_key": MASTER_KEY}, + "litellm_settings": {}, + } + database_url = os.environ.get("DATABASE_URL") + if database_url: + config["general_settings"]["database_url"] = database_url + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) + yaml.dump(config, f) + f.close() + return f.name + + +@pytest_asyncio.fixture(scope="session") +async def proxy_app(): + from litellm.proxy import proxy_server + from litellm.proxy.proxy_server import ( + app, + cleanup_router_config_variables, + initialize, + proxy_startup_event, + ) + + cleanup_router_config_variables() + config_path = _write_minimal_proxy_config() + + # proxy_startup_event re-reads master_key from LITELLM_MASTER_KEY and + # unconditionally overwrites the global, even when initialize() already + # set it from the config YAML. Force (not setdefault) both vars: an + # ambient LITELLM_MASTER_KEY with a different value would make the proxy + # authenticate on that key while the tests still send MASTER_KEY. + os.environ["LITELLM_MASTER_KEY"] = MASTER_KEY + os.environ["CONFIG_FILE_PATH"] = config_path + + await initialize(config=config_path) + + # /key/regenerate is gated behind premium_user; flipping it lets the matrix + # pin authz behavior instead of the licensing gate. + proxy_server.premium_user = True + + async with proxy_startup_event(app): + proxy_server.premium_user = True # lifespan re-runs _license_check + # The lifespan fires check_view_exists() as a background task; on a + # fresh DB the first auth call races it and resolves user_id=None. + if proxy_server.prisma_client is not None: + await proxy_server.prisma_client.check_view_exists() + yield app + + +@pytest_asyncio.fixture(scope="session") +async def proxy_client(proxy_app) -> AsyncIterator[httpx.AsyncClient]: + transport = httpx.ASGITransport(app=proxy_app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + yield client + + +@pytest_asyncio.fixture(scope="session") +async def prisma(proxy_app): + from litellm.proxy import proxy_server + + assert proxy_server.prisma_client is not None + return proxy_server.prisma_client + + +@pytest_asyncio.fixture(scope="session") +async def world(prisma): + from .actors import seed_world + + return await seed_world(prisma) + + +@dataclass(frozen=True) +class Scratch: + prefix: str + + def tag(self, suffix: str = "") -> str: + return f"{self.prefix}-{suffix}" if suffix else self.prefix + + +async def create_scratch_key( + proxy_client, + seeder_cleartext: str, + scratch_prefix: str, + *, + user_id: str, + team_id: Optional[str] = None, + organization_id: Optional[str] = None, +) -> str: + """Seed a scratch-tagged key via /key/generate; returns its cleartext. + + Shared by the write-scenario matrices (key update/regenerate/delete). + """ + body: Dict[str, Any] = {"key_alias": scratch_prefix, "user_id": user_id} + if team_id is not None: + body["team_id"] = team_id + if organization_id is not None: + body["organization_id"] = organization_id + resp = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {seeder_cleartext}"}, + json=body, + ) + assert resp.status_code == 200, f"setup failed: {resp.text}" + return resp.json()["key"] + + +async def create_scratch_team( + prisma, + team_id: str, + *, + organization_id: Optional[str] = None, + admin_user_ids: Optional[list] = None, + member_user_ids: Optional[list] = None, +) -> str: + """Raw-seed a scratch-tagged team row; returns its team_id. + + The target team for the team write matrices (update / member_*). Raw + prisma (not POST /team/new) avoids creation side effects — no creator + auto-add, no membership rows written onto the world's users — so seeding + never mutates the immutable read-world. The authz gates read the team's + members_with_roles JSON, so a raw-seeded team exercises them exactly as + a /team/new-created team would. team_id must start with the scratch + prefix so the `scratch` fixture reclaims the row. + """ + admin_user_ids = list(admin_user_ids or []) + member_user_ids = list(member_user_ids or []) + members_with_roles = [ + {"user_id": uid, "role": "admin"} for uid in admin_user_ids + ] + [{"user_id": uid, "role": "user"} for uid in member_user_ids] + data: Dict[str, Any] = { + "team_id": team_id, + "team_alias": team_id, + "admins": admin_user_ids, + "members": admin_user_ids + member_user_ids, + "members_with_roles": Json(members_with_roles), + } + if organization_id is not None: + data["organization_id"] = organization_id + await prisma.db.litellm_teamtable.create(data=data) + return team_id + + +@pytest_asyncio.fixture +async def scratch(prisma): + handle = Scratch(prefix=f"{SCRATCH_PREFIX}{uuid.uuid4().hex[:12]}") + try: + yield handle + finally: + # Children before parents to avoid FK violations. + await prisma.db.litellm_verificationtoken.delete_many( + where={ + "OR": [ + {"key_alias": {"startswith": handle.prefix}}, + {"key_name": {"startswith": handle.prefix}}, + ] + } + ) + await prisma.db.litellm_teammembership.delete_many( + where={"team_id": {"startswith": handle.prefix}} + ) + await prisma.db.litellm_organizationmembership.delete_many( + where={"user_id": {"startswith": handle.prefix}} + ) + await prisma.db.litellm_teamtable.delete_many( + where={"team_id": {"startswith": handle.prefix}} + ) + await prisma.db.litellm_usertable.delete_many( + where={"user_id": {"startswith": handle.prefix}} + ) + await prisma.db.litellm_budgettable.delete_many( + where={"budget_id": {"startswith": handle.prefix}} + ) + # /team/member_add writes LiteLLM_UserTable.teams; the available-team + # self-join writes it on a world actor whose row must survive. Strip + # dangling scratch-team refs so the read-world stays immutable. + polluted = await prisma.db.litellm_usertable.find_many( + where={"teams": {"isEmpty": False}} + ) + for user in polluted: + cleaned = [t for t in user.teams if not t.startswith(handle.prefix)] + if cleaned != list(user.teams): + await prisma.db.litellm_usertable.update( + where={"user_id": user.user_id}, + data={"teams": {"set": cleaned}}, + ) diff --git a/tests/proxy_behavior/management/test_key_delete.py b/tests/proxy_behavior/management/test_key_delete.py new file mode 100644 index 00000000000..05844ac0031 --- /dev/null +++ b/tests/proxy_behavior/management/test_key_delete.py @@ -0,0 +1,101 @@ +import pytest + +from litellm.proxy.utils import hash_token + +from .actors import TEAM_ALPHA, TEAM_BETA, Actor +from .conftest import create_scratch_key + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# Same-team peers can READ each other's keys (see test_key_info) but cannot +# DELETE them — delete is stricter than read. +_SCENARIOS = [ + ("self/proxy_admin", Actor.PROXY_ADMIN, "self", 200), + ("self/org_admin", Actor.ORG_ADMIN, "self", 401), + ("self/team_admin", Actor.TEAM_ADMIN, "self", 200), + ("self/internal_user", Actor.INTERNAL_USER, "self", 200), + ("self/owner", Actor.OWNER, "self", 200), + ("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "self", 200), + ("self/cross_org_user", Actor.CROSS_ORG_USER, "self", 200), + ("self/service_account", Actor.SERVICE_ACCOUNT, "self", 200), + ("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200), + ("owner_target/org_admin", Actor.ORG_ADMIN, "owner", 401), + ("owner_target/team_admin", Actor.TEAM_ADMIN, "owner", 200), + ("owner_target/internal_user", Actor.INTERNAL_USER, "owner", 403), + ("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 403), + ("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 403), + ("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 403), + ("cross_org_target/proxy_admin", Actor.PROXY_ADMIN, "cross_org", 200), + ("cross_org_target/org_admin", Actor.ORG_ADMIN, "cross_org", 401), + ("cross_org_target/team_admin", Actor.TEAM_ADMIN, "cross_org", 403), + ("cross_org_target/owner", Actor.OWNER, "cross_org", 403), + ("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 200), + ("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 403), +] + + +@pytest.mark.parametrize( + "actor,target_shape,expected_status", + [(a, t, s) for (_id, a, t, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_key_delete_authz_matrix( + actor: Actor, + target_shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + caller = world.keys[actor] + seeder = world.keys[Actor.PROXY_ADMIN].cleartext + + if target_shape == "self": + target_cleartext = await create_scratch_key( + proxy_client, seeder, scratch.prefix, user_id=caller.user_id + ) + elif target_shape == "owner": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.OWNER].user_id, + team_id=TEAM_ALPHA, + ) + elif target_shape == "cross_org": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.CROSS_ORG_USER].user_id, + team_id=TEAM_BETA, + ) + else: + pytest.fail(f"unknown target_shape={target_shape}") + + target_hashed = hash_token(target_cleartext) + + resp = await proxy_client.post( + "/key/delete", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"keys": [target_cleartext]}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_verificationtoken.find_unique( + where={"token": target_hashed} + ) + auth_check = await proxy_client.get( + "/key/info", headers={"Authorization": f"Bearer {target_cleartext}"} + ) + + if expected_status == 200: + # Hard- or soft-delete both produce a 401 on subsequent auth. + assert auth_check.status_code == 401 + else: + assert row is not None, f"{actor.value}: denied but row vanished" + assert auth_check.status_code == 200 diff --git a/tests/proxy_behavior/management/test_key_generate.py b/tests/proxy_behavior/management/test_key_generate.py new file mode 100644 index 00000000000..851de33d3ff --- /dev/null +++ b/tests/proxy_behavior/management/test_key_generate.py @@ -0,0 +1,70 @@ +from typing import Any, Dict + +import pytest + +from .actors import TEAM_ALPHA, TEAM_BETA, Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# (id, actor, body_extras, expected_status). Status codes pinned to observed +# handler behavior — heterogeneous (200, 400, 401) because the handler routes +# denials through three different gates (role gate, user_id mismatch, team +# member permission). +_SCENARIOS = [ + ("self/proxy_admin", Actor.PROXY_ADMIN, {}, 200), + ("self/org_admin", Actor.ORG_ADMIN, {}, 401), + ("self/team_admin", Actor.TEAM_ADMIN, {}, 200), + ("self/internal_user", Actor.INTERNAL_USER, {}, 200), + ("self/owner", Actor.OWNER, {}, 200), + ("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, {}, 200), + ("self/cross_org_user", Actor.CROSS_ORG_USER, {}, 200), + ("self/service_account", Actor.SERVICE_ACCOUNT, {}, 200), + ("team_alpha/proxy_admin", Actor.PROXY_ADMIN, {"team_id": TEAM_ALPHA}, 200), + ("team_alpha/org_admin", Actor.ORG_ADMIN, {"team_id": TEAM_ALPHA}, 401), + ("team_alpha/team_admin", Actor.TEAM_ADMIN, {"team_id": TEAM_ALPHA}, 200), + ("team_alpha/internal_user", Actor.INTERNAL_USER, {"team_id": TEAM_ALPHA}, 401), + ("team_alpha/cross_org_user", Actor.CROSS_ORG_USER, {"team_id": TEAM_ALPHA}, 400), + ("team_beta/proxy_admin", Actor.PROXY_ADMIN, {"team_id": TEAM_BETA}, 200), + ("team_beta/org_admin", Actor.ORG_ADMIN, {"team_id": TEAM_BETA}, 401), + ("team_beta/team_admin", Actor.TEAM_ADMIN, {"team_id": TEAM_BETA}, 400), + ("team_beta/internal_user", Actor.INTERNAL_USER, {"team_id": TEAM_BETA}, 400), + ("team_beta/cross_org_user", Actor.CROSS_ORG_USER, {"team_id": TEAM_BETA}, 401), +] + + +@pytest.mark.parametrize( + "actor,body_extras,expected_status", + [(actor, body, expected) for (_id, actor, body, expected) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_key_generate_authz_matrix( + actor: Actor, + body_extras: Dict[str, Any], + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + seeded = world.keys[actor] + body: Dict[str, Any] = {"key_alias": scratch.prefix, **body_extras} + + resp = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {seeded.cleartext}"}, + json=body, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {body!r} → {resp.status_code}: {resp.text}" + + rows = await prisma.db.litellm_verificationtoken.find_many( + where={"key_alias": scratch.prefix} + ) + if expected_status == 200: + cleartext = resp.json()["key"] + assert cleartext.startswith("sk-") + assert len(rows) == 1 + else: + assert rows == [], f"{actor.value}: denied but row leaked" diff --git a/tests/proxy_behavior/management/test_key_info.py b/tests/proxy_behavior/management/test_key_info.py new file mode 100644 index 00000000000..ddcef9fd27b --- /dev/null +++ b/tests/proxy_behavior/management/test_key_info.py @@ -0,0 +1,74 @@ +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# (id, actor, target_actor, expected_status). Targets are 3 fixed seeded keys +# representing the canonical relations: own, OWNER (same org_a/team_alpha), +# and CROSS_ORG_USER (org_b/team_beta). +# +# Notable pinned behaviors (intentionally surfaced, not endorsed): +# - ORG_ADMIN 403s on individual key info even within its own org — +# visibility is "your own keys" + "your team's keys", not "your org's keys". +# - Same-team peers (internal_user, unrelated_same_org, service_account) DO +# see each other's keys. +_SCENARIOS = [ + ("own/proxy_admin", Actor.PROXY_ADMIN, Actor.PROXY_ADMIN, 200), + ("own/org_admin", Actor.ORG_ADMIN, Actor.ORG_ADMIN, 200), + ("own/team_admin", Actor.TEAM_ADMIN, Actor.TEAM_ADMIN, 200), + ("own/internal_user", Actor.INTERNAL_USER, Actor.INTERNAL_USER, 200), + ("own/owner", Actor.OWNER, Actor.OWNER, 200), + ("own/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.UNRELATED_SAME_ORG, 200), + ("own/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200), + ("own/service_account", Actor.SERVICE_ACCOUNT, Actor.SERVICE_ACCOUNT, 200), + ("owner_key/proxy_admin", Actor.PROXY_ADMIN, Actor.OWNER, 200), + ("owner_key/org_admin", Actor.ORG_ADMIN, Actor.OWNER, 403), + ("owner_key/team_admin", Actor.TEAM_ADMIN, Actor.OWNER, 200), + ("owner_key/internal_user", Actor.INTERNAL_USER, Actor.OWNER, 200), + ("owner_key/owner", Actor.OWNER, Actor.OWNER, 200), + ("owner_key/unrelated_same_org", Actor.UNRELATED_SAME_ORG, Actor.OWNER, 200), + ("owner_key/cross_org_user", Actor.CROSS_ORG_USER, Actor.OWNER, 403), + ("owner_key/service_account", Actor.SERVICE_ACCOUNT, Actor.OWNER, 200), + ("cross_org/proxy_admin", Actor.PROXY_ADMIN, Actor.CROSS_ORG_USER, 200), + ("cross_org/org_admin", Actor.ORG_ADMIN, Actor.CROSS_ORG_USER, 403), + ("cross_org/team_admin", Actor.TEAM_ADMIN, Actor.CROSS_ORG_USER, 403), + ("cross_org/internal_user", Actor.INTERNAL_USER, Actor.CROSS_ORG_USER, 403), + ("cross_org/owner", Actor.OWNER, Actor.CROSS_ORG_USER, 403), + ( + "cross_org/unrelated_same_org", + Actor.UNRELATED_SAME_ORG, + Actor.CROSS_ORG_USER, + 403, + ), + ("cross_org/cross_org_user", Actor.CROSS_ORG_USER, Actor.CROSS_ORG_USER, 200), + ("cross_org/service_account", Actor.SERVICE_ACCOUNT, Actor.CROSS_ORG_USER, 403), +] + + +@pytest.mark.parametrize( + "actor,target_actor,expected_status", + [(a, t, s) for (_id, a, t, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_key_info_authz_matrix( + actor: Actor, target_actor: Actor, expected_status: int, proxy_client, world +): + caller = world.keys[actor] + target = world.keys[target_actor] + + resp = await proxy_client.get( + f"/key/info?key={target.cleartext}", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} → {target_actor.value}: {resp.status_code} {resp.text}" + + if expected_status == 200: + body = resp.json() + # The handler echoes back whatever ?key was passed (cleartext here), + # so accept either form — info.user_id is the canonical identity check. + assert body.get("key") in (target.cleartext, target.hashed) + assert body["info"].get("user_id") == target.user_id diff --git a/tests/proxy_behavior/management/test_key_list.py b/tests/proxy_behavior/management/test_key_list.py new file mode 100644 index 00000000000..bda8788c9a7 --- /dev/null +++ b/tests/proxy_behavior/management/test_key_list.py @@ -0,0 +1,63 @@ +from typing import FrozenSet + +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# Pinned default visibility for /key/list (no filter params): each actor's +# expected set of seeded actor keys. +_VISIBILITY = { + Actor.PROXY_ADMIN: frozenset(Actor), + Actor.ORG_ADMIN: frozenset({Actor.ORG_ADMIN}), + Actor.TEAM_ADMIN: frozenset({Actor.TEAM_ADMIN}), + Actor.INTERNAL_USER: frozenset({Actor.INTERNAL_USER}), + Actor.OWNER: frozenset({Actor.OWNER}), + Actor.UNRELATED_SAME_ORG: frozenset({Actor.UNRELATED_SAME_ORG}), + Actor.CROSS_ORG_USER: frozenset({Actor.CROSS_ORG_USER}), + Actor.SERVICE_ACCOUNT: frozenset({Actor.SERVICE_ACCOUNT}), +} + + +async def _all_visible_hashes(proxy_client, caller_cleartext) -> set: + """Walk every /key/list page — size is capped at 100 by the endpoint, so a + single request can truncate PROXY_ADMIN's view on a non-fresh DB.""" + hashes: set = set() + page = 1 + while True: + resp = await proxy_client.get( + f"/key/list?page={page}&size=100", + headers={"Authorization": f"Bearer {caller_cleartext}"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + for entry in body.get("keys", []): + tok = entry.get("token") if isinstance(entry, dict) else entry + if tok: + hashes.add(tok) + if page >= (body.get("total_pages") or 1): + return hashes + page += 1 + + +@pytest.mark.parametrize( + "actor,expected_visible", + list(_VISIBILITY.items()), + ids=[a.value for a in _VISIBILITY], +) +async def test_key_list_visibility( + actor: Actor, expected_visible: FrozenSet[Actor], proxy_client, world +): + caller = world.keys[actor] + hashed_to_actor = {world.keys[a].hashed: a for a in Actor} + + returned_hashes = await _all_visible_hashes(proxy_client, caller.cleartext) + visible_seeded = { + hashed_to_actor[h] for h in returned_hashes if h in hashed_to_actor + } + assert visible_seeded == set(expected_visible), ( + f"{actor.value}: expected {sorted(a.value for a in expected_visible)}, " + f"got {sorted(a.value for a in visible_seeded)}" + ) diff --git a/tests/proxy_behavior/management/test_key_regenerate.py b/tests/proxy_behavior/management/test_key_regenerate.py new file mode 100644 index 00000000000..a3289144eef --- /dev/null +++ b/tests/proxy_behavior/management/test_key_regenerate.py @@ -0,0 +1,117 @@ +import pytest + +from .actors import TEAM_ALPHA, TEAM_BETA, Actor +from .conftest import create_scratch_key + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# Most denials route through team_member_permission (401), unlike /key/update +# which goes through user_id-mismatch (403). The matrix surfaces that +# divergence between the two endpoints. +_SCENARIOS = [ + ("self/proxy_admin", Actor.PROXY_ADMIN, "self", 200), + ("self/org_admin", Actor.ORG_ADMIN, "self", 401), + ("self/team_admin", Actor.TEAM_ADMIN, "self", 200), + ("self/internal_user", Actor.INTERNAL_USER, "self", 200), + ("self/owner", Actor.OWNER, "self", 200), + ("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "self", 200), + ("self/cross_org_user", Actor.CROSS_ORG_USER, "self", 200), + ("self/service_account", Actor.SERVICE_ACCOUNT, "self", 200), + ("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200), + ("owner_target/org_admin", Actor.ORG_ADMIN, "owner", 401), + ("owner_target/team_admin", Actor.TEAM_ADMIN, "owner", 200), + ("owner_target/internal_user", Actor.INTERNAL_USER, "owner", 401), + ("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 401), + ("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 401), + ("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 401), + ("cross_org_target/proxy_admin", Actor.PROXY_ADMIN, "cross_org", 200), + ("cross_org_target/org_admin", Actor.ORG_ADMIN, "cross_org", 401), + ("cross_org_target/team_admin", Actor.TEAM_ADMIN, "cross_org", 401), + ("cross_org_target/owner", Actor.OWNER, "cross_org", 401), + ("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 401), + ("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 401), +] + + +async def _info(proxy_client, cleartext: str): + return await proxy_client.get( + "/key/info", headers={"Authorization": f"Bearer {cleartext}"} + ) + + +@pytest.mark.parametrize( + "actor,target_shape,expected_status", + [(a, t, s) for (_id, a, t, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_key_regenerate_authz_matrix( + actor: Actor, + target_shape: str, + expected_status: int, + proxy_client, + scratch, + world, +): + caller = world.keys[actor] + seeder = world.keys[Actor.PROXY_ADMIN].cleartext + + if target_shape == "self": + target_cleartext = await create_scratch_key( + proxy_client, seeder, scratch.prefix, user_id=caller.user_id + ) + elif target_shape == "owner": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.OWNER].user_id, + team_id=TEAM_ALPHA, + ) + elif target_shape == "cross_org": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.CROSS_ORG_USER].user_id, + team_id=TEAM_BETA, + ) + else: + pytest.fail(f"unknown target_shape={target_shape}") + + resp = await proxy_client.post( + "/key/regenerate", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"key": target_cleartext}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}" + + if expected_status == 200: + new_cleartext = resp.json()["key"] + assert new_cleartext.startswith("sk-") and new_cleartext != target_cleartext + assert (await _info(proxy_client, target_cleartext)).status_code == 401 + assert (await _info(proxy_client, new_cleartext)).status_code == 200 + else: + # Denied: rotation must not have leaked — old cleartext still works. + assert (await _info(proxy_client, target_cleartext)).status_code == 200 + + +async def test_key_path_regenerate_smoke(proxy_client, scratch, world): + """Pins that POST /key/{key:path}/regenerate shares the same handler.""" + caller = world.keys[Actor.PROXY_ADMIN] + target_cleartext = await create_scratch_key( + proxy_client, caller.cleartext, scratch.prefix, user_id=caller.user_id + ) + + resp = await proxy_client.post( + f"/key/{target_cleartext}/regenerate", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={}, + ) + assert resp.status_code == 200, resp.text + new_cleartext = resp.json()["key"] + assert new_cleartext.startswith("sk-") and new_cleartext != target_cleartext + assert (await _info(proxy_client, target_cleartext)).status_code == 401 + assert (await _info(proxy_client, new_cleartext)).status_code == 200 diff --git a/tests/proxy_behavior/management/test_key_update.py b/tests/proxy_behavior/management/test_key_update.py new file mode 100644 index 00000000000..36ddefa5750 --- /dev/null +++ b/tests/proxy_behavior/management/test_key_update.py @@ -0,0 +1,100 @@ +import pytest + +from litellm.proxy.utils import hash_token + +from .actors import TEAM_ALPHA, TEAM_BETA, Actor +from .conftest import create_scratch_key + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# (id, actor, target_shape, expected_status). Pinned against current gating: +# proxy_admin bypasses; org_admin is blocked by an early role gate (401); +# every other (INTERNAL_USER-roled) actor hits user_id-mismatch 403, no-team- +# admin 403, or team_member_permission 401 depending on target / membership. +_SCENARIOS = [ + ("self/proxy_admin", Actor.PROXY_ADMIN, "self", 200), + ("self/org_admin", Actor.ORG_ADMIN, "self", 401), + ("self/team_admin", Actor.TEAM_ADMIN, "self", 403), + ("self/internal_user", Actor.INTERNAL_USER, "self", 403), + ("self/owner", Actor.OWNER, "self", 403), + ("self/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "self", 403), + ("self/cross_org_user", Actor.CROSS_ORG_USER, "self", 403), + ("self/service_account", Actor.SERVICE_ACCOUNT, "self", 403), + ("owner_target/proxy_admin", Actor.PROXY_ADMIN, "owner", 200), + ("owner_target/org_admin", Actor.ORG_ADMIN, "owner", 401), + ("owner_target/team_admin", Actor.TEAM_ADMIN, "owner", 403), + ("owner_target/internal_user", Actor.INTERNAL_USER, "owner", 403), + ("owner_target/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "owner", 403), + ("owner_target/cross_org_user", Actor.CROSS_ORG_USER, "owner", 403), + ("owner_target/service_account", Actor.SERVICE_ACCOUNT, "owner", 403), + ("cross_org_target/proxy_admin", Actor.PROXY_ADMIN, "cross_org", 200), + ("cross_org_target/org_admin", Actor.ORG_ADMIN, "cross_org", 401), + ("cross_org_target/team_admin", Actor.TEAM_ADMIN, "cross_org", 403), + ("cross_org_target/owner", Actor.OWNER, "cross_org", 403), + ("cross_org_target/cross_org_user", Actor.CROSS_ORG_USER, "cross_org", 401), + ("cross_org_target/service_account", Actor.SERVICE_ACCOUNT, "cross_org", 403), +] + +MARKER_MODEL = "behavior-pin-update-marker-model" + + +@pytest.mark.parametrize( + "actor,target_shape,expected_status", + [(a, t, s) for (_id, a, t, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_key_update_authz_matrix( + actor: Actor, + target_shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + caller = world.keys[actor] + seeder = world.keys[Actor.PROXY_ADMIN].cleartext + + if target_shape == "self": + target_cleartext = await create_scratch_key( + proxy_client, seeder, scratch.prefix, user_id=caller.user_id + ) + elif target_shape == "owner": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.OWNER].user_id, + team_id=TEAM_ALPHA, + ) + elif target_shape == "cross_org": + target_cleartext = await create_scratch_key( + proxy_client, + seeder, + scratch.prefix, + user_id=world.keys[Actor.CROSS_ORG_USER].user_id, + team_id=TEAM_BETA, + ) + else: + pytest.fail(f"unknown target_shape={target_shape}") + + target_hashed = hash_token(target_cleartext) + + resp = await proxy_client.post( + "/key/update", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"key": target_cleartext, "models": [MARKER_MODEL]}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {target_shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_verificationtoken.find_unique( + where={"token": target_hashed} + ) + assert row is not None + if expected_status == 200: + assert row.models == [MARKER_MODEL] + else: + assert row.models != [MARKER_MODEL], "denied but row mutated" diff --git a/tests/proxy_behavior/management/test_no_management_imports.py b/tests/proxy_behavior/management/test_no_management_imports.py new file mode 100644 index 00000000000..f8c52a1c37e --- /dev/null +++ b/tests/proxy_behavior/management/test_no_management_imports.py @@ -0,0 +1,46 @@ +import pathlib +import re + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +BEHAVIOR_DIR = REPO_ROOT / "tests" / "proxy_behavior" + +FORBIDDEN_IMPORT = re.compile(r"^\s*from\s+litellm\.proxy\.management_endpoints\b") +FORBIDDEN_AUTH_MOCK = re.compile( + r"(?:mock\.[A-Za-z_]+|patch[a-z_]*)\([^)]*user_api_key_auth" +) +# This file is the only place the forbidden patterns appear as regex source; +# exclude it so it can describe what it forbids. +SELF = pathlib.Path(__file__).resolve() + + +def _iter_py_files(): + for path in BEHAVIOR_DIR.rglob("*.py"): + if path.resolve() != SELF: + yield path + + +def _scan(pattern): + violations = [] + for path in _iter_py_files(): + for lineno, line in enumerate(path.read_text().splitlines(), start=1): + if pattern.search(line): + violations.append( + f"{path.relative_to(REPO_ROOT)}:{lineno}: {line.strip()}" + ) + return violations + + +def test_no_management_endpoint_imports(): + violations = _scan(FORBIDDEN_IMPORT) + assert not violations, ( + "tests/proxy_behavior/ must not import from litellm.proxy.management_endpoints. " + "Violations:\n " + "\n ".join(violations) + ) + + +def test_no_user_api_key_auth_mocking(): + violations = _scan(FORBIDDEN_AUTH_MOCK) + assert not violations, ( + "tests/proxy_behavior/ must not mock user_api_key_auth. " + "Violations:\n " + "\n ".join(violations) + ) diff --git a/tests/proxy_behavior/management/test_scratch_teardown.py b/tests/proxy_behavior/management/test_scratch_teardown.py new file mode 100644 index 00000000000..689c60fc78a --- /dev/null +++ b/tests/proxy_behavior/management/test_scratch_teardown.py @@ -0,0 +1,31 @@ +import pytest + +from .conftest import MASTER_KEY, SCRATCH_PREFIX + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# The two tests run in file order: _a writes a scratch-tagged key and asserts +# it lands; _b runs after _a's fixture teardown and asserts no scratch row +# survived. A leak in either direction fails _b on the next collection. + + +async def test_a_scratch_key_lands_in_db(proxy_client, prisma, scratch): + resp = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"key_alias": scratch.prefix}, + ) + assert resp.status_code == 200, resp.text + + rows = await prisma.db.litellm_verificationtoken.find_many( + where={"key_alias": scratch.prefix} + ) + assert len(rows) == 1 + + +async def test_b_scratch_namespace_is_clean(prisma): + rows = await prisma.db.litellm_verificationtoken.find_many( + where={"key_alias": {"startswith": SCRATCH_PREFIX}} + ) + assert rows == [] diff --git a/tests/proxy_behavior/management/test_smoke.py b/tests/proxy_behavior/management/test_smoke.py new file mode 100644 index 00000000000..4e90986ad9f --- /dev/null +++ b/tests/proxy_behavior/management/test_smoke.py @@ -0,0 +1,28 @@ +import pytest + +from .conftest import MASTER_KEY + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def test_liveliness(proxy_client): + resp = await proxy_client.get("/health/liveliness") + assert resp.status_code == 200 + + +async def test_key_generate_lands_in_db(proxy_client, prisma, scratch): + from litellm.proxy.utils import hash_token + + resp = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"key_alias": scratch.prefix}, + ) + assert resp.status_code == 200, resp.text + cleartext = resp.json()["key"] + assert cleartext.startswith("sk-") + + hashed = hash_token(cleartext) + row = await prisma.db.litellm_verificationtoken.find_unique(where={"token": hashed}) + assert row is not None + assert row.token == hashed != cleartext diff --git a/tests/proxy_behavior/management/test_team_info.py b/tests/proxy_behavior/management/test_team_info.py new file mode 100644 index 00000000000..51809942113 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_info.py @@ -0,0 +1,70 @@ +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# GET /team/info — actor x team-target authz matrix, pinned against +# validate_membership(): a team is readable by a proxy admin, a key whose +# own team_id matches, a listed member, or an org admin of the team's org; +# everything else is 403. TEAM_GAMMA has no members, so only PROXY_ADMIN +# and ORG_A's org admin can read it. +_SCENARIOS = [ + ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200), + ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200), + ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200), + ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 200), + ("alpha/owner", Actor.OWNER, "alpha", 200), + ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 200), + ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403), + ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 200), + ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403), + ("gamma/proxy_admin", Actor.PROXY_ADMIN, "gamma", 200), + ("gamma/org_admin", Actor.ORG_ADMIN, "gamma", 200), + ("gamma/team_admin", Actor.TEAM_ADMIN, "gamma", 403), + ("gamma/internal_user", Actor.INTERNAL_USER, "gamma", 403), + ("gamma/owner", Actor.OWNER, "gamma", 403), + ("gamma/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "gamma", 403), + ("gamma/cross_org_user", Actor.CROSS_ORG_USER, "gamma", 403), + ("gamma/service_account", Actor.SERVICE_ACCOUNT, "gamma", 403), + ("gamma/org_b_admin", Actor.ORG_B_ADMIN, "gamma", 403), + ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200), + ("beta/org_admin", Actor.ORG_ADMIN, "beta", 403), + ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403), + ("beta/internal_user", Actor.INTERNAL_USER, "beta", 403), + ("beta/owner", Actor.OWNER, "beta", 403), + ("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403), + ("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 200), + ("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403), + ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200), +] + + +@pytest.mark.parametrize( + "actor,target,expected_status", + [(a, t, s) for (_id, a, t, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_team_info_authz_matrix( + actor: Actor, target: str, expected_status: int, proxy_client, world +): + caller = world.keys[actor] + target_team_id = { + "alpha": world.team_alpha_id, + "gamma": world.team_gamma_id, + "beta": world.team_beta_id, + }[target] + + resp = await proxy_client.get( + f"/team/info?team_id={target_team_id}", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} -> {target}: {resp.status_code} {resp.text}" + + if expected_status == 200: + body = resp.json() + assert body["team_id"] == target_team_id + assert body["team_info"]["team_id"] == target_team_id diff --git a/tests/proxy_behavior/management/test_team_list.py b/tests/proxy_behavior/management/test_team_list.py new file mode 100644 index 00000000000..2bd106dd2d0 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_list.py @@ -0,0 +1,105 @@ +from typing import FrozenSet, Optional + +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# The behavior DB may hold teams beyond the three seeded ones, so every +# assertion intersects the returned team_ids with the known seeded set. +def _seeded_visible(resp_json, world) -> set: + known = { + world.team_alpha_id: "alpha", + world.team_beta_id: "beta", + world.team_gamma_id: "gamma", + } + return { + known[entry["team_id"]] + for entry in resp_json + if isinstance(entry, dict) and entry.get("team_id") in known + } + + +# Family 1 — bare GET /team/list (no query params). _authorize_and_filter_teams +# authorizes only an admin view (proxy admin) or an org admin; everyone else +# is 401. An org admin sees every team in its org(s). +_BARE = [ + ("proxy_admin", Actor.PROXY_ADMIN, 200, {"alpha", "beta", "gamma"}), + ("org_admin", Actor.ORG_ADMIN, 200, {"alpha", "gamma"}), + ("team_admin", Actor.TEAM_ADMIN, 401, None), + ("internal_user", Actor.INTERNAL_USER, 401, None), + ("owner", Actor.OWNER, 401, None), + ("unrelated_same_org", Actor.UNRELATED_SAME_ORG, 401, None), + ("cross_org_user", Actor.CROSS_ORG_USER, 401, None), + ("service_account", Actor.SERVICE_ACCOUNT, 401, None), + ("org_b_admin", Actor.ORG_B_ADMIN, 200, {"beta"}), +] + + +@pytest.mark.parametrize( + "actor,expected_status,expected_visible", + [(a, s, v) for (_id, a, s, v) in _BARE], + ids=[s[0] for s in _BARE], +) +async def test_team_list_bare_authz( + actor: Actor, + expected_status: int, + expected_visible: Optional[set], + proxy_client, + world, +): + caller = world.keys[actor] + resp = await proxy_client.get( + "/team/list", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value}: {resp.status_code} {resp.text}" + + if expected_status == 200: + visible = _seeded_visible(resp.json(), world) + assert visible == expected_visible, ( + f"{actor.value}: expected {sorted(expected_visible)}, " + f"got {sorted(visible)}" + ) + + +# Family 2 — GET /team/list?user_id= ("own query"). Every +# actor may query its own teams (200); the result is exactly the teams it +# belongs to. A user_id filter scopes proxy/org admins to their own +# membership too — the broad admin view from family 1 does not carry over. +_OWN = { + Actor.PROXY_ADMIN: frozenset(), + Actor.ORG_ADMIN: frozenset(), + Actor.TEAM_ADMIN: frozenset({"alpha"}), + Actor.INTERNAL_USER: frozenset({"alpha"}), + Actor.OWNER: frozenset({"alpha"}), + Actor.UNRELATED_SAME_ORG: frozenset({"alpha"}), + Actor.CROSS_ORG_USER: frozenset({"beta"}), + Actor.SERVICE_ACCOUNT: frozenset({"alpha"}), + Actor.ORG_B_ADMIN: frozenset(), +} + + +@pytest.mark.parametrize( + "actor,expected_visible", + list(_OWN.items()), + ids=[a.value for a in _OWN], +) +async def test_team_list_own_query( + actor: Actor, expected_visible: FrozenSet[str], proxy_client, world +): + caller = world.keys[actor] + resp = await proxy_client.get( + f"/team/list?user_id={caller.user_id}", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + ) + assert resp.status_code == 200, f"{actor.value}: {resp.status_code} {resp.text}" + + visible = _seeded_visible(resp.json(), world) + assert visible == set(expected_visible), ( + f"{actor.value}: expected {sorted(expected_visible)}, " f"got {sorted(visible)}" + ) diff --git a/tests/proxy_behavior/management/test_team_member_add.py b/tests/proxy_behavior/management/test_team_member_add.py new file mode 100644 index 00000000000..a0dc4a7ecaf --- /dev/null +++ b/tests/proxy_behavior/management/test_team_member_add.py @@ -0,0 +1,149 @@ +import litellm +import pytest + +from .actors import Actor +from .conftest import create_scratch_team + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# POST /team/member_add — actor x team-shape matrix, pinned against +# _validate_team_member_add_permissions: PROXY_ADMIN, the team's team admin, +# or an org admin of the team's org may add members; everyone else is 403. +# Unlike /team/update there is no route gate in front, so the team-admin +# branch is reachable (TEAM_ADMIN, an internal_user, is allowed on its team). +_MATRIX = [ + ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200), + ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200), + ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200), + ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403), + ("alpha/owner", Actor.OWNER, "alpha", 403), + ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403), + ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403), + ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403), + ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403), + ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200), + ("beta/org_admin", Actor.ORG_ADMIN, "beta", 403), + ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403), + ("beta/internal_user", Actor.INTERNAL_USER, "beta", 403), + ("beta/owner", Actor.OWNER, "beta", 403), + ("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403), + ("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403), + ("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403), + ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200), +] + + +async def _seed_target(prisma, world, shape: str, team_id: str) -> None: + if shape == "alpha": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_a_id, + admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id], + ) + elif shape == "beta": + await create_scratch_team(prisma, team_id, organization_id=world.org_b_id) + else: # pragma: no cover - guard + pytest.fail(f"unknown shape={shape}") + + +def _member_ids(row) -> list: + return [m["user_id"] for m in (row.members_with_roles or [])] + + +@pytest.mark.parametrize( + "actor,shape,expected_status", + [(a, sh, s) for (_id, a, sh, s) in _MATRIX], + ids=[s[0] for s in _MATRIX], +) +async def test_team_member_add_authz_matrix( + actor: Actor, + shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + await _seed_target(prisma, world, shape, scratch.prefix) + caller = world.keys[actor] + new_member_id = scratch.tag("newmember") + + resp = await proxy_client.post( + "/team/member_add", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={ + "team_id": scratch.prefix, + "member": {"user_id": new_member_id, "role": "user"}, + }, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert new_member_id in _member_ids(row) + else: + assert new_member_id not in _member_ids(row), "denied but member added" + + +# Available-team self-join: a non-admin caller may add ITSELF to a team listed +# in litellm.default_internal_user_params["available_teams"], but the bypass +# must not escalate to role=admin or inject another user. +_SELF_JOIN = [ + ("self_as_user", "self", "user", 200), + ("self_as_admin", "self", "admin", 403), + ("other_as_user", "other", "user", 403), +] + + +@pytest.mark.parametrize( + "who,role,expected_status", + [(w, r, s) for (_id, w, r, s) in _SELF_JOIN], + ids=[s[0] for s in _SELF_JOIN], +) +async def test_team_member_add_available_team_self_join( + who: str, + role: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, + monkeypatch, +): + # Org-less team with no admins: the INTERNAL_USER caller is neither team + # nor org admin, so it lands on the available-team branch. + await create_scratch_team(prisma, scratch.prefix) + monkeypatch.setattr( + litellm, "default_internal_user_params", {"available_teams": [scratch.prefix]} + ) + + caller = world.keys[Actor.INTERNAL_USER] + member_id = caller.user_id if who == "self" else world.keys[Actor.OWNER].user_id + + resp = await proxy_client.post( + "/team/member_add", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={ + "team_id": scratch.prefix, + "member": {"user_id": member_id, "role": role}, + }, + ) + assert ( + resp.status_code == expected_status + ), f"{who}/{role}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert member_id in _member_ids(row) + else: + assert member_id not in _member_ids(row), "denied but member added" diff --git a/tests/proxy_behavior/management/test_team_member_delete.py b/tests/proxy_behavior/management/test_team_member_delete.py new file mode 100644 index 00000000000..43879d9fd16 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_member_delete.py @@ -0,0 +1,92 @@ +import pytest + +from .actors import Actor +from .conftest import create_scratch_team + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# POST /team/member_delete — actor x team-shape matrix. The scratch team is +# raw-seeded with a victim member already in it; PROXY_ADMIN, the team's team +# admin, or an org admin of the team's org may remove members; else 403. +_MATRIX = [ + ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200), + ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200), + ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200), + ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403), + ("alpha/owner", Actor.OWNER, "alpha", 403), + ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403), + ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403), + ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403), + ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403), + ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200), + ("beta/org_admin", Actor.ORG_ADMIN, "beta", 403), + ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403), + ("beta/internal_user", Actor.INTERNAL_USER, "beta", 403), + ("beta/owner", Actor.OWNER, "beta", 403), + ("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403), + ("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403), + ("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403), + ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200), +] + + +async def _seed_target(prisma, world, shape: str, team_id: str, victim_id: str) -> None: + if shape == "alpha": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_a_id, + admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id], + member_user_ids=[victim_id], + ) + elif shape == "beta": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_b_id, + member_user_ids=[victim_id], + ) + else: # pragma: no cover - guard + pytest.fail(f"unknown shape={shape}") + + +def _member_ids(row) -> list: + return [m["user_id"] for m in (row.members_with_roles or [])] + + +@pytest.mark.parametrize( + "actor,shape,expected_status", + [(a, sh, s) for (_id, a, sh, s) in _MATRIX], + ids=[s[0] for s in _MATRIX], +) +async def test_team_member_delete_authz_matrix( + actor: Actor, + shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + victim_id = scratch.tag("victim") + await _seed_target(prisma, world, shape, scratch.prefix, victim_id) + caller = world.keys[actor] + + resp = await proxy_client.post( + "/team/member_delete", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"team_id": scratch.prefix, "user_id": victim_id}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert victim_id not in _member_ids(row) + else: + assert victim_id in _member_ids(row), "denied but member removed" diff --git a/tests/proxy_behavior/management/test_team_member_update.py b/tests/proxy_behavior/management/test_team_member_update.py new file mode 100644 index 00000000000..53b245bd1e9 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_member_update.py @@ -0,0 +1,97 @@ +import pytest + +from .actors import Actor +from .conftest import create_scratch_team + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# POST /team/member_update — actor x team-shape matrix. The scratch team is +# raw-seeded with a "user"-role member; each scenario tries to promote it to +# "admin". PROXY_ADMIN, the team's team admin, or an org admin of the team's +# org may update members; else 403. (The harness forces premium_user, so the +# promotion does not hit the admin-role premium gate.) +_MATRIX = [ + ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200), + ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200), + ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200), + ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403), + ("alpha/owner", Actor.OWNER, "alpha", 403), + ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403), + ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403), + ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403), + ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403), + ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200), + ("beta/org_admin", Actor.ORG_ADMIN, "beta", 403), + ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403), + ("beta/internal_user", Actor.INTERNAL_USER, "beta", 403), + ("beta/owner", Actor.OWNER, "beta", 403), + ("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403), + ("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403), + ("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403), + ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200), +] + + +async def _seed_target(prisma, world, shape: str, team_id: str, member_id: str) -> None: + if shape == "alpha": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_a_id, + admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id], + member_user_ids=[member_id], + ) + elif shape == "beta": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_b_id, + member_user_ids=[member_id], + ) + else: # pragma: no cover - guard + pytest.fail(f"unknown shape={shape}") + + +def _role_of(row, user_id: str): + for m in row.members_with_roles or []: + if m["user_id"] == user_id: + return m["role"] + return None + + +@pytest.mark.parametrize( + "actor,shape,expected_status", + [(a, sh, s) for (_id, a, sh, s) in _MATRIX], + ids=[s[0] for s in _MATRIX], +) +async def test_team_member_update_authz_matrix( + actor: Actor, + shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + member_id = scratch.tag("member") + await _seed_target(prisma, world, shape, scratch.prefix, member_id) + caller = world.keys[actor] + + resp = await proxy_client.post( + "/team/member_update", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"team_id": scratch.prefix, "user_id": member_id, "role": "admin"}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert _role_of(row, member_id) == "admin" + else: + assert _role_of(row, member_id) == "user", "denied but role changed" diff --git a/tests/proxy_behavior/management/test_team_new.py b/tests/proxy_behavior/management/test_team_new.py new file mode 100644 index 00000000000..7b07f259641 --- /dev/null +++ b/tests/proxy_behavior/management/test_team_new.py @@ -0,0 +1,139 @@ +from typing import Any, Dict + +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# POST /team/new — actor x org-target matrix (org_target picks the request's +# organization_id: none / ORG_A / ORG_B). Pinned against the role gate, which +# 401s every denial: PROXY_ADMIN always passes; any other caller must name an +# organization_id AND be ORG_ADMIN of that org. +_SCENARIOS = [ + ("none/proxy_admin", Actor.PROXY_ADMIN, "none", 200), + ("none/org_admin", Actor.ORG_ADMIN, "none", 401), + ("none/team_admin", Actor.TEAM_ADMIN, "none", 401), + ("none/internal_user", Actor.INTERNAL_USER, "none", 401), + ("none/owner", Actor.OWNER, "none", 401), + ("none/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "none", 401), + ("none/cross_org_user", Actor.CROSS_ORG_USER, "none", 401), + ("none/service_account", Actor.SERVICE_ACCOUNT, "none", 401), + ("none/org_b_admin", Actor.ORG_B_ADMIN, "none", 401), + ("org_a/proxy_admin", Actor.PROXY_ADMIN, "org_a", 200), + ("org_a/org_admin", Actor.ORG_ADMIN, "org_a", 200), + ("org_a/team_admin", Actor.TEAM_ADMIN, "org_a", 401), + ("org_a/internal_user", Actor.INTERNAL_USER, "org_a", 401), + ("org_a/owner", Actor.OWNER, "org_a", 401), + ("org_a/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_a", 401), + ("org_a/cross_org_user", Actor.CROSS_ORG_USER, "org_a", 401), + ("org_a/service_account", Actor.SERVICE_ACCOUNT, "org_a", 401), + ("org_a/org_b_admin", Actor.ORG_B_ADMIN, "org_a", 401), + ("org_b/proxy_admin", Actor.PROXY_ADMIN, "org_b", 200), + ("org_b/org_admin", Actor.ORG_ADMIN, "org_b", 401), + ("org_b/team_admin", Actor.TEAM_ADMIN, "org_b", 401), + ("org_b/internal_user", Actor.INTERNAL_USER, "org_b", 401), + ("org_b/owner", Actor.OWNER, "org_b", 401), + ("org_b/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "org_b", 401), + ("org_b/cross_org_user", Actor.CROSS_ORG_USER, "org_b", 401), + ("org_b/service_account", Actor.SERVICE_ACCOUNT, "org_b", 401), + ("org_b/org_b_admin", Actor.ORG_B_ADMIN, "org_b", 200), +] + + +@pytest.mark.parametrize( + "actor,org_target,expected_status", + [(a, o, s) for (_id, a, o, s) in _SCENARIOS], + ids=[s[0] for s in _SCENARIOS], +) +async def test_team_new_authz_matrix( + actor: Actor, + org_target: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + caller = world.keys[actor] + org_id = { + "none": None, + "org_a": world.org_a_id, + "org_b": world.org_b_id, + }[org_target] + + body: Dict[str, Any] = {"team_id": scratch.prefix, "team_alias": scratch.prefix} + if org_id is not None: + body["organization_id"] = org_id + + resp = await proxy_client.post( + "/team/new", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json=body, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} org={org_target}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + if expected_status == 200: + assert row is not None + assert row.organization_id == org_id + else: + assert row is None, f"{actor.value}: denied but team row leaked" + + +async def test_team_new_rejects_negative_budget(proxy_client, prisma, scratch, world): + """Input-validation pin: max_budget < 0 is a 400, no row created.""" + resp = await proxy_client.post( + "/team/new", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "max_budget": -1}, + ) + assert resp.status_code == 400, resp.text + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is None + + +async def test_team_new_rejects_duplicate_team_id(proxy_client, prisma, scratch, world): + """Input-validation pin: a colliding team_id is a 400 on the second call.""" + seeder = world.keys[Actor.PROXY_ADMIN].cleartext + first = await proxy_client.post( + "/team/new", + headers={"Authorization": f"Bearer {seeder}"}, + json={"team_id": scratch.prefix, "team_alias": scratch.prefix}, + ) + assert first.status_code == 200, first.text + + second = await proxy_client.post( + "/team/new", + headers={"Authorization": f"Bearer {seeder}"}, + json={"team_id": scratch.prefix, "team_alias": scratch.prefix}, + ) + assert second.status_code == 400, second.text + + +async def test_team_new_unknown_organization_is_500( + proxy_client, prisma, scratch, world +): + """SURFACED, NOT ENDORSED: a /team/new with an organization_id that does + not exist currently fails 500 (the role-resolution layer raises before + the handler's own 400 'Organization not found' check is reached).""" + resp = await proxy_client.post( + "/team/new", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={ + "team_id": scratch.prefix, + "organization_id": scratch.tag("no-such-org"), + }, + ) + assert resp.status_code == 500, resp.text + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is None diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py new file mode 100644 index 00000000000..3baf2b2148f --- /dev/null +++ b/tests/proxy_behavior/management/test_team_update.py @@ -0,0 +1,176 @@ +import pytest + +from .actors import Actor +from .conftest import create_scratch_team + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +# POST /team/update — actor x team-shape matrix (shapes built by _seed_target). +# Each request carries the team's own organization_id so a non-proxy-admin can +# reach the org-scoped branch of the route-permission gate (401 on denial), +# which fronts the handler's _verify_team_access. Only PROXY_ADMIN and an +# ORG_ADMIN of the team's org pass: an internal_user team admin is filtered by +# the route gate before _verify_team_access's team-admin branch is reached. +MARKER_ALIAS = "behavior-pin-update-marker-alias" + +_MATRIX = [ + ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200), + ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200), + ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 401), + ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 401), + ("alpha/owner", Actor.OWNER, "alpha", 401), + ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 401), + ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 401), + ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 401), + ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 401), + ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200), + ("beta/org_admin", Actor.ORG_ADMIN, "beta", 401), + ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 401), + ("beta/internal_user", Actor.INTERNAL_USER, "beta", 401), + ("beta/owner", Actor.OWNER, "beta", 401), + ("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 401), + ("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 401), + ("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 401), + ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200), +] + + +async def _seed_target(prisma, world, shape: str, team_id: str) -> str: + """Raw-seed the scratch target team; returns its organization_id.""" + if shape == "alpha": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_a_id, + admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id], + member_user_ids=[ + world.keys[Actor.INTERNAL_USER].user_id, + world.keys[Actor.OWNER].user_id, + world.keys[Actor.UNRELATED_SAME_ORG].user_id, + world.keys[Actor.SERVICE_ACCOUNT].user_id, + ], + ) + return world.org_a_id + if shape == "beta": + await create_scratch_team( + prisma, + team_id, + organization_id=world.org_b_id, + member_user_ids=[world.keys[Actor.CROSS_ORG_USER].user_id], + ) + return world.org_b_id + pytest.fail(f"unknown shape={shape}") # pragma: no cover + + +@pytest.mark.parametrize( + "actor,shape,expected_status", + [(a, sh, s) for (_id, a, sh, s) in _MATRIX], + ids=[s[0] for s in _MATRIX], +) +async def test_team_update_authz_matrix( + actor: Actor, + shape: str, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + org_id = await _seed_target(prisma, world, shape, scratch.prefix) + caller = world.keys[actor] + + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={ + "team_id": scratch.prefix, + "team_alias": MARKER_ALIAS, + "organization_id": org_id, + }, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value} {shape}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert row.team_alias == MARKER_ALIAS + else: + assert row.team_alias != MARKER_ALIAS, "denied but team mutated" + + +async def test_team_update_requires_proxy_admin_without_org_context( + proxy_client, prisma, scratch, world +): + """With no organization_id in the body the route gate has no org context + and falls back to proxy-admin-only: an org admin of the team's own org + is 401, PROXY_ADMIN is 200.""" + await _seed_target(prisma, world, "alpha", scratch.prefix) + + denied = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, + ) + assert denied.status_code == 401, denied.text + + allowed = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, + ) + assert allowed.status_code == 200, allowed.text + + +# Relocation gate — moving a team to a different org. The scratch team starts +# in ORG_A; each scenario relocates it to ORG_B. PROXY_ADMIN bypasses; +# ORG_B_ADMIN clears the route gate (dest-org admin) but fails +# _verify_team_access on the source team (403); the rest fail the route gate +# (401). The relocation-allowed branch needs a caller who is org admin of both +# orgs — no seeded actor is, so it is left to a later slice. +_RELOCATION = [ + ("proxy_admin", Actor.PROXY_ADMIN, 200), + ("org_b_admin", Actor.ORG_B_ADMIN, 403), + ("org_admin", Actor.ORG_ADMIN, 401), + ("team_admin", Actor.TEAM_ADMIN, 401), + ("internal_user", Actor.INTERNAL_USER, 401), +] + + +@pytest.mark.parametrize( + "actor,expected_status", + [(a, s) for (_id, a, s) in _RELOCATION], + ids=[s[0] for s in _RELOCATION], +) +async def test_team_update_org_relocation_gate( + actor: Actor, + expected_status: int, + proxy_client, + prisma, + scratch, + world, +): + await _seed_target(prisma, world, "alpha", scratch.prefix) + caller = world.keys[actor] + + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller.cleartext}"}, + json={"team_id": scratch.prefix, "organization_id": world.org_b_id}, + ) + assert ( + resp.status_code == expected_status + ), f"{actor.value}: {resp.status_code} {resp.text}" + + row = await prisma.db.litellm_teamtable.find_unique( + where={"team_id": scratch.prefix} + ) + assert row is not None + if expected_status == 200: + assert row.organization_id == world.org_b_id + else: + assert row.organization_id == world.org_a_id, "denied but team relocated" diff --git a/tests/proxy_behavior/management/test_world_seed.py b/tests/proxy_behavior/management/test_world_seed.py new file mode 100644 index 00000000000..00f9540c9c3 --- /dev/null +++ b/tests/proxy_behavior/management/test_world_seed.py @@ -0,0 +1,30 @@ +import pytest + +from .actors import Actor + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +@pytest.mark.parametrize("actor", list(Actor), ids=[a.value for a in Actor]) +async def test_each_actor_can_self_info(actor, proxy_client, world): + seeded = world.keys[actor] + resp = await proxy_client.get( + "/key/info", + headers={"Authorization": f"Bearer {seeded.cleartext}"}, + ) + assert resp.status_code == 200, f"{actor.value}: {resp.text}" + body = resp.json() + assert body.get("key") == seeded.hashed + assert body["info"].get("user_id") == seeded.user_id + + +async def test_proxy_admin_actor_can_create_keys_for_others(proxy_client, world): + seeder = world.keys[Actor.PROXY_ADMIN] + target_user_id = world.keys[Actor.OWNER].user_id + + resp = await proxy_client.post( + "/key/generate", + headers={"Authorization": f"Bearer {seeder.cleartext}"}, + json={"key_alias": "smoke-proxy-admin-bypass", "user_id": target_user_id}, + ) + assert resp.status_code == 200, resp.text diff --git a/tests/proxy_unit_tests/test_gemini_agents_endpoints.py b/tests/proxy_unit_tests/test_gemini_agents_endpoints.py new file mode 100644 index 00000000000..bdac9348f71 --- /dev/null +++ b/tests/proxy_unit_tests/test_gemini_agents_endpoints.py @@ -0,0 +1,519 @@ +""" +Unit tests for litellm/proxy/google_endpoints/agents_endpoints.py + +Focus: verify that list_gemini_agents, get_gemini_agent, delete_gemini_agent, +and list_gemini_agent_versions correctly forward per-request credentials +(api_key, api_base, …) supplied via the JSON-encoded litellm_params_template +query parameter. Flat credential query params (e.g. ?api_key=…) are no +longer accepted — they would appear in server logs. +""" + +import json +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request +from fastapi.datastructures import Headers, QueryParams + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.proxy.google_endpoints.agents_endpoints import ( + _merge_query_params_into_data, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_request(query_string: str = "") -> MagicMock: + """Build a minimal mock Request whose query_params match *query_string*.""" + req = MagicMock(spec=Request) + req.query_params = QueryParams(query_string) + req.headers = Headers({}) + return req + + +# --------------------------------------------------------------------------- +# _merge_query_params_into_data – unit tests for the helper +# --------------------------------------------------------------------------- + + +class TestMergeQueryParamsIntoData: + def test_no_query_params_leaves_data_unchanged(self): + data = {"custom_llm_provider": "gemini"} + request = _make_request("") + result = _merge_query_params_into_data(data, request) + assert result == {"custom_llm_provider": "gemini"} + + def test_flat_api_key_is_ignored(self): + """Flat credential params must NOT be merged (they leak into server logs).""" + data = {"custom_llm_provider": "gemini"} + request = _make_request("api_key=AIzaSyTest123") + _merge_query_params_into_data(data, request) + assert "api_key" not in data + assert data["custom_llm_provider"] == "gemini" + + def test_flat_params_are_silently_dropped(self): + """Flat params (including name injection attempts) are ignored entirely.""" + data = {"name": "my-agent", "custom_llm_provider": "gemini"} + request = _make_request("name=INJECTED&api_key=AIzaSyTest") + _merge_query_params_into_data(data, request) + assert data["name"] == "my-agent" + assert "api_key" not in data + + def test_litellm_params_template_json_is_expanded(self): + template = json.dumps( + {"api_key": "AIzaFromTemplate", "api_base": "https://example.com"} + ) + from urllib.parse import quote + + request = _make_request(f"litellm_params_template={quote(template)}") + data = {"custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + assert data["api_key"] == "AIzaFromTemplate" + assert data["api_base"] == "https://example.com" + # The raw template key itself must NOT appear in data + assert "litellm_params_template" not in data + + def test_litellm_params_template_does_not_overwrite_existing(self): + template = json.dumps( + {"api_key": "FromTemplate", "custom_llm_provider": "openai"} + ) + from urllib.parse import quote + + request = _make_request(f"litellm_params_template={quote(template)}") + data = {"custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + # custom_llm_provider was already set; template must not override it + assert data["custom_llm_provider"] == "gemini" + assert data["api_key"] == "FromTemplate" + + def test_invalid_litellm_params_template_json_is_ignored(self): + request = _make_request("litellm_params_template=NOT_VALID_JSON") + data = {"custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + # Bad JSON is silently skipped; other data stays intact + assert data == {"custom_llm_provider": "gemini"} + + def test_template_only_no_flat_params_merged(self): + """Only litellm_params_template is expanded; unknown flat params are dropped.""" + template = json.dumps({"api_key": "FromTemplate"}) + from urllib.parse import quote + + qs = f"litellm_params_template={quote(template)}&vertex_project=my-project" + request = _make_request(qs) + data = {"custom_llm_provider": "gemini"} + _merge_query_params_into_data(data, request) + assert data["api_key"] == "FromTemplate" + # flat vertex_project is ignored since it wasn't in litellm_params_template + assert "vertex_project" not in data + assert "litellm_params_template" not in data + + +# --------------------------------------------------------------------------- +# Endpoint-level smoke tests: data dict is populated before the processor call +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_srv(): + """Patch _proxy_server_imports to return lightweight fakes.""" + srv = { + "general_settings": {}, + "llm_router": MagicMock(), + "proxy_config": MagicMock(), + "proxy_logging_obj": MagicMock(), + "select_data_generator": MagicMock(), + "user_api_base": None, + "user_max_tokens": None, + "user_model": None, + "user_request_timeout": None, + "user_temperature": None, + "version": "0.0.0", + } + with patch( + "litellm.proxy.google_endpoints.agents_endpoints._proxy_server_imports", + return_value=srv, + ): + yield srv + + +@pytest.fixture +def user_api_key_dict(): + from litellm.proxy._types import UserAPIKeyAuth + + return UserAPIKeyAuth(api_key="test-key") + + +def _make_endpoint_request(query_string: str = "") -> MagicMock: + req = MagicMock(spec=Request) + req.query_params = QueryParams(query_string) + req.headers = Headers({}) + req.scope = {} + + async def _body(): + return b"" + + req.body = _body + return req + + +@pytest.mark.asyncio +async def test_list_gemini_agents_passes_api_key_to_processor( + mock_srv, user_api_key_dict +): + from urllib.parse import quote + + from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents + + template = json.dumps({"api_key": "AIzaListTest"}) + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request(f"litellm_params_template={quote(template)}") + await list_gemini_agents( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data.get("api_key") == "AIzaListTest" + assert init_data.get("custom_llm_provider") == "gemini" + + +@pytest.mark.asyncio +async def test_get_gemini_agent_passes_api_key_to_processor( + mock_srv, user_api_key_dict +): + from urllib.parse import quote + + from litellm.proxy.google_endpoints.agents_endpoints import get_gemini_agent + + template = json.dumps({"api_key": "AIzaGetTest"}) + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request(f"litellm_params_template={quote(template)}") + await get_gemini_agent( + request=request, + name="my-agent", + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data.get("api_key") == "AIzaGetTest" + assert init_data.get("name") == "my-agent" + assert init_data.get("custom_llm_provider") == "gemini" + + +@pytest.mark.asyncio +async def test_delete_gemini_agent_passes_api_key_to_processor( + mock_srv, user_api_key_dict +): + from urllib.parse import quote + + from litellm.proxy.google_endpoints.agents_endpoints import delete_gemini_agent + + template = json.dumps({"api_key": "AIzaDeleteTest"}) + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request(f"litellm_params_template={quote(template)}") + await delete_gemini_agent( + request=request, + name="my-agent", + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data.get("api_key") == "AIzaDeleteTest" + assert init_data.get("name") == "my-agent" + assert init_data.get("custom_llm_provider") == "gemini" + + +@pytest.mark.asyncio +async def test_list_gemini_agent_versions_passes_api_key_to_processor( + mock_srv, user_api_key_dict +): + from urllib.parse import quote + + from litellm.proxy.google_endpoints.agents_endpoints import ( + list_gemini_agent_versions, + ) + + template = json.dumps({"api_key": "AIzaVersionsTest"}) + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request(f"litellm_params_template={quote(template)}") + await list_gemini_agent_versions( + request=request, + name="my-agent", + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data.get("api_key") == "AIzaVersionsTest" + assert init_data.get("name") == "my-agent" + assert init_data.get("custom_llm_provider") == "gemini" + + +@pytest.mark.asyncio +async def test_get_gemini_agent_name_not_overwritten_by_query_param( + mock_srv, user_api_key_dict +): + """Path-param ``name`` must not be replaced by an attacker-controlled query param.""" + from urllib.parse import quote + + from litellm.proxy.google_endpoints.agents_endpoints import get_gemini_agent + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + # Even if a caller tries to inject "name" via flat query param, it is + # ignored (flat params are not merged). The path-param name wins. + # ``api_key`` is supplied via the JSON template (required for non-admin + # callers — see test_*_non_admin_without_api_key_is_rejected below). + template = json.dumps({"api_key": "AIzaTest"}) + request = _make_endpoint_request( + f"name=INJECTED&litellm_params_template={quote(template)}" + ) + await get_gemini_agent( + request=request, + name="real-agent", + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data["name"] == "real-agent" + + +@pytest.mark.asyncio +async def test_list_agents_template_via_query_param(mock_srv, user_api_key_dict): + """litellm_params_template in query string is expanded.""" + from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents + from urllib.parse import quote + + template = json.dumps({"api_key": "TemplateKey", "vertex_project": "proj-x"}) + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request(f"litellm_params_template={quote(template)}") + await list_gemini_agents( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert init_data["api_key"] == "TemplateKey" + assert init_data["vertex_project"] == "proj-x" + assert "litellm_params_template" not in init_data + + +# --------------------------------------------------------------------------- +# Security guards (veria-flagged findings) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def proxy_admin_user_api_key_dict(): + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + return UserAPIKeyAuth( + api_key="sk-admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + +@pytest.mark.asyncio +async def test_list_agents_non_admin_without_api_key_is_rejected( + mock_srv, user_api_key_dict +): + """Non-admin callers must supply an explicit api_key — the proxy must not + silently fall back to the operator's shared GOOGLE_API_KEY/GEMINI_API_KEY. + """ + from fastapi import HTTPException + + from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request("") + with pytest.raises(HTTPException) as excinfo: + await list_gemini_agents( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + assert excinfo.value.status_code == 401 + # Processor must never be invoked + instance.base_process_llm_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_agent_non_admin_without_api_key_is_rejected( + mock_srv, user_api_key_dict +): + from fastapi import HTTPException + + from litellm.proxy.google_endpoints.agents_endpoints import delete_gemini_agent + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request("") + with pytest.raises(HTTPException) as excinfo: + await delete_gemini_agent( + request=request, + name="my-agent", + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + assert excinfo.value.status_code == 401 + instance.base_process_llm_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_agent_non_admin_without_api_key_is_rejected( + mock_srv, user_api_key_dict +): + from fastapi import HTTPException + + from litellm.proxy.google_endpoints.agents_endpoints import create_gemini_agent + + with ( + patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor, + patch( + "litellm.proxy.google_endpoints.agents_endpoints._read_request_body", + new=AsyncMock(return_value={"name": "agent-1", "base_agent": "waverunner"}), + ), + ): + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request("") + with pytest.raises(HTTPException) as excinfo: + await create_gemini_agent( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=user_api_key_dict, + ) + assert excinfo.value.status_code == 401 + instance.base_process_llm_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_list_agents_proxy_admin_may_use_env_fallback( + mock_srv, proxy_admin_user_api_key_dict +): + """Proxy admins (master key) keep the env-fallback convenience.""" + from litellm.proxy.google_endpoints.agents_endpoints import list_gemini_agents + + with patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing" + ) as MockProcessor: + instance = MockProcessor.return_value + instance.base_process_llm_request = AsyncMock(return_value=MagicMock()) + + request = _make_endpoint_request("") + await list_gemini_agents( + request=request, + fastapi_response=MagicMock(), + user_api_key_dict=proxy_admin_user_api_key_dict, + ) + + init_data = MockProcessor.call_args[1]["data"] + assert "api_key" not in init_data + instance.base_process_llm_request.assert_awaited_once() + + +def test_validate_environment_rejects_api_base_override_without_explicit_key( + monkeypatch, +): + """SECURITY: caller-supplied api_base must be paired with an explicit + api_key — otherwise the proxy's shared GOOGLE_API_KEY leaks to the + attacker-controlled host via the x-goog-api-key header. + """ + from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig + + # Even if env-fallback is available, api_base override must require api_key. + monkeypatch.setenv("GOOGLE_API_KEY", "AIzaSharedSecret") + + cfg = GeminiAgentsConfig() + with pytest.raises(ValueError, match="api_base"): + cfg.validate_environment( + headers={}, + litellm_params={"api_base": "https://attacker.example"}, + ) + + +def test_validate_environment_allows_api_base_with_explicit_key(monkeypatch): + """api_base override is OK when paired with an explicit api_key.""" + from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig + + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + cfg = GeminiAgentsConfig() + headers = cfg.validate_environment( + headers={}, + litellm_params={ + "api_base": "https://my-gemini-proxy.example", + "api_key": "AIzaCallerOwned", + }, + ) + assert headers["x-goog-api-key"] == "AIzaCallerOwned" + + +def test_validate_environment_env_fallback_when_no_api_base_override(monkeypatch): + """Without api_base override, env fallback continues to work for SDK use.""" + from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig + + monkeypatch.setenv("GOOGLE_API_KEY", "AIzaFromEnv") + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + cfg = GeminiAgentsConfig() + headers = cfg.validate_environment(headers={}, litellm_params={}) + assert headers["x-goog-api-key"] == "AIzaFromEnv" diff --git a/tests/proxy_unit_tests/test_reducto_ocr_route.py b/tests/proxy_unit_tests/test_reducto_ocr_route.py new file mode 100644 index 00000000000..dc658a74ee8 --- /dev/null +++ b/tests/proxy_unit_tests/test_reducto_ocr_route.py @@ -0,0 +1,137 @@ +import asyncio +import os +from unittest.mock import AsyncMock, patch + +import litellm +import pytest +from fastapi.testclient import TestClient + +from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo +from litellm.proxy.proxy_server import app, initialize + + +@pytest.fixture(scope="function") +def fake_env_vars(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "fake_openai_api_key") + monkeypatch.setenv("OPENAI_API_BASE", "http://fake-openai-api-base") + monkeypatch.setenv("AZURE_AI_API_BASE", "http://fake-azure-api-base") + monkeypatch.setenv("AZURE_AI_API_KEY", "fake_azure_api_key") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "fake_azure_openai_api_key") + monkeypatch.setenv("AZURE_SWEDEN_API_BASE", "http://fake-azure-sweden-api-base") + monkeypatch.setenv("AZURE_SWEDEN_API_KEY", "fake_azure_sweden_api_key") + monkeypatch.setenv("REDIS_HOST", "localhost") + + +@pytest.fixture(scope="function") +def client_no_auth(fake_env_vars): + from litellm.proxy.proxy_server import cleanup_router_config_variables + + original_disable_aiohttp = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = True + litellm.in_memory_llm_clients_cache.flush_cache() + cleanup_router_config_variables() + + filepath = os.path.dirname(os.path.abspath(__file__)) + config_fp = os.path.join(filepath, "test_configs", "test_config_no_auth.yaml") + asyncio.run(initialize(config=config_fp, debug=True)) + + # Passthrough of api_base in the JSON body is rejected by default + # (pre_db_read_auth_checks / is_request_body_safe). This test asserts + # api_base reaches aocr(). + from litellm.proxy import proxy_server as _ps + + if _ps.general_settings is None: + _ps.general_settings = {} + _ps.general_settings["allow_client_side_credentials"] = True + + try: + yield TestClient(app) + finally: + litellm.disable_aiohttp_transport = original_disable_aiohttp + litellm.in_memory_llm_clients_cache.flush_cache() + + +def test_proxy_reducto_ocr_json_rejects_reducto_id(client_no_auth): + with patch( + "litellm.proxy.proxy_server.llm_router.aocr", + new=AsyncMock(), + ) as mock_aocr: + response = client_no_auth.post( + "/v1/ocr", + json={ + "model": "reducto/parse-v3", + "document": { + "type": "document_url", + "document_url": "reducto://proxy.pdf", + }, + "api_key": "proxy-key", + "api_base": "https://platform.reducto.ai", + }, + ) + + assert response.status_code >= 400 + assert "reducto://" in response.text + assert mock_aocr.await_count == 0 + + +def test_proxy_reducto_ocr_json_rejects_reducto_id_in_image_url(client_no_auth): + with patch( + "litellm.proxy.proxy_server.llm_router.aocr", + new=AsyncMock(), + ) as mock_aocr: + response = client_no_auth.post( + "/v1/ocr", + json={ + "model": "reducto/parse-v3", + "document": { + "type": "image_url", + "image_url": "reducto://proxy.png", + }, + }, + ) + + assert response.status_code >= 400 + assert "reducto://" in response.text + assert mock_aocr.await_count == 0 + + +def test_proxy_reducto_ocr_json_passthrough_data_uri(client_no_auth): + mocked_response = OCRResponse( + pages=[OCRPage(index=0, markdown="Proxy OCR")], + model="parse-v3", + usage_info=OCRUsageInfo(pages_processed=1, credits=1), + ) + + data_uri = "data:application/pdf;base64,JVBERi0xLjQK" + + with patch( + "litellm.proxy.proxy_server.llm_router.aocr", + new=AsyncMock(return_value=mocked_response), + ) as mock_aocr: + response = client_no_auth.post( + "/v1/ocr", + json={ + "model": "reducto/parse-v3", + "document": { + "type": "document_url", + "document_url": data_uri, + }, + "api_key": "proxy-key", + "api_base": "https://platform.reducto.ai", + }, + ) + + assert response.status_code == 200 + assert mock_aocr.await_count == 1 + assert mock_aocr.await_args.kwargs["model"] == "reducto/parse-v3" + assert mock_aocr.await_args.kwargs["document"] == { + "type": "document_url", + "document_url": data_uri, + } + assert mock_aocr.await_args.kwargs["api_key"] == "proxy-key" + assert mock_aocr.await_args.kwargs["api_base"] == "https://platform.reducto.ai" + + response_body = response.json() + assert response_body["object"] == "ocr" + assert response_body["usage_info"]["credits"] == 1 + assert response_body["pages"][0]["markdown"] == "Proxy OCR" diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 210347aaf94..958b028c542 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -915,6 +915,36 @@ async def test_user_api_key_auth_websocket(): ) +@pytest.mark.asyncio +async def test_user_api_key_auth_websocket_carries_asgi_path(): + """ + The synthetic Request must carry the ASGI scope's ``path`` so + ``get_request_route`` returns the real WebSocket path, not a value + reconstructed from the (Host-poisonable) ``websocket.url``. + """ + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth_websocket + + mock_websocket = MagicMock(spec=WebSocket) + mock_websocket.query_params = {"model": "some_model"} + mock_websocket.headers = {"authorization": "Bearer some_api_key"} + mock_websocket.scope = { + "type": "websocket", + "path": "/v1/realtime", + "root_path": "", + "headers": [(b"authorization", b"Bearer some_api_key")], + } + mock_websocket.url = URL(url="/v1/realtime") + + with patch( + "litellm.proxy.auth.user_api_key_auth.user_api_key_auth", autospec=True + ) as mock_user_api_key_auth: + await user_api_key_auth_websocket(mock_websocket) + + request_arg = mock_user_api_key_auth.call_args.kwargs["request"] + assert request_arg.scope.get("path") == "/v1/realtime" + assert request_arg.scope.get("root_path") == "" + + @pytest.mark.parametrize("enforce_rbac", [True, False]) @pytest.mark.asyncio async def test_jwt_user_api_key_auth_builder_enforce_rbac(enforce_rbac, monkeypatch): diff --git a/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py new file mode 100644 index 00000000000..25bf79cd575 --- /dev/null +++ b/tests/router_unit_tests/test_router_aresponses_streaming_fallback.py @@ -0,0 +1,268 @@ +""" +Unit tests for the Responses-API streaming-fallback helpers added to Router +in PR #28215 (fix(router): wrap aresponses streaming iterator for mid-stream +fallbacks). + +Targets the four helpers introduced on Router: + - _extract_partial_responses_usage + - _combine_responses_fallback_usage + - _build_responses_continuation_input + - _aresponses_streaming_iterator +""" + +import os +import sys +from typing import Any, AsyncIterator, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm import Router +from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, +) + + +def _make_router() -> Router: + return Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + }, + { + "model_name": "fallback", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "sk-test", + }, + }, + ] + ) + + +def _make_completed_event( + input_tokens: int, output_tokens: int, total_tokens: int +) -> ResponseCompletedEvent: + response = ResponsesAPIResponse.model_construct( + usage=ResponseAPIUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) + ) + return ResponseCompletedEvent.model_construct( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=response, + ) + + +# -------- _extract_partial_responses_usage -------- + + +def test_extract_partial_responses_usage_native_completed(): + """Native path: completed_response carries usage → returned as-is.""" + completed = _make_completed_event(11, 7, 18) + source = MagicMock() + source.completed_response = completed + + usage = Router._extract_partial_responses_usage(source) + assert usage is not None + assert usage.input_tokens == 11 + assert usage.output_tokens == 7 + assert usage.total_tokens == 18 + + +def test_extract_partial_responses_usage_no_completed_response(): + """Native path: no completed_response → returns None.""" + source = MagicMock() + source.completed_response = None + + usage = Router._extract_partial_responses_usage(source) + assert usage is None + + +# -------- _combine_responses_fallback_usage -------- + + +def test_combine_responses_fallback_usage_sums_completed_event(): + """Partial-stream usage is summed into the fallback event's usage.""" + fallback_event = _make_completed_event(5, 3, 8) + partial = ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18) + + Router._combine_responses_fallback_usage(fallback_event, partial) + + combined = fallback_event.response.usage + assert combined is not None + assert combined.input_tokens == 16 + assert combined.output_tokens == 10 + assert combined.total_tokens == 26 + + +def test_combine_responses_fallback_usage_passthrough_for_unknown_event(): + """Events that are not completed/failed/incomplete are not mutated.""" + other = MagicMock() # not a ResponseCompletedEvent etc. → isinstance false + partial = ResponseAPIUsage(input_tokens=1, output_tokens=1, total_tokens=2) + Router._combine_responses_fallback_usage(other, partial) + # No mutation expected on the unknown event — call is a no-op. + + +# -------- _build_responses_continuation_input -------- + + +def test_build_responses_continuation_input_from_string(): + out = Router._build_responses_continuation_input( + "Hello world", "partial assistant text" + ) + assert len(out) == 3 + assert out[0]["role"] == "user" + assert out[0]["content"][0]["text"] == "Hello world" + assert out[1]["role"] == "developer" + assert out[2]["role"] == "assistant" + assert out[2]["content"][0]["text"] == "partial assistant text" + + +def test_build_responses_continuation_input_from_list_preserves_items(): + existing: List[Any] = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "msg1"}], + } + ] + out = Router._build_responses_continuation_input(existing, "partial") + assert len(out) == 3 + assert out[0]["content"][0]["text"] == "msg1" + assert out[1]["role"] == "developer" + assert out[2]["role"] == "assistant" + + +def test_build_responses_continuation_input_from_none(): + out = Router._build_responses_continuation_input(None, "partial") + assert len(out) == 2 + assert out[0]["role"] == "developer" + assert out[1]["role"] == "assistant" + + +# -------- _aresponses_streaming_iterator (passthrough smoke test) -------- + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_passthrough(): + """ + Without MidStreamFallbackError, the wrapper yields source events + unchanged and returns a BaseResponsesAPIStreamingIterator subclass. + """ + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + events = [_make_completed_event(1, 1, 2)] + + class _FakeSource: + """Minimal source iterator. Provides every attribute the wrapper + constructor reads from source_iterator.""" + + def __init__(self) -> None: + self._i = 0 + self.completed_response = None + self.response = MagicMock() + self.model = "openai/gpt-4o-mini" + self.logging_obj = MagicMock() + self.responses_api_provider_config = MagicMock() + self.start_time = 0.0 + self.litellm_metadata = {} + self.custom_llm_provider = "openai" + self.request_data = {} + self.call_type = "aresponses" + self._hidden_params: dict = {} + + def __aiter__(self) -> AsyncIterator[Any]: + return self + + async def __anext__(self): + if self._i >= len(events): + raise StopAsyncIteration + ev = events[self._i] + self._i += 1 + return ev + + async def aclose(self): + return None + + router = _make_router() + source = _FakeSource() + + wrapper = await router._aresponses_streaming_iterator( + source, initial_kwargs={"model": "primary"} + ) + assert isinstance(wrapper, BaseResponsesAPIStreamingIterator) + + collected = [ev async for ev in wrapper] + assert len(collected) == 1 + assert collected[0].type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED + + +# -------- _aresponses_with_streaming_fallbacks -------- + + +@pytest.mark.asyncio +async def test_aresponses_with_streaming_fallbacks_non_streaming_passthrough(): + """Non-streaming response is returned unchanged, no wrap.""" + router = _make_router() + plain_response = MagicMock() + + async def fake_original(**_kwargs): + return plain_response + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new=AsyncMock(return_value=plain_response), + ): + out = await router._aresponses_with_streaming_fallbacks( + original_function=fake_original, + model="primary", + stream=False, + ) + assert out is plain_response + + +@pytest.mark.asyncio +async def test_aresponses_with_streaming_fallbacks_wraps_streaming_iterator(): + """Streaming response is wrapped via _aresponses_streaming_iterator.""" + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + router = _make_router() + streaming_iter = MagicMock(spec=BaseResponsesAPIStreamingIterator) + wrapped = MagicMock(spec=BaseResponsesAPIStreamingIterator) + + async def fake_original(**_kwargs): + return streaming_iter + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new=AsyncMock(return_value=streaming_iter), + ), patch.object( + router, + "_aresponses_streaming_iterator", + new=AsyncMock(return_value=wrapped), + ) as mock_wrap: + out = await router._aresponses_with_streaming_fallbacks( + original_function=fake_original, + model="primary", + stream=True, + ) + assert out is wrapped + mock_wrap.assert_awaited_once() diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 697a9ebc720..d335c359aa0 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -508,6 +508,308 @@ and I learn to carry this small calm home.""" print("✓ transform_response correctly handled reasoning items and output messages") +def _make_empty_responses_api_response(model: str = "gpt-5.4"): + from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + + return ResponsesAPIResponse( + id="resp_from_stream", + created_at=1760144904, + error=None, + incomplete_details=None, + instructions=None, + metadata={}, + model=model, + object="response", + output=[], + parallel_tool_calls=True, + temperature=1.0, + tool_choice="auto", + tools=[], + top_p=1.0, + max_output_tokens=None, + previous_response_id=None, + reasoning={"effort": "low", "summary": "detailed"}, + status="completed", + text={"format": {"type": "text"}, "verbosity": "medium"}, + truncation="disabled", + usage=ResponseAPIUsage( + input_tokens=1, + input_tokens_details=None, + output_tokens=1, + output_tokens_details=None, + total_tokens=2, + cost=None, + ), + user=None, + store=True, + background=False, + billing={"payer": "developer"}, + max_tool_calls=None, + prompt_cache_key=None, + safety_identifier=None, + service_tier="default", + top_logprobs=0, + ) + + +def _make_empty_model_response(): + from litellm.types.utils import ModelResponse, Usage + + return ModelResponse( + id="chatcmpl-test-recovered", + created=1760144904, + model=None, + object="chat.completion", + system_fingerprint=None, + choices=[], + usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0), + ) + + +def test_transform_response_recovers_empty_output_from_raw_sse(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"msg_from_stream","text":"Recovered from SSE"}', + 'data: {"type":"response.completed","response":{"id":"resp_from_stream","object":"response","created_at":1760144904,"status":"completed","model":"gpt-5.4","output":[]}}', + "data: [DONE]", + "", + ] + ) + + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: ok"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Recovered from SSE" + + +def test_transform_response_recovers_output_item_done_from_raw_sse(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_from_item","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Recovered from output item","annotations":[]}]}}', + 'data: {"type":"response.completed","response":{"id":"resp_from_stream","object":"response","created_at":1760144904,"status":"completed","model":"gpt-5.4","output":[]}}', + "data: [DONE]", + "", + ] + ) + + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: ok"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Recovered from output item" + + +def test_transform_response_recovers_output_item_done_from_whitespace_padded_raw_sse(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + output_item_event = { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "id": "msg_from_item", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Recovered from padded output item", + "annotations": [], + } + ], + }, + } + completed_event = { + "type": "response.completed", + "response": { + "id": "resp_from_stream", + "object": "response", + "created_at": 1760144904, + "status": "completed", + "model": "gpt-5.4", + "output": [], + }, + } + raw_sse = "\n".join( + [ + f" data: {json.dumps(output_item_event)} ", + f"\tdata: {json.dumps(completed_event)}", + "data: [DONE]", + "", + ] + ) + + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: ok"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Recovered from padded output item" + + +def test_transform_response_preserves_output_item_when_text_done_arrives_later(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_from_item","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Complete output item text","annotations":[]}]}}', + 'data: {"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"msg_from_stream","text":"Late text event"}', + 'data: {"type":"response.completed","response":{"id":"resp_from_stream","object":"response","created_at":1760144904,"status":"completed","model":"gpt-5.4","output":[]}}', + "data: [DONE]", + "", + ] + ) + + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: ok"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Complete output item text" + + +def test_recover_output_items_merges_text_only_items_at_distinct_indices(): + """When OUTPUT_ITEM_DONE covers some indices and OUTPUT_TEXT_DONE covers + others, both must be preserved instead of treating them as mutually + exclusive fallbacks.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_item_0","role":"assistant","status":"completed","content":[{"type":"output_text","text":"From OUTPUT_ITEM_DONE","annotations":[]}]}}', + 'data: {"type":"response.output_text.done","output_index":1,"content_index":0,"item_id":"msg_text_1","text":"From OUTPUT_TEXT_DONE only"}', + "data: [DONE]", + "", + ] + ) + + recovered = ( + LiteLLMResponsesTransformationHandler._recover_output_items_from_raw_sse( + raw_sse + ) + ) + + assert len(recovered) == 2 + assert recovered[0]["id"] == "msg_item_0" + assert recovered[0]["content"][0]["text"] == "From OUTPUT_ITEM_DONE" + assert recovered[1]["id"] == "msg_text_1" + assert recovered[1]["content"][0]["text"] == "From OUTPUT_TEXT_DONE only" + + +def test_transform_response_prefers_completed_output_from_raw_sse(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + raw_sse = "\n".join( + [ + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_from_item","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Earlier stream text","annotations":[]}]}}', + 'data: {"type":"response.completed","response":{"id":"resp_from_stream","object":"response","created_at":1760144904,"status":"completed","model":"gpt-5.4","output":[{"type":"message","id":"msg_from_completed","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Authoritative completed text","annotations":[]}]}]}}', + "data: [DONE]", + "", + ] + ) + + raw_response = _make_empty_responses_api_response() + model_response = _make_empty_model_response() + logging_obj = Mock() + logging_obj.model_call_details = {"original_response": raw_sse} + + result = handler.transform_response( + model="gpt-5.4", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.4"}, + messages=[{"role": "user", "content": "Reply with exactly: ok"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Authoritative completed text" + + def test_convert_tools_to_responses_format(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, diff --git a/tests/test_litellm/containers/test_azure_container_transformation.py b/tests/test_litellm/containers/test_azure_container_transformation.py index 70181f6f03d..cdcccf7c04e 100644 --- a/tests/test_litellm/containers/test_azure_container_transformation.py +++ b/tests/test_litellm/containers/test_azure_container_transformation.py @@ -109,6 +109,31 @@ class TestAzureContainerConfig: assert "/openai/v1/containers" in url + def test_get_complete_url_strips_responses_path_and_preserves_api_version(self): + """When api_base is the responses endpoint URL, get_complete_url must: + - strip /openai/responses (no double-path) + - use the api-version from api_base query string, NOT the deployment's + older api_version (e.g. 2024-08-01-preview → containers need 2025-04-01-preview) + """ + api_base = "https://my-resource.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview" + + url = self.config.get_complete_url( + api_base=api_base, + litellm_params={"api_version": "2024-08-01-preview"}, + ) + + assert ( + "/openai/responses/openai/containers" not in url + ), "path must not double /openai/responses" + assert "my-resource.cognitiveservices.azure.com" in url + assert "/openai/containers" in url or "/openai/v1/containers" in url + assert ( + "2025-04-01-preview" in url + ), "must use version from api_base, not litellm_params" + assert ( + "2024-08-01-preview" not in url + ), "must not fall back to older chat api_version" + def test_get_complete_url_raises_without_api_base(self, monkeypatch): monkeypatch.delenv("AZURE_API_BASE", raising=False) monkeypatch.setattr(litellm, "api_base", None) @@ -531,6 +556,92 @@ class TestAzureContainerKnownFailureRegressions: assert qs.get("api-version") == ["v1"] assert qs.get("foo") == ["bar"] + @pytest.mark.asyncio + async def test_regression_no_container_id_does_not_use_user_supplied_model_id( + self, monkeypatch + ): + """Operations without container_id (create, list) must NOT route via + _ageneric_api_call_with_fallbacks using a caller-supplied model_id. + + Security boundary: only the path that holds a validated container_id + is trusted to fall back to the forwarded model_id. A caller setting + model_id without container_id on POST /v1/containers must not gain + access to an arbitrary deployment UUID. + """ + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "azure-model", + "litellm_params": { + "model": "azure/gpt-4", + "api_base": "https://my-resource.cognitiveservices.azure.com", + "api_key": "test-key", + "api_version": "2025-04-01-preview", + }, + "model_info": {"id": "deployment-uuid-123"}, + } + ] + ) + + fallback_called = {"called": False} + + async def _mock_fallback(original_function, **kwargs): + fallback_called["called"] = True + return {} + + monkeypatch.setattr(router, "_ageneric_api_call_with_fallbacks", _mock_fallback) + + original_called = {"called": False} + + async def _noop(**kwargs): + original_called["called"] = True + return {} + + # No container_id — simulates create/list; caller injects a model_id + await router._init_containers_api_endpoints( + original_function=_noop, + model_id="deployment-uuid-123", + custom_llm_provider="azure", + ) + + assert not fallback_called["called"], ( + "_ageneric_api_call_with_fallbacks must NOT be called when " + "container_id is absent, even if model_id is supplied" + ) + assert original_called["called"], "original_function must be called directly" + + def test_regression_httpx_empty_params_strips_query_string(self): + """httpx erases the URL query-string when params={} (empty dict) is passed. + + Root cause of the Azure container 404s on POST/DELETE: + _build_query_params returns {} when the endpoint has no extra params; + passing that {} as params= to httpx wiped ?api-version=2025-04-01-preview. + + Fix: every container httpx call now uses `params or None` so an empty + dict falls back to None, which tells httpx to leave the URL untouched. + """ + url = ( + "https://resource.cognitiveservices.azure.com" + "/openai/containers/cntr_123?api-version=2025-04-01-preview" + ) + client = httpx.AsyncClient() + + req_none = client.build_request("DELETE", url, params=None) + assert "api-version=2025-04-01-preview" in str(req_none.url) + + req_empty = client.build_request("DELETE", url, params={}) + assert "api-version" not in str( + req_empty.url + ), "Documents root cause: params={} strips the query string" + + effective: dict = {} + req_guarded = client.build_request("DELETE", url, params=effective or None) + assert "api-version=2025-04-01-preview" in str( + req_guarded.url + ), "`params or None` must preserve ?api-version" + def test_regression_proxy_resolves_azure_text_same_as_azure(self): """Router/proxy treat azure_text like azure for container config.""" from litellm.proxy.container_endpoints.handler_factory import ( @@ -770,3 +881,143 @@ class TestAzureContainerKnownFailureRegressions: assert captured["data"]["container_id"] == "cntr_123" assert captured["data"]["custom_llm_provider"] == "azure" assert captured["data"]["model_id"] == "model_abc123" + + @pytest.mark.asyncio + async def test_regression_get_container_forwarding_params_sets_model_id_for_managed_id( + self, + ): + """get_container_forwarding_params must extract model_id from a + LiteLLM-managed encoded container ID and include it in the forwarding + dict. This is the proxy-side half of the native-Azure-ID routing fix: + the router's _init_containers_api_endpoints reads kwargs["model_id"] + which is set here. + """ + from litellm.proxy.container_endpoints.ownership import ( + get_container_forwarding_params, + ) + + encoded_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="deployment-uuid-123", + container_id="cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df", + ) + + params = await get_container_forwarding_params( + container_id=encoded_id, + original_container_id="cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df", + custom_llm_provider="azure", + ) + + assert ( + params.get("model_id") == "deployment-uuid-123" + ), "model_id must be forwarded to the router for managed container IDs" + assert params.get("container_id") == ( + "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + ) + assert params.get("custom_llm_provider") == "azure" + + @pytest.mark.asyncio + async def test_regression_get_container_forwarding_params_recovers_model_id_for_native_id( + self, monkeypatch + ): + """Native Azure IDs (``cntr_``) cannot be decoded, so model_id + must be recovered from the ownership row's ``unified_object_id`` — + the encoded form captured at create time when the router selected a + specific deployment. Without this, the router-side fallback for + native IDs in ``_init_containers_api_endpoints`` is dead code. + """ + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from litellm.proxy.container_endpoints import ownership + from litellm.proxy.container_endpoints.ownership import ( + get_container_forwarding_params, + ) + + native_id = "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + encoded_stored_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="deployment-uuid-123", + container_id=native_id, + ) + + ownership._CONTAINER_STORED_ID_CACHE.flush_cache() + ownership._CONTAINER_OWNER_CACHE.flush_cache() + + table = AsyncMock() + table.find_first.return_value = SimpleNamespace( + created_by="user-1", + file_purpose=ownership.CONTAINER_OBJECT_PURPOSE, + unified_object_id=encoded_stored_id, + ) + prisma_client = SimpleNamespace( + db=SimpleNamespace(litellm_managedobjecttable=table) + ) + monkeypatch.setattr( + ownership, + "_get_prisma_client", + AsyncMock(return_value=prisma_client), + ) + + params = await get_container_forwarding_params( + container_id=native_id, + original_container_id=native_id, + custom_llm_provider="azure", + ) + + assert params.get("model_id") == "deployment-uuid-123", ( + "model_id must be recovered from the stored unified_object_id " + "for native upstream container IDs" + ) + assert params.get("container_id") == native_id + assert params.get("custom_llm_provider") == "azure" + + @pytest.mark.asyncio + async def test_regression_native_azure_container_id_uses_forwarded_model_id( + self, monkeypatch + ): + """Native Azure container IDs (cntr_ + hex, no LiteLLM payload) must + still route through _ageneric_api_call_with_fallbacks using the + model_id forwarded from the proxy ownership check so that deployment + credentials (api_base) are applied.""" + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "azure-model", + "litellm_params": { + "model": "azure/gpt-4", + "api_base": "https://my-resource.cognitiveservices.azure.com", + "api_key": "test-key", + "api_version": "2025-04-01-preview", + }, + "model_info": {"id": "deployment-uuid-123"}, + } + ] + ) + + called_with: dict = {} + + async def _mock_fallback(original_function, **kwargs): + called_with.update(kwargs) + return {} + + monkeypatch.setattr(router, "_ageneric_api_call_with_fallbacks", _mock_fallback) + + native_azure_id = "cntr_6a058b43d24c8190a226cfb1d35405b20115fb7875ff11df" + + async def _noop(**kwargs): + return {} + + await router._init_containers_api_endpoints( + original_function=_noop, + container_id=native_azure_id, + model_id="deployment-uuid-123", + custom_llm_provider="azure", + ) + + assert called_with.get("model") == "deployment-uuid-123", ( + "_ageneric_api_call_with_fallbacks must be called with the forwarded " + "model_id when the container_id carries no LiteLLM routing payload" + ) diff --git a/tests/test_litellm/enterprise/proxy/test_batch_update_db_managed_output_file_id.py b/tests/test_litellm/enterprise/proxy/test_batch_update_db_managed_output_file_id.py new file mode 100644 index 00000000000..d8669960674 --- /dev/null +++ b/tests/test_litellm/enterprise/proxy/test_batch_update_db_managed_output_file_id.py @@ -0,0 +1,260 @@ +"""Regression: update_batch_in_database must not persist raw provider output_file_id.""" + +import json +from types import SimpleNamespace +from typing import Optional +import pytest +from unittest.mock import AsyncMock, MagicMock + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.openai_files_endpoints.common_utils import ( + ensure_batch_response_managed_file_ids, + update_batch_in_database, +) +from litellm.types.utils import LiteLLMBatch + + +def _build_batch_response( + *, + batch_id: str = "batch_managed_ids_test", + status: str = "completed", + output_file_id: Optional[str] = "file-rawoutput789", + error_file_id: Optional[str] = None, + hidden_params: Optional[dict] = None, +) -> LiteLLMBatch: + batch = LiteLLMBatch( + id=batch_id, + object="batch", + status=status, + endpoint="/v1/chat/completions", + input_file_id="file-input123", + output_file_id=output_file_id, + error_file_id=error_file_id, + completion_window="24h", + created_at=1234567890, + ) + if hidden_params is not None: + batch._hidden_params = hidden_params # type: ignore[attr-defined] + return batch + + +def _build_managed_files_mock(unified_id: str = "file-bWFuYWdlZF9vdXRwdXRfaWQ="): + mock = MagicMock() + mock.get_unified_output_file_id = MagicMock(return_value=unified_id) + mock.store_unified_file_id = AsyncMock() + return mock + + +def _build_prisma_mock(): + mock = MagicMock() + mock.db.litellm_managedfiletable.find_first = AsyncMock(return_value=None) + mock.db.litellm_managedobjecttable.update = AsyncMock() + return mock + + +@pytest.mark.asyncio +async def test_update_batch_in_database_stores_unified_output_file_id(): + raw_output_file_id = "file-rawoutput789" + unified_output_file_id = "file-bWFuYWdlZF9vdXRwdXRfaWQ=" + batch_id = "batch_managed_ids_test" + unified_batch_id = ( + "litellm_proxy;model_id:my-model;llm_batch_id:batch_managed_ids_test" + ) + + response = _build_batch_response( + batch_id=batch_id, + output_file_id=raw_output_file_id, + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + + mock_managed_files = _build_managed_files_mock(unified_id=unified_output_file_id) + mock_prisma = _build_prisma_mock() + + await update_batch_in_database( + batch_id=batch_id, + unified_batch_id=unified_batch_id, + response=response, + managed_files_obj=mock_managed_files, + prisma_client=mock_prisma, + verbose_proxy_logger=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + stored = json.loads( + mock_prisma.db.litellm_managedobjecttable.update.call_args.kwargs["data"][ + "file_object" + ] + ) + assert stored["output_file_id"] == unified_output_file_id + assert stored["output_file_id"] != raw_output_file_id + + +@pytest.mark.asyncio +async def test_ensure_batch_response_normalizes_error_file_id(): + """Both output_file_id and error_file_id must be normalized to managed IDs.""" + unified_id = "file-bWFuYWdlZF9vdXRwdXRfaWQ=" + response = _build_batch_response( + output_file_id="file-raw-output", + error_file_id="file-raw-error", + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + + mock_managed_files = _build_managed_files_mock(unified_id=unified_id) + mock_prisma = _build_prisma_mock() + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=mock_prisma, + verbose_proxy_logger=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + assert response.output_file_id == unified_id + assert response.error_file_id == unified_id + assert mock_managed_files.get_unified_output_file_id.call_count == 2 + + +@pytest.mark.asyncio +async def test_ensure_batch_response_swallows_conversion_errors(): + """When the managed-files conversion raises, the failure is logged, not propagated.""" + raw_output_file_id = "file-raw-output" + response = _build_batch_response( + output_file_id=raw_output_file_id, + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + + mock_managed_files = MagicMock() + mock_managed_files.get_unified_output_file_id = MagicMock( + side_effect=RuntimeError("boom") + ) + mock_managed_files.store_unified_file_id = AsyncMock() + + mock_logger = MagicMock() + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=mock_logger, + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + assert response.output_file_id == raw_output_file_id + mock_logger.warning.assert_called() + + +@pytest.mark.asyncio +async def test_ensure_batch_response_builds_auth_from_db_batch_object(): + """If user_api_key_dict is omitted, fall back to created_by/team_id on db_batch_object.""" + unified_id = "file-bWFuYWdlZF9vdXRwdXRfaWQ=" + response = _build_batch_response( + output_file_id="file-raw-output", + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + + mock_managed_files = _build_managed_files_mock(unified_id=unified_id) + db_batch_object = SimpleNamespace( + created_by="user-from-db", team_id="team-from-db", status="completed" + ) + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=MagicMock(), + db_batch_object=db_batch_object, + ) + + forwarded_auth = mock_managed_files.store_unified_file_id.call_args.kwargs[ + "user_api_key_dict" + ] + assert forwarded_auth.user_id == "user-from-db" + assert forwarded_auth.team_id == "team-from-db" + + +@pytest.mark.asyncio +async def test_ensure_batch_response_resolves_model_name_from_unified_file_id(): + """When hidden_params lacks model_name, derive it from unified_file_id.""" + unified_id = "file-bWFuYWdlZF9vdXRwdXRfaWQ=" + response = _build_batch_response( + output_file_id="file-raw-output", + hidden_params={ + "model_id": "my-model", + "unified_file_id": "litellm_proxy:application/octet-stream;unified_id,abc;target_model_names,gpt-4o-mini,gemini-2.0-flash", + }, + ) + + mock_managed_files = _build_managed_files_mock(unified_id=unified_id) + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + assert ( + mock_managed_files.get_unified_output_file_id.call_args.kwargs["model_name"] + == "gpt-4o-mini,gemini-2.0-flash" + ) + + +@pytest.mark.asyncio +async def test_ensure_batch_response_returns_early_without_managed_files_obj(): + """Without managed_files_obj, the helper is a no-op (no conversion attempted).""" + response = _build_batch_response( + output_file_id="file-raw-output", + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=None, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + assert response.output_file_id == "file-raw-output" + + +@pytest.mark.asyncio +async def test_ensure_batch_response_returns_early_without_model_id(): + """Without model_id in hidden_params, the helper cannot create managed IDs.""" + response = _build_batch_response( + output_file_id="file-raw-output", + hidden_params={"model_name": "openai/gpt-4o"}, + ) + mock_managed_files = _build_managed_files_mock() + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="user-abc"), + ) + + assert response.output_file_id == "file-raw-output" + mock_managed_files.get_unified_output_file_id.assert_not_called() + + +@pytest.mark.asyncio +async def test_ensure_batch_response_returns_early_without_auth(): + """Without user_api_key_dict or db_batch_object, no conversion is attempted.""" + response = _build_batch_response( + output_file_id="file-raw-output", + hidden_params={"model_id": "my-model", "model_name": "openai/gpt-4o"}, + ) + mock_managed_files = _build_managed_files_mock() + + await ensure_batch_response_managed_file_ids( + response=response, + managed_files_obj=mock_managed_files, + prisma_client=_build_prisma_mock(), + verbose_proxy_logger=MagicMock(), + ) + + assert response.output_file_id == "file-raw-output" + mock_managed_files.get_unified_output_file_id.assert_not_called() diff --git a/tests/test_litellm/integrations/rubrik_test_helpers.py b/tests/test_litellm/integrations/rubrik_test_helpers.py new file mode 100644 index 00000000000..1bdb8cb247b --- /dev/null +++ b/tests/test_litellm/integrations/rubrik_test_helpers.py @@ -0,0 +1,23 @@ +"""Shared helpers for Rubrik plugin tests.""" + +from typing import Any, Dict + +from litellm.types.utils import GenericGuardrailAPIInputs + + +def make_tool_call_dict( + tc_id: str, name: str, arguments: str = "{}" +) -> Dict[str, Any]: + """Create a tool call dict matching the ChatCompletionMessageToolCall schema.""" + return { + "id": tc_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + + +def make_inputs_with_tools( + tool_calls: list, texts: list | None = None +) -> GenericGuardrailAPIInputs: + """Create GenericGuardrailAPIInputs with tool_calls.""" + return GenericGuardrailAPIInputs(texts=texts or [], tool_calls=tool_calls) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 87298b5a7a3..b65e629c890 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -66,7 +66,7 @@ class TestOpenTelemetryGuardrails(unittest.TestCase): mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail") mock_span.set_attribute.assert_any_call("guardrail_mode", "input") mock_span.set_attribute.assert_any_call( - "guardrail_response", "filtered_content" + "guardrail_response", safe_dumps("filtered_content") ) mock_span.set_attribute.assert_any_call( "masked_entity_count", safe_dumps({"CREDIT_CARD": 2}) @@ -87,6 +87,208 @@ class TestOpenTelemetryGuardrails(unittest.TestCase): # Verify that start_span was never called otel.tracer.start_span.assert_not_called() + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_response_dict_is_json_serialized(self, mock_datetime): + """Dict guardrail_response (e.g. OpenAI moderation result) must reach + the span as a JSON string so downstream pipelines can parse it for + metric extraction — this is the bug the PR fixes.""" + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + moderation_payload = { + "id": "modr-7740", + "model": "omni-moderation-latest", + "results": [{"categories": {"harassment": False}}], + } + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": moderation_payload, + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": {"guardrail_information": [guardrail_info]} + } + + otel._create_guardrail_span(kwargs=kwargs, context=None) + + mock_span.set_attribute.assert_any_call( + "guardrail_response", safe_dumps(moderation_payload) + ) + + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_response_none_is_skipped(self, mock_datetime): + """When guardrail_response is None, the attribute must not be set — + guards against round-tripping ``"null"`` into traces.""" + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": None, + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": {"guardrail_information": [guardrail_info]} + } + + otel._create_guardrail_span(kwargs=kwargs, context=None) + + attribute_keys = [ + call.args[0] for call in mock_span.set_attribute.call_args_list + ] + self.assertNotIn("guardrail_response", attribute_keys) + + +class TestOpenTelemetryTeamAttributesOnChildSpans(unittest.TestCase): + """team_id / team_alias must land on every child span of a + litellm_request trace, not only the root litellm_request span.""" + + def _slo_metadata(self): + return { + "user_api_key_team_id": "team-123", + "user_api_key_team_alias": "my-team", + } + + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_span_has_team_attributes(self, mock_datetime): + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": "filtered_content", + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": { + "guardrail_information": [guardrail_info], + "metadata": self._slo_metadata(), + } + } + + otel._create_guardrail_span(kwargs=kwargs, context=None) + + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_id", "team-123" + ) + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_alias", "my-team" + ) + + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) + @patch("litellm.turn_off_message_logging", False) + def test_raw_request_span_has_team_attributes(self): + otel = OpenTelemetry() + otel.message_logging = True + + mock_tracer = MagicMock() + mock_span = MagicMock() + mock_tracer.start_span.return_value = mock_span + otel.get_tracer_to_use_for_request = MagicMock(return_value=mock_tracer) + otel.set_raw_request_attributes = MagicMock() + otel._to_ns = MagicMock(return_value=1234567890) + + kwargs = { + "litellm_params": {"metadata": {}}, + "standard_logging_object": {"metadata": self._slo_metadata()}, + } + otel._maybe_log_raw_request( + kwargs, {}, datetime.now(), datetime.now(), MagicMock() + ) + + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_id", "team-123" + ) + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_alias", "my-team" + ) + + def test_helper_skips_when_team_values_missing(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + otel._set_team_attributes_on_span(span=mock_span, team_id=None, team_alias=None) + + mock_span.set_attribute.assert_not_called() + + def test_helper_skips_when_team_values_are_empty_strings(self): + """A master-key / team-less request carries user_api_key_team_id='' + in metadata. Propagating '' to every span is noise that makes + traces look mis-instrumented; treat empty as absent.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + otel._set_team_attributes_on_span(span=mock_span, team_id="", team_alias="") + + mock_span.set_attribute.assert_not_called() + + def test_helper_reads_metadata_from_kwargs(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + otel._set_team_attributes_from_kwargs( + mock_span, + {"standard_logging_object": {"metadata": self._slo_metadata()}}, + ) + + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_id", "team-123" + ) + mock_span.set_attribute.assert_any_call( + "metadata.user_api_key_team_alias", "my-team" + ) + + def test_helper_handles_missing_standard_logging_object(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + otel._set_team_attributes_from_kwargs(mock_span, {}) + + mock_span.set_attribute.assert_not_called() + + def test_failure_hook_exception_span_has_team_attributes(self): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer(__name__) + + otel = OpenTelemetry() + otel.tracer = tracer + server_span = tracer.start_span("Received Proxy Server Request") + + user_api_key_dict = MagicMock() + user_api_key_dict.parent_otel_span = server_span + user_api_key_dict.team_id = "team-123" + user_api_key_dict.team_alias = "my-team" + + asyncio.run( + otel.async_post_call_failure_hook( + request_data={}, + original_exception=ValueError("boom"), + user_api_key_dict=user_api_key_dict, + traceback_str="trace", + ) + ) + + finished = {s.name: s for s in exporter.get_finished_spans()} + exception_span = finished["Failed Proxy Server Request"] + assert exception_span.attributes["metadata.user_api_key_team_id"] == "team-123" + assert ( + exception_span.attributes["metadata.user_api_key_team_alias"] == "my-team" + ) + class TestOpenTelemetryCostBreakdown(unittest.TestCase): def test_cost_breakdown_emitted_to_otel_span(self): @@ -1026,7 +1228,7 @@ class TestOpenTelemetry(unittest.TestCase): mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail") mock_span.set_attribute.assert_any_call("guardrail_mode", "input") mock_span.set_attribute.assert_any_call( - "guardrail_response", "filtered_content" + "guardrail_response", safe_dumps("filtered_content") ) mock_span.set_attribute.assert_any_call( "masked_entity_count", safe_dumps({"CREDIT_CARD": 2}) diff --git a/tests/test_litellm/integrations/test_otel_guardrail_violation_spans.py b/tests/test_litellm/integrations/test_otel_guardrail_violation_spans.py new file mode 100644 index 00000000000..ace9399cf53 --- /dev/null +++ b/tests/test_litellm/integrations/test_otel_guardrail_violation_spans.py @@ -0,0 +1,641 @@ +""" +Tests for guardrail OTEL spans on violation. + +Two distinct gaps surface together when a pre-call guardrail blocks the +request before it reaches the LLM provider: + + 1. ``async_post_call_failure_hook`` (the OTEL hook that actually runs on + the proxy failure path) only stamps attributes on the proxy parent + span. It never creates the child ``guardrail`` span, even though + ``request_data["metadata"]["standard_logging_guardrail_information"]`` + is populated by the time the hook runs. + + 2. ``_create_guardrail_span`` records ``guardrail_name`` / ``guardrail_mode`` + / ``guardrail_response`` but does not surface ``guardrail_status`` + (success / guardrail_intervened / guardrail_failed_to_respond / + not_run) or the violation categories (Bedrock topic policy names, + content filter types, etc.) as queryable span attributes — the data + is buried inside the serialised ``guardrail_response`` blob and cannot + be filtered on in the trace backend. + +The tests below use real OTEL SDK objects (TracerProvider + +InMemorySpanExporter + a real BatchSpanProcessor-equivalent) and the +real ``OpenTelemetry`` integration. No monkey patching of the integration +under test — only the OTEL exporter is in-memory. +""" + +import os +import sys +import time +import unittest +from datetime import datetime, timedelta, timezone + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import StatusCode + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.integrations.opentelemetry import ( + LITELLM_REQUEST_SPAN_NAME, + OpenTelemetry, +) +from litellm.proxy._types import UserAPIKeyAuth + + +GUARDRAIL_SPAN_NAME = "guardrail" +PROXY_SPAN_NAME = "Received Proxy Server Request" + + +def _bedrock_block_response(): + """Realistic Bedrock ApplyGuardrail response when a topic policy fires. + + Mirrors the shape in ``litellm/types/proxy/guardrails/guardrail_hooks/ + bedrock_guardrails.py`` so the violation-category extraction can be + tested against the exact payload Bedrock returns. + """ + return { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "topicPolicy": { + "topics": [ + { + "name": "Fiduciary Advice", + "type": "DENY", + "action": "BLOCKED", + } + ] + }, + "contentPolicy": { + "filters": [ + { + "type": "VIOLENCE", + "confidence": "HIGH", + "action": "BLOCKED", + } + ] + }, + "wordPolicy": { + "customWords": [{"match": "secret-codeword", "action": "BLOCKED"}], + "managedWordLists": [ + {"match": "fuck", "type": "PROFANITY", "action": "BLOCKED"} + ], + }, + } + ], + "outputs": [{"text": "Sorry, the model cannot respond to this request."}], + } + + +def _slg_entry( + guardrail_status, + guardrail_response, + *, + name="bedrock-test", + mode="pre_call", + provider="bedrock", + start=1.0, + end=2.0, + violation_categories=None, + guardrail_action=None, +): + """Build a StandardLoggingGuardrailInformation entry the way + ``add_standard_logging_guardrail_information_to_request_data`` does.""" + entry = { + "guardrail_name": name, + "guardrail_provider": provider, + "guardrail_mode": mode, + "guardrail_response": guardrail_response, + "guardrail_status": guardrail_status, + "start_time": start, + "end_time": end, + "duration": end - start, + } + if violation_categories is not None: + entry["violation_categories"] = violation_categories + if guardrail_action is not None: + entry["guardrail_action"] = guardrail_action + return entry + + +def _kwargs_with_guardrail( + *, + entries, + parent_span=None, + include_exception=False, +): + """Build the kwargs / model_call_details shape that the OTEL integration + consumes. ``litellm_params.metadata`` is the SAME dict that the proxy's + ``request_data["metadata"]`` becomes after ``update_environment_variables``, + so ``_otel_internal`` dedupe state lives there too.""" + metadata = {"standard_logging_guardrail_information": list(entries)} + if parent_span is not None: + metadata["litellm_parent_otel_span"] = parent_span + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": { + "custom_llm_provider": "openai", + "metadata": metadata, + }, + "standard_logging_object": { + "id": "test-call-id", + "call_type": "completion", + "metadata": metadata, + "hidden_params": {}, + "guardrail_information": list(entries), + }, + } + if include_exception: + kwargs["exception"] = Exception("guardrail blocked the request") + return kwargs + + +def _make_otel(): + """Spin up a real OTEL pipeline backed by an in-memory exporter.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + otel = OpenTelemetry(tracer_provider=provider) + otel.tracer = provider.get_tracer(__name__) + return otel, provider, exporter + + +def _run(coro): + """Run a coroutine on a fresh event loop and close it — prevents the + "unclosed event loop" / ResourceWarning that you get from + asyncio.new_event_loop().run_until_complete() with no cleanup.""" + import asyncio + + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _attr(span, key): + return (span.attributes or {}).get(key) + + +class TestGuardrailSpanOnViolation(unittest.TestCase): + """Bug 1: when a pre-call guardrail blocks, the guardrail span and the + litellm_request span must both appear with the correct status.""" + + def test_handle_failure_creates_litellm_request_and_guardrail_spans(self): + """Driving ``_handle_failure`` with a populated + ``standard_logging_object['guardrail_information']`` entry must + emit both spans, parented correctly, with ERROR on the parent.""" + otel, _, exporter = _make_otel() + + kwargs = _kwargs_with_guardrail( + entries=[ + _slg_entry("guardrail_intervened", _bedrock_block_response()), + ], + include_exception=True, + ) + + start = datetime.now(timezone.utc) + end = start + timedelta(milliseconds=20) + otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end) + + spans = exporter.get_finished_spans() + litellm_spans = [s for s in spans if s.name == LITELLM_REQUEST_SPAN_NAME] + guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME] + + self.assertEqual( + len(litellm_spans), + 1, + "Expected exactly one litellm_request span on guardrail block", + ) + self.assertEqual(litellm_spans[0].status.status_code, StatusCode.ERROR) + + self.assertEqual( + len(guardrail_spans), + 1, + "Expected exactly one guardrail span on guardrail block", + ) + + # Guardrail span must be a child of the litellm_request span + self.assertIsNotNone( + guardrail_spans[0].parent, + "Guardrail span must be parented (not a root span)", + ) + self.assertEqual( + guardrail_spans[0].parent.span_id, + litellm_spans[0].context.span_id, + ) + + def test_async_post_call_failure_hook_emits_guardrail_span(self): + """The production failure path on the proxy calls + ``async_post_call_failure_hook`` with the (still-populated) + ``request_data``. The hook currently only stamps attrs on the proxy + span; it must also emit the guardrail span so the violation is + visible in the trace.""" + otel, provider, exporter = _make_otel() + parent_span = provider.get_tracer(__name__).start_span(PROXY_SPAN_NAME) + + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-test", + parent_otel_span=parent_span, + request_route="/chat/completions", + ) + + request_data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "standard_logging_guardrail_information": [ + _slg_entry("guardrail_intervened", _bedrock_block_response()) + ], + }, + } + + _run( + otel.async_post_call_failure_hook( + request_data=request_data, + original_exception=Exception("guardrail blocked"), + user_api_key_dict=user_api_key_dict, + ) + ) + + spans = exporter.get_finished_spans() + guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME] + self.assertEqual( + len(guardrail_spans), + 1, + "async_post_call_failure_hook must emit the guardrail span when " + "request_data['metadata'] carries standard_logging_guardrail_information", + ) + + # The guardrail span must be parented to the proxy request span so + # backends correlate it with the rest of the trace. + self.assertIsNotNone(guardrail_spans[0].parent) + self.assertEqual( + guardrail_spans[0].parent.span_id, + parent_span.context.span_id, + ) + + def test_handle_failure_and_post_call_failure_hook_dedupe(self): + """When _handle_failure and async_post_call_failure_hook BOTH fire + for the same request (the production flow on a guardrail block), + exactly one guardrail span must be emitted. The dedupe relies on + request_data['metadata'] and kwargs['litellm_params']['metadata'] + referencing the SAME dict so _emit_once sees its earlier marker.""" + otel, provider, exporter = _make_otel() + parent_span = provider.get_tracer(__name__).start_span(PROXY_SPAN_NAME) + + # Shared metadata dict — same identity, mirroring how + # update_environment_variables wires them in the proxy. + shared_metadata = { + "standard_logging_guardrail_information": [ + _slg_entry( + "guardrail_intervened", + _bedrock_block_response(), + violation_categories=["Fiduciary Advice"], + ) + ], + } + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": { + "custom_llm_provider": "openai", + "metadata": shared_metadata, + }, + "standard_logging_object": { + "id": "test-call-id", + "call_type": "completion", + "metadata": shared_metadata, + "hidden_params": {}, + "guardrail_information": shared_metadata[ + "standard_logging_guardrail_information" + ], + }, + "exception": Exception("guardrail blocked"), + } + request_data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": shared_metadata, + } + user_api_key_dict = UserAPIKeyAuth( + api_key="sk-test", + parent_otel_span=parent_span, + request_route="/chat/completions", + ) + + start = datetime.now(timezone.utc) + end = start + timedelta(milliseconds=20) + otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end) + _run( + otel.async_post_call_failure_hook( + request_data=request_data, + original_exception=Exception("guardrail blocked"), + user_api_key_dict=user_api_key_dict, + ) + ) + + guardrail_spans = [ + s for s in exporter.get_finished_spans() if s.name == GUARDRAIL_SPAN_NAME + ] + self.assertEqual( + len(guardrail_spans), + 1, + "Dedupe must collapse the two emit calls into one span when the " + "metadata dict identity is shared between kwargs and request_data", + ) + + +class TestGuardrailSpanAttributesOnViolation(unittest.TestCase): + """Bug 2: the guardrail span must surface the violation status and + violation categories as queryable span attributes, not bury them inside + ``guardrail_response`` (which is logged as a single serialised blob).""" + + def _emit_and_get_guardrail_span(self, entry): + otel, _, exporter = _make_otel() + kwargs = _kwargs_with_guardrail(entries=[entry]) + otel._create_guardrail_span(kwargs=kwargs, context=None) + + guardrail_spans = [ + s for s in exporter.get_finished_spans() if s.name == GUARDRAIL_SPAN_NAME + ] + self.assertEqual(len(guardrail_spans), 1) + return guardrail_spans[0] + + def test_status_attribute_present_for_intervened(self): + entry = _slg_entry("guardrail_intervened", _bedrock_block_response()) + span = self._emit_and_get_guardrail_span(entry) + self.assertEqual( + _attr(span, "guardrail_status"), + "guardrail_intervened", + "guardrail_status must be exposed as a top-level span attribute", + ) + + def test_status_attribute_present_for_success(self): + entry = _slg_entry( + "success", + {"action": "NONE", "assessments": []}, + ) + span = self._emit_and_get_guardrail_span(entry) + self.assertEqual(_attr(span, "guardrail_status"), "success") + + def test_status_attribute_present_for_failed_to_respond(self): + entry = _slg_entry( + "guardrail_failed_to_respond", + {"error": "endpoint unreachable"}, + ) + span = self._emit_and_get_guardrail_span(entry) + self.assertEqual(_attr(span, "guardrail_status"), "guardrail_failed_to_respond") + + def test_violation_categories_surfaced_when_provider_populates_them(self): + """The provider hook (e.g. Bedrock) extracts violation categories + from the raw response BEFORE redaction and stamps them onto the + StandardLoggingGuardrailInformation entry. OTEL must surface that + list as a queryable span attribute so dashboards can group by + violation type without parsing the redacted guardrail_response.""" + entry = _slg_entry( + "guardrail_intervened", + _bedrock_block_response(), + violation_categories=["Fiduciary Advice", "VIOLENCE", "PROFANITY"], + ) + span = self._emit_and_get_guardrail_span(entry) + + categories = _attr(span, "guardrail_violation_categories") + self.assertIsNotNone( + categories, + "guardrail_violation_categories must be set when the entry " + "carries violation_categories", + ) + # Serialised as JSON to keep set_attribute typing simple. + as_str = categories if isinstance(categories, str) else repr(list(categories)) + self.assertIn("Fiduciary Advice", as_str) + self.assertIn("VIOLENCE", as_str) + self.assertIn("PROFANITY", as_str) + + def test_no_violation_categories_when_field_absent(self): + """When the provider didn't populate violation_categories (success + path, or provider didn't extract them), don't pollute the trace + with an empty attribute.""" + entry = _slg_entry("success", {"action": "NONE", "assessments": []}) + span = self._emit_and_get_guardrail_span(entry) + self.assertIsNone(_attr(span, "guardrail_violation_categories")) + + def test_no_violation_categories_when_field_is_empty(self): + """Empty list must not produce a span attribute either.""" + entry = _slg_entry( + "guardrail_intervened", + _bedrock_block_response(), + violation_categories=[], + ) + span = self._emit_and_get_guardrail_span(entry) + self.assertIsNone(_attr(span, "guardrail_violation_categories")) + + def test_guardrail_action_surfaced_when_provider_populates_it(self): + """The provider hook (e.g. Bedrock) writes its raw top-level + ``action`` string onto StandardLoggingGuardrailInformation as + ``guardrail_action``. OTEL must expose it as a queryable span + attribute so dashboards can pivot on the raw provider verdict + (Bedrock ``GUARDRAIL_INTERVENED`` / ``NONE``) without parsing + the redacted guardrail_response blob.""" + entry = _slg_entry( + "guardrail_intervened", + _bedrock_block_response(), + guardrail_action="GUARDRAIL_INTERVENED", + ) + span = self._emit_and_get_guardrail_span(entry) + self.assertEqual( + _attr(span, "guardrail_action"), + "GUARDRAIL_INTERVENED", + "guardrail_action must be exposed as a top-level span attribute", + ) + + def test_guardrail_action_surfaced_for_allowed_request(self): + """Even on the success path, the provider's raw action (e.g. + Bedrock ``NONE``) should be queryable so dashboards can group + allowed-vs-blocked counts off the same attribute.""" + entry = _slg_entry( + "success", + {"action": "NONE", "assessments": []}, + guardrail_action="NONE", + ) + span = self._emit_and_get_guardrail_span(entry) + self.assertEqual(_attr(span, "guardrail_action"), "NONE") + + def test_no_guardrail_action_when_field_absent(self): + """If the provider didn't populate the field (older payloads, + non-Bedrock providers without a top-level action), don't emit + an empty attribute.""" + entry = _slg_entry("success", {"action": "NONE", "assessments": []}) + span = self._emit_and_get_guardrail_span(entry) + self.assertIsNone(_attr(span, "guardrail_action")) + + +class TestMultipleGuardrailsOneBlocks(unittest.TestCase): + """When several guardrails run sequentially and only the last one + intervenes, every guardrail span must appear with its own status — + losing the early "allowed" spans would mask which checks ran.""" + + def test_all_guardrail_spans_emitted_with_per_entry_status(self): + otel, _, exporter = _make_otel() + + entries = [ + _slg_entry( + "success", + {"action": "NONE", "assessments": []}, + name="pii-mask", + start=1.0, + end=1.5, + ), + _slg_entry( + "success", + {"action": "NONE", "assessments": []}, + name="prompt-injection", + start=2.0, + end=2.2, + ), + _slg_entry( + "guardrail_intervened", + _bedrock_block_response(), + name="bedrock-policy", + start=3.0, + end=3.4, + ), + ] + kwargs = _kwargs_with_guardrail( + entries=entries, + include_exception=True, + ) + + start = datetime.now(timezone.utc) + end = start + timedelta(milliseconds=50) + otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end) + + spans = exporter.get_finished_spans() + guardrail_spans = sorted( + (s for s in spans if s.name == GUARDRAIL_SPAN_NAME), + key=lambda s: (s.attributes or {}).get("guardrail_name", ""), + ) + self.assertEqual( + len(guardrail_spans), + 3, + "Every guardrail invocation must emit a span — even the ones " + "that allowed the request through before the blocker fired", + ) + + statuses = { + _attr(s, "guardrail_name"): _attr(s, "guardrail_status") + for s in guardrail_spans + } + self.assertEqual(statuses["pii-mask"], "success") + self.assertEqual(statuses["prompt-injection"], "success") + self.assertEqual(statuses["bedrock-policy"], "guardrail_intervened") + + +class TestCustomGuardrailEndToEnd(unittest.TestCase): + """End-to-end: a real ``CustomGuardrail`` subclass calls + ``add_standard_logging_guardrail_information_to_request_data`` and then + raises. We then drive ``_handle_failure`` with the resulting kwargs + (matching the shape ``async_failure_handler`` would build) and verify + the guardrail span carries the recorded information.""" + + def test_real_custom_guardrail_violation_path(self): + # Deliberately not importing fastapi here — the real Bedrock guardrail + # raises HTTPException, but the OTEL span flow is exception-type + # agnostic. Using a plain Exception keeps this test runnable in + # SDK-only installs that don't ship fastapi. + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.types.guardrails import GuardrailEventHooks + + class BlockingViolation(Exception): + pass + + class BlockingGuardrail(CustomGuardrail): + async def async_pre_call_hook( + self, + user_api_key_dict, + cache, + data, + call_type, + ): + start_ts = time.time() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider="bedrock", + guardrail_json_response=_bedrock_block_response(), + request_data=data, + guardrail_status="guardrail_intervened", + start_time=start_ts, + end_time=start_ts + 0.01, + duration=0.01, + event_type=GuardrailEventHooks.pre_call, + tracing_detail={ + "violation_categories": ["Fiduciary Advice", "VIOLENCE"] + }, + ) + raise BlockingViolation("violation") + + request_data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hi"}], + "metadata": {}, + } + guardrail = BlockingGuardrail( + guardrail_name="blocking-test", + event_hook=GuardrailEventHooks.pre_call, + ) + + with self.assertRaises(BlockingViolation): + _run( + guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + cache=None, + data=request_data, + call_type="completion", + ) + ) + + slg_info = request_data["metadata"].get( + "standard_logging_guardrail_information" + ) + self.assertTrue( + slg_info, + "Guardrail must have recorded its information to request_data " + "BEFORE raising — otherwise the OTEL hook sees nothing", + ) + + # Now simulate the OTEL failure handler picking up this metadata + otel, _, exporter = _make_otel() + kwargs = _kwargs_with_guardrail( + entries=slg_info, + include_exception=True, + ) + start = datetime.now(timezone.utc) + end = start + timedelta(milliseconds=15) + otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end) + + spans = exporter.get_finished_spans() + guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME] + self.assertEqual(len(guardrail_spans), 1) + self.assertEqual( + _attr(guardrail_spans[0], "guardrail_status"), + "guardrail_intervened", + ) + self.assertEqual( + _attr(guardrail_spans[0], "guardrail_name"), + "blocking-test", + ) + # End-to-end: the violation_categories the guardrail passed through + # tracing_detail must arrive as a queryable span attribute. + categories = _attr(guardrail_spans[0], "guardrail_violation_categories") + self.assertIsNotNone(categories) + self.assertIn("Fiduciary Advice", str(categories)) + self.assertIn("VIOLENCE", str(categories)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_litellm/integrations/test_otel_team_attributes_matrix.py b/tests/test_litellm/integrations/test_otel_team_attributes_matrix.py new file mode 100644 index 00000000000..1ce55fa7a58 --- /dev/null +++ b/tests/test_litellm/integrations/test_otel_team_attributes_matrix.py @@ -0,0 +1,285 @@ +""" +Matrix test: team_id / team_alias must land on EVERY span of a proxy +request trace, for a representative set of endpoints x HTTP outcomes. + +Endpoints + - /v1/chat/completions (OpenAI-format LLM path) + - /v1/messages (Anthropic-format LLM path) + - /team/info (management/admin path) + +Outcomes + - 2xx success + - 3xx redirect (LLM endpoints never 3xx -> N/A; admin too) + - 4xx client error (auth / validation failure) + - 5xx server error (upstream / DB failure) + +Strategy + These assertions exercise the real OpenTelemetry callback the proxy + invokes for each path, with a SERVER parent span (as + ``user_api_key_auth`` creates) and an in-memory exporter. Each cell + drives the path, then asserts team attributes on every span that path + actually emits. + + - success path -> ``log_success_event`` -> litellm_request + + raw_gen_ai_request + guardrail child spans. + - failure path -> ``async_post_call_failure_hook`` -> Failed Proxy + Server Request exception child span. + + Admin endpoints do not run the LLM success callback, so their only + trace surface is the SERVER span (success) or the exception child span + (failure) -- the cells below assert exactly that. +""" + +import asyncio +import os +import sys +import unittest +from datetime import datetime +from unittest.mock import MagicMock + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.integrations.opentelemetry import ( + LITELLM_PROXY_REQUEST_SPAN_NAME, + OpenTelemetry, +) + +TEAM_ID = "team-123" +TEAM_ALIAS = "my-team" +TEAM_ID_ATTR = "metadata.user_api_key_team_id" +TEAM_ALIAS_ATTR = "metadata.user_api_key_team_alias" + + +def _make_otel(): + """OTel callback whose every span lands in an in-memory exporter.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + otel = OpenTelemetry() + otel.tracer = provider.get_tracer(__name__) + # raw_gen_ai_request sub-span is gated on message logging. + otel.message_logging = True + return otel, exporter + + +def _server_span(otel): + """Mirror the SERVER span user_api_key_auth opens per request.""" + return otel.create_litellm_proxy_request_started_span( + start_time=datetime.now(), headers={} + ) + + +def _slo(call_type, with_guardrail=False): + """standard_logging_object the proxy attaches, carrying team metadata.""" + md = { + "user_api_key_team_id": TEAM_ID, + "user_api_key_team_alias": TEAM_ALIAS, + } + slo = {"metadata": md, "call_type": call_type} + if with_guardrail: + slo["guardrail_information"] = [ + { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": "ok", + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + ] + return slo + + +def _success_kwargs(call_type, server_span, with_guardrail=True): + """kwargs the success callback receives for an LLM proxy request.""" + return { + "model": "gpt-4.1-mini", + "litellm_call_id": "call-abc", + "call_type": call_type, + "litellm_params": { + "metadata": { + "litellm_parent_otel_span": server_span, + "user_api_key_team_id": TEAM_ID, + "user_api_key_team_alias": TEAM_ALIAS, + } + }, + "standard_logging_object": _slo(call_type, with_guardrail=with_guardrail), + "messages": [{"role": "user", "content": "hi"}], + } + + +def _team_user_api_key_dict(server_span): + d = MagicMock() + d.parent_otel_span = server_span + d.team_id = TEAM_ID + d.team_alias = TEAM_ALIAS + return d + + +def _spans_by_name(exporter): + return {s.name: s for s in exporter.get_finished_spans()} + + +def _assert_team_attrs(span, where): + assert span.attributes.get(TEAM_ID_ATTR) == TEAM_ID, ( + f"{where}: missing/blank {TEAM_ID_ATTR} " + f"(got {span.attributes.get(TEAM_ID_ATTR)!r})" + ) + assert span.attributes.get(TEAM_ALIAS_ATTR) == TEAM_ALIAS, ( + f"{where}: missing/blank {TEAM_ALIAS_ATTR} " + f"(got {span.attributes.get(TEAM_ALIAS_ATTR)!r})" + ) + + +class _Boom(Exception): + """Upstream/DB style 5xx.""" + + status_code = 500 + + +class _ClientErr(Exception): + """Auth/validation style 4xx.""" + + status_code = 401 + + +# --------------------------------------------------------------------------- +# LLM success cells: litellm_request + raw_gen_ai_request + guardrail spans +# --------------------------------------------------------------------------- +class TestLLMSuccessCells(unittest.TestCase): + def _run_success(self, call_type): + otel, exporter = _make_otel() + server_span = _server_span(otel) + kwargs = _success_kwargs(call_type, server_span) + now = datetime.now() + otel.log_success_event(kwargs, {"id": "resp-1"}, now, now) + return _spans_by_name(exporter) + + def test_chat_completions_2xx(self): + spans = self._run_success("completion") + for name in ( + LITELLM_PROXY_REQUEST_SPAN_NAME, + "litellm_request", + "raw_gen_ai_request", + "guardrail", + ): + assert name in spans, f"chat/completions 2xx: missing span {name}" + _assert_team_attrs(spans[name], f"chat/completions 2xx [{name}]") + + def test_v1_messages_2xx(self): + spans = self._run_success("anthropic_messages") + for name in ( + LITELLM_PROXY_REQUEST_SPAN_NAME, + "litellm_request", + "raw_gen_ai_request", + "guardrail", + ): + assert name in spans, f"v1/messages 2xx: missing span {name}" + _assert_team_attrs(spans[name], f"v1/messages 2xx [{name}]") + + +# --------------------------------------------------------------------------- +# LLM failure cells: Failed Proxy Server Request exception child span +# --------------------------------------------------------------------------- +class TestLLMFailureCells(unittest.TestCase): + def _run_failure(self, exc): + """Drive the failure hook, then close the SERVER span (the proxy + closes it after the hook in real flow) so both the exception child + span and the SERVER root span are asserted.""" + otel, exporter = _make_otel() + server_span = _server_span(otel) + asyncio.run( + otel.async_post_call_failure_hook( + request_data={}, + original_exception=exc, + user_api_key_dict=_team_user_api_key_dict(server_span), + traceback_str="tb", + ) + ) + server_span.end() + return _spans_by_name(exporter) + + def _assert_all(self, spans, where): + for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME): + assert name in spans, f"{where}: missing span {name}" + _assert_team_attrs(spans[name], f"{where} [{name}]") + + def test_chat_completions_4xx(self): + self._assert_all( + self._run_failure(_ClientErr("bad key")), "chat/completions 4xx" + ) + + def test_chat_completions_5xx(self): + self._assert_all( + self._run_failure(_Boom("upstream blew up")), "chat/completions 5xx" + ) + + def test_v1_messages_4xx(self): + self._assert_all( + self._run_failure(_ClientErr("bad anthropic key")), "v1/messages 4xx" + ) + + def test_v1_messages_5xx(self): + self._assert_all( + self._run_failure(_Boom("anthropic upstream timeout")), "v1/messages 5xx" + ) + + +# --------------------------------------------------------------------------- +# Admin /team/info cells. +# 2xx: admin path never runs the LLM success callback -> its only trace +# surface is the SERVER span; no child spans are emitted. +# 3xx: management endpoints do not redirect -> N/A (documented, no run). +# 4xx/5xx: proxy_logging post_call_failure_hook -> exception child span. +# --------------------------------------------------------------------------- +class TestAdminTeamInfoCells(unittest.TestCase): + def _run_admin_failure(self, exc): + otel, exporter = _make_otel() + server_span = _server_span(otel) + asyncio.run( + otel.async_post_call_failure_hook( + request_data={}, + original_exception=exc, + user_api_key_dict=_team_user_api_key_dict(server_span), + traceback_str="tb", + ) + ) + server_span.end() + return _spans_by_name(exporter) + + def test_team_info_4xx(self): + spans = self._run_admin_failure(_ClientErr("team not found")) + for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME): + _assert_team_attrs(spans[name], f"/team/info 4xx [{name}]") + + def test_team_info_5xx(self): + spans = self._run_admin_failure(_Boom("db connection lost")) + for name in ("Failed Proxy Server Request", LITELLM_PROXY_REQUEST_SPAN_NAME): + _assert_team_attrs(spans[name], f"/team/info 5xx [{name}]") + + def test_team_info_2xx_only_server_span_no_orphan_children(self): + """Admin success path emits no LLM child spans; nothing to stamp + beyond the SERVER span. This pins that contract so a future + regression that starts emitting child spans here without team + attrs is caught.""" + otel, exporter = _make_otel() + server_span = _server_span(otel) + server_span.end() + spans = _spans_by_name(exporter) + assert set(spans) == { + LITELLM_PROXY_REQUEST_SPAN_NAME + }, f"/team/info 2xx: unexpected child spans {set(spans)}" + + def test_team_info_3xx_not_applicable(self): + """Management endpoints return JSON, never a 3xx redirect.""" + self.skipTest("/team/info has no 3xx redirect path (N/A)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_litellm/integrations/test_rubrik.py b/tests/test_litellm/integrations/test_rubrik.py new file mode 100644 index 00000000000..922d2fe8a15 --- /dev/null +++ b/tests/test_litellm/integrations/test_rubrik.py @@ -0,0 +1,1012 @@ +""" +Tests for the Rubrik LiteLLM plugin. + +Covers initialization, apply_guardrail tool blocking (all allowed, all blocked, +partial blocking, fail-open), batch logging, and Anthropic format handling. +""" + +import os +from typing import Any, Dict +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from litellm.integrations.custom_guardrail import ModifyResponseException +from litellm.integrations.rubrik import RubrikLogger +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + +from tests.test_litellm.integrations.rubrik_test_helpers import ( + make_inputs_with_tools, + make_tool_call_dict, +) + + +@pytest.fixture +def mock_env(): + """Set up environment variables for testing.""" + with patch.dict( + os.environ, + { + "RUBRIK_WEBHOOK_URL": "http://localhost:8080", + "RUBRIK_API_KEY": "test-api-key", + }, + ): + yield + + +@pytest.fixture +def handler(mock_env): + """Create a RubrikLogger instance for testing.""" + with patch("asyncio.create_task", Mock()): + return RubrikLogger() + + +# -- Initialization ----------------------------------------------------------- + + +class TestInitialization: + def test_init_success(self, mock_env): + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger() + assert ( + handler.tool_blocking_endpoint + == "http://localhost:8080/v1/after_completion/openai/v1" + ) + assert handler.logging_endpoint == "http://localhost:8080/v1/litellm/batch" + assert handler.key == "test-api-key" + assert isinstance(handler.tool_blocking_client, AsyncHTTPHandler) + + def test_init_with_constructor_params(self): + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(api_key="ctor-key", api_base="http://ctor-host:9090") + assert handler.key == "ctor-key" + assert ( + handler.tool_blocking_endpoint + == "http://ctor-host:9090/v1/after_completion/openai/v1" + ) + + def test_init_without_url(self): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="Rubrik webhook URL not configured"): + RubrikLogger() + + def test_init_without_api_key(self): + with patch.dict( + os.environ, {"RUBRIK_WEBHOOK_URL": "http://localhost:8080"}, clear=True + ): + with patch("asyncio.create_task", Mock()): + assert RubrikLogger().key is None + + def test_trailing_slash_removed(self): + with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://localhost:8080/"}): + with patch("asyncio.create_task", Mock()): + assert ( + RubrikLogger().tool_blocking_endpoint + == "http://localhost:8080/v1/after_completion/openai/v1" + ) + + def test_v1_suffix_stripped_as_substring_not_charset(self): + with patch("asyncio.create_task", Mock()): + with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host/v1"}): + assert ( + RubrikLogger().tool_blocking_endpoint + == "http://host/v1/after_completion/openai/v1" + ) + + with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host/v11"}): + assert ( + RubrikLogger().tool_blocking_endpoint + == "http://host/v11/v1/after_completion/openai/v1" + ) + + def test_sampling_rate_fractional(self): + with patch("asyncio.create_task", Mock()): + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "0.5"}, + ): + assert RubrikLogger().sampling_rate == 0.5 + + def test_sampling_rate_invalid_ignored(self): + with patch("asyncio.create_task", Mock()): + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "abc"}, + ): + assert RubrikLogger().sampling_rate == 1.0 + + def test_sampling_rate_clamped(self): + with patch("asyncio.create_task", Mock()): + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "2.0"}, + ): + assert RubrikLogger().sampling_rate == 1.0 + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_SAMPLING_RATE": "-0.5"}, + ): + assert RubrikLogger().sampling_rate == 0.0 + + def test_batch_size_invalid_ignored(self): + with patch("asyncio.create_task", Mock()): + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_BATCH_SIZE": "abc"}, + ): + # Should use default without crashing + assert isinstance(RubrikLogger().batch_size, int) + + def test_batch_size_valid(self): + with patch("asyncio.create_task", Mock()): + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://host", "RUBRIK_BATCH_SIZE": "256"}, + ): + assert RubrikLogger().batch_size == 256 + + def test_init_outside_event_loop_does_not_raise(self): + """Instantiation without a running event loop must not raise RuntimeError.""" + with patch.dict( + os.environ, + {"RUBRIK_WEBHOOK_URL": "http://localhost:8080", "RUBRIK_API_KEY": "k"}, + ): + # Do NOT patch asyncio.create_task — the real call should be + # guarded and fall back gracefully when there is no event loop. + handler = RubrikLogger() + assert handler.tool_blocking_endpoint.startswith("http://localhost:8080") + # Without a running loop at init, the periodic flush task should be + # deferred so batches still get drained once a log event arrives. + assert handler._flush_task is None + + @pytest.mark.asyncio + async def test_periodic_flush_task_started_lazily_on_first_log(self, mock_env): + """Loggers instantiated outside an event loop must still start the + periodic flush task on first use to drain low-traffic batches.""" + # Simulate sync-init by hiding the running loop from the constructor. + with patch( + "litellm.integrations.rubrik.asyncio.get_running_loop", + side_effect=RuntimeError("no running loop"), + ): + handler = RubrikLogger() + assert handler._flush_task is None + + kwargs = { + "standard_logging_object": { + "messages": [{"role": "user", "content": "hi"}], + "id": "litellm-id", + }, + "litellm_call_id": "litellm-id", + "litellm_params": {}, + } + with patch.object(handler, "_log_batch_to_rubrik", AsyncMock()): + await handler.async_log_success_event(kwargs, None, None, None) + + assert handler._flush_task is not None + handler._flush_task.cancel() + + def test_event_hook_defaults_to_post_call_when_none_passed(self, mock_env): + """`initialize_guardrail` always passes ``event_hook=litellm_params.mode`` + (which is ``None`` when the user omits ``mode``). The logger must coerce + a None ``event_hook`` to ``post_call`` rather than leaving it as None, + which would otherwise cause the guardrail to run on every event hook.""" + from litellm.types.guardrails import GuardrailEventHooks + + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(event_hook=None) + assert handler.event_hook == GuardrailEventHooks.post_call + + def test_explicit_event_hook_preserved(self, mock_env): + from litellm.types.guardrails import GuardrailEventHooks + + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(event_hook=GuardrailEventHooks.pre_call) + assert handler.event_hook == GuardrailEventHooks.pre_call + + def test_default_on_defaults_to_true_when_none_passed(self, mock_env): + """`initialize_guardrail` always passes ``default_on=litellm_params.default_on`` + (which is ``None`` when the user omits ``default_on``). The logger must + coerce a None ``default_on`` to True, otherwise ``should_run_guardrail`` + (which checks ``self.default_on is True``) silently skips the guardrail.""" + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(default_on=None) + assert handler.default_on is True + + def test_explicit_default_on_false_preserved(self, mock_env): + """A user explicitly setting ``default_on: false`` in their guardrail + config must NOT be silently overridden to True.""" + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(default_on=False) + assert handler.default_on is False + + def test_explicit_default_on_true_preserved(self, mock_env): + with patch("asyncio.create_task", Mock()): + handler = RubrikLogger(default_on=True) + assert handler.default_on is True + + def test_headers_with_api_key(self, handler): + assert handler._headers["Authorization"] == "Bearer test-api-key" + assert handler._headers["Content-Type"] == "application/json" + + def test_headers_without_api_key(self): + with patch.dict(os.environ, {"RUBRIK_WEBHOOK_URL": "http://host"}, clear=True): + with patch("asyncio.create_task", Mock()): + h = RubrikLogger() + assert "Authorization" not in h._headers + + +# -- Batch Logging ------------------------------------------------------------ + + +@pytest.mark.asyncio +class TestBatchLogging: + async def test_log_success_event_appends_to_queue(self, handler): + kwargs = { + "standard_logging_object": { + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + }, + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert len(handler.log_queue) == 1 + + async def test_log_failure_event_appends_to_queue(self, handler): + kwargs = { + "standard_logging_object": { + "messages": [{"role": "user", "content": "hi"}], + "response": "error", + }, + } + await handler.async_log_failure_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert len(handler.log_queue) == 1 + + async def test_log_success_event_sampling_skips(self, handler): + handler.sampling_rate = 0.0 + kwargs = { + "standard_logging_object": { + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + }, + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert len(handler.log_queue) == 0 + + async def test_flush_queue_sends_batch(self, handler): + handler.log_queue = [{"msg": "a"}, {"msg": "b"}] + mock_response = Mock() + mock_response.status_code = 200 + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = AsyncMock(return_value=mock_response) + await handler.flush_queue() + handler.async_httpx_client.post.assert_called_once() + assert len(handler.log_queue) == 0 + + async def test_flush_queue_preserves_events_added_during_send(self, handler): + handler.log_queue = [{"msg": "a"}, {"msg": "b"}] + + async def mock_post(*_args, **_kwargs): + handler.log_queue.append({"msg": "c"}) + mock_response = Mock() + mock_response.raise_for_status = Mock() + return mock_response + + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = mock_post + + await handler.flush_queue() + + assert handler.log_queue == [{"msg": "c"}] + + async def test_async_send_batch_does_not_drain_events(self, handler): + handler.log_queue = [{"msg": "a"}, {"msg": "b"}] + + async def mock_post(*_args, **_kwargs): + handler.log_queue.append({"msg": "c"}) + mock_response = Mock() + mock_response.raise_for_status = Mock() + return mock_response + + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = mock_post + + await handler.async_send_batch() + + assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}, {"msg": "c"}] + + async def test_log_batch_error_does_not_crash_and_preserves_events(self, handler): + """A failed batch send must not crash the caller AND must preserve the + original events in the queue so they can be retried on the next flush. + Previously the events were silently dropped on HTTP 5xx / network errors. + """ + handler.log_queue = [{"msg": "a"}] + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_response.raise_for_status = Mock( + side_effect=httpx.HTTPStatusError( + "err", request=Mock(), response=mock_response + ) + ) + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = AsyncMock(return_value=mock_response) + await handler.flush_queue() + assert handler.log_queue == [{"msg": "a"}] + + async def test_log_batch_network_error_preserves_events(self, handler): + """Network/timeout errors must also preserve the in-flight events.""" + handler.log_queue = [{"msg": "a"}, {"msg": "b"}] + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = AsyncMock( + side_effect=httpx.TimeoutException("timeout") + ) + await handler.flush_queue() + assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}] + + async def test_enqueue_drops_oldest_when_queue_exceeds_max_size(self, handler): + """A sustained Rubrik webhook outage must not let the in-memory retry + queue grow without bound. Once max_queue_size is exceeded, the oldest + events are dropped to make room for new ones.""" + handler.max_queue_size = 3 + handler.batch_size = 10**6 # disable size-triggered flush + handler.flush_queue = AsyncMock() + for i in range(5): + await handler._enqueue_log_event( + kwargs={ + "standard_logging_object": { + "messages": [{"role": "user", "content": f"hi-{i}"}], + "response": "hello", + }, + }, + event_type="success", + ) + assert len(handler.log_queue) == 3 + retained = [item["messages"][0]["content"] for item in handler.log_queue] + assert retained == ["hi-2", "hi-3", "hi-4"] + + async def test_log_batch_failure_preserves_events_added_during_send(self, handler): + """Failure must preserve both the snapshot AND events appended mid-flush.""" + handler.log_queue = [{"msg": "a"}, {"msg": "b"}] + + async def mock_post(*_args, **_kwargs): + handler.log_queue.append({"msg": "c"}) + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "boom" + mock_response.raise_for_status = Mock( + side_effect=httpx.HTTPStatusError( + "err", request=Mock(), response=mock_response + ) + ) + return mock_response + + handler.async_httpx_client = AsyncMock() + handler.async_httpx_client.post = mock_post + + await handler.flush_queue() + assert handler.log_queue == [{"msg": "a"}, {"msg": "b"}, {"msg": "c"}] + + async def test_system_prompt_prepended_to_messages(self, handler): + kwargs = { + "standard_logging_object": { + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + }, + "system": "You are a helpful assistant.", + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert len(handler.log_queue) == 1 + msgs = handler.log_queue[0]["messages"] + assert msgs[0]["role"] == "system" + assert msgs[0]["content"] == "You are a helpful assistant." + + async def test_system_prompt_with_dict_messages(self, handler): + kwargs = { + "standard_logging_object": { + "messages": {"role": "user", "content": "hi"}, + "response": "hello", + }, + "system": "Be concise.", + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert len(handler.log_queue) == 1 + msgs = handler.log_queue[0]["messages"] + assert isinstance(msgs, list) + assert msgs[0]["role"] == "system" + assert msgs[1] == {"role": "user", "content": "hi"} + + async def test_anthropic_id_normalization(self, handler): + kwargs = { + "standard_logging_object": { + "id": "chatcmpl-original", + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + }, + "litellm_params": { + "proxy_server_request": { + "url": "http://proxy/v1/messages", + }, + }, + "litellm_call_id": "litellm-call-123", + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert handler.log_queue[0]["id"] == "litellm-call-123" + + async def test_non_anthropic_id_unchanged(self, handler): + kwargs = { + "standard_logging_object": { + "id": "chatcmpl-original", + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + }, + "litellm_params": { + "proxy_server_request": { + "url": "http://proxy/v1/chat/completions", + }, + }, + "litellm_call_id": "litellm-call-123", + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + assert handler.log_queue[0]["id"] == "chatcmpl-original" + + async def test_payload_deep_copied_not_mutated(self, handler): + """Verify the shared standard_logging_object is not mutated.""" + original_payload = { + "id": "original-id", + "messages": [{"role": "user", "content": "hi"}], + "response": "hello", + } + kwargs = { + "standard_logging_object": original_payload, + "system": "System prompt.", + } + await handler.async_log_success_event( + kwargs=kwargs, response_obj=None, start_time=None, end_time=None + ) + # Original payload should NOT have been mutated + assert original_payload["id"] == "original-id" + assert len(original_payload["messages"]) == 1 + + +# -- Tool Blocking (apply_guardrail) ------------------------------------------ + + +def _mock_service_response(response_json): + """Create a mock tool blocking client that returns the given JSON.""" + + async def mock_post(*_args, **kwargs): + mock_resp = Mock() + mock_resp.json.return_value = response_json + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + return mock_client + + +def _echo_service(): + """Create a mock tool blocking client that echoes the payload back.""" + + async def mock_post(*_args, **kwargs): + mock_resp = Mock() + mock_resp.json.return_value = kwargs.get("json", {}).get("response", {}) + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + return mock_client + + +@pytest.mark.asyncio +class TestApplyGuardrail: + async def test_skips_requests(self, handler): + inputs = make_inputs_with_tools([make_tool_call_dict("call_1", "test_tool")]) + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="request" + ) + assert result is inputs + + async def test_no_tool_calls(self, handler): + from litellm.types.utils import GenericGuardrailAPIInputs + + inputs = GenericGuardrailAPIInputs(texts=["hello"]) + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + async def test_all_allowed(self, handler): + tc1 = make_tool_call_dict("call_1", "get_weather") + tc2 = make_tool_call_dict("call_2", "get_time") + inputs = make_inputs_with_tools([tc1, tc2]) + + handler.tool_blocking_client = _echo_service() + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + async def test_all_blocked(self, handler): + tc1 = make_tool_call_dict("call_1", "delete_table") + tc2 = make_tool_call_dict("call_2", "drop_database") + inputs = make_inputs_with_tools([tc1, tc2]) + + handler.tool_blocking_client = _mock_service_response( + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "Tool blocked by policy", + "tool_calls": [], + } + } + ], + } + ) + + with pytest.raises(ModifyResponseException) as exc_info: + await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert "Tool blocked by policy" in exc_info.value.message + + async def test_partial_blocking(self, handler): + tc_blocked = make_tool_call_dict("call_A", "blocked_tool") + tc_allowed = make_tool_call_dict("call_B", "allowed_tool") + inputs = make_inputs_with_tools([tc_blocked, tc_allowed]) + + async def mock_post(*_args, **kwargs): + payload = kwargs.get("json", {}).get("response", {}) + all_tcs = payload["choices"][0]["message"]["tool_calls"] + allowed = [tc for tc in all_tcs if tc.get("id") == "call_B"] + mock_resp = Mock() + mock_resp.json.return_value = { + "choices": [ + { + "message": { + "role": "assistant", + "content": "blocked", + "tool_calls": allowed, + } + } + ], + } + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + handler.tool_blocking_client = mock_client + + with pytest.raises(ModifyResponseException): + await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + + async def test_service_failure_fail_open(self, handler): + tc1 = make_tool_call_dict("call_1", "test_tool") + inputs = make_inputs_with_tools([tc1]) + + mock_client = AsyncMock() + mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + handler.tool_blocking_client = mock_client + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + async def test_service_empty_choices_fail_open(self, handler): + tc1 = make_tool_call_dict("call_1", "test_tool") + inputs = make_inputs_with_tools([tc1]) + + handler.tool_blocking_client = _mock_service_response({"choices": []}) + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + async def test_blocking_service_payload_format(self, handler): + tc1 = make_tool_call_dict("call_1", "get_weather", '{"location": "SF"}') + tc2 = make_tool_call_dict("call_2", "send_email", '{"to": "user@example.com"}') + inputs = make_inputs_with_tools([tc1, tc2]) + + captured_payload: Dict[str, Any] = {} + + async def mock_post(*_args, **kwargs): + captured_payload.update(kwargs.get("json", {})) + mock_resp = Mock() + mock_resp.json.return_value = captured_payload.get("response", {}) + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + handler.tool_blocking_client = mock_client + + await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + + # Verify envelope structure + assert "request" in captured_payload + assert "response" in captured_payload + + response_data = captured_payload["response"] + message = response_data["choices"][0]["message"] + assert message["role"] == "assistant" + assert len(message["tool_calls"]) == 2 + assert message["tool_calls"][0]["id"] == "call_1" + assert message["tool_calls"][0]["function"]["name"] == "get_weather" + assert message["tool_calls"][1]["id"] == "call_2" + assert message["tool_calls"][1]["function"]["name"] == "send_email" + + async def test_request_data_included_in_envelope(self, handler): + tc = make_tool_call_dict("call_1", "test_tool") + inputs = make_inputs_with_tools([tc]) + + captured_payload: Dict[str, Any] = {} + + async def mock_post(*_args, **kwargs): + captured_payload.update(kwargs.get("json", {})) + mock_resp = Mock() + mock_resp.json.return_value = captured_payload.get("response", {}) + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + handler.tool_blocking_client = mock_client + + logging_obj = Mock() + logging_obj.model_call_details = { + "messages": [{"role": "user", "content": "hi"}], + "model": "gpt-4", + "litellm_params": { + "proxy_server_request": {"url": "/chat/completions"}, + }, + } + + await handler.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + logging_obj=logging_obj, + ) + + req = captured_payload["request"] + assert req["model"] == "gpt-4" + assert req["messages"] == [{"role": "user", "content": "hi"}] + + async def test_proxy_server_request_headers_stripped(self, handler): + tc = make_tool_call_dict("call_1", "test_tool") + inputs = make_inputs_with_tools([tc]) + + captured_payload: Dict[str, Any] = {} + + async def mock_post(*_args, **kwargs): + captured_payload.update(kwargs.get("json", {})) + mock_resp = Mock() + mock_resp.json.return_value = captured_payload.get("response", {}) + mock_resp.raise_for_status = Mock() + return mock_resp + + mock_client = AsyncMock() + mock_client.post = mock_post + handler.tool_blocking_client = mock_client + + logging_obj = Mock() + logging_obj.model_call_details = { + "messages": [{"role": "user", "content": "hi"}], + "model": "gpt-4", + "litellm_params": { + "proxy_server_request": { + "url": "/chat/completions", + "method": "POST", + "headers": { + "authorization": "Bearer sk-litellm-secret", + "cookie": "session=abc", + "x-api-key": "leaked-key", + }, + "body": {"api_key": "sk-upstream-secret"}, + }, + }, + } + + await handler.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + logging_obj=logging_obj, + ) + + forwarded = captured_payload["request"]["proxy_server_request"] + assert forwarded == {"url": "/chat/completions", "method": "POST"} + + +# -- Anthropic format ---------------------------------------------------------- + + +@pytest.mark.asyncio +class TestApplyGuardrailAnthropicFormat: + """Verify blocking works correctly regardless of original provider format. + + The framework converts Anthropic tool_use blocks to OpenAI-format + tool_calls before calling apply_guardrail. + """ + + async def test_single_tool_allowed(self, handler): + tc = make_tool_call_dict( + "toolu_123", "get_weather", '{"location": "Portland, OR"}' + ) + inputs = make_inputs_with_tools([tc], texts=["I'll check the weather."]) + + handler.tool_blocking_client = _echo_service() + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + async def test_single_tool_blocked(self, handler): + tc = make_tool_call_dict("toolu_123", "dangerous_tool", '{"arg": "value"}') + inputs = make_inputs_with_tools([tc]) + + handler.tool_blocking_client = _mock_service_response( + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "blocked", + "tool_calls": [], + } + } + ], + } + ) + + with pytest.raises(ModifyResponseException): + await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + + async def test_text_only_response_no_blocking(self, handler): + from litellm.types.utils import GenericGuardrailAPIInputs + + inputs = GenericGuardrailAPIInputs(texts=["Hello! I'm Claude."]) + + mock_client = AsyncMock() + mock_client.post = AsyncMock() + handler.tool_blocking_client = mock_client + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + + assert result is inputs + mock_client.post.assert_not_called() + + async def test_service_failure_preserves_tools(self, handler): + tc = make_tool_call_dict("toolu_123", "get_weather", '{"location": "SF"}') + inputs = make_inputs_with_tools([tc]) + + mock_client = AsyncMock() + mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + handler.tool_blocking_client = mock_client + + result = await handler.apply_guardrail( + inputs=inputs, request_data={}, input_type="response" + ) + assert result is inputs + + +# -- Normalize tool calls ------------------------------------------------------ + + +class TestNormalizeToolCalls: + def test_dict_input(self): + tc = make_tool_call_dict("call_1", "test", '{"a": 1}') + result = RubrikLogger._normalize_tool_calls([tc]) + assert len(result) == 1 + assert result[0].id == "call_1" + assert result[0].function.name == "test" + assert result[0].function.arguments == '{"a": 1}' + + def test_typed_object_input(self): + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tc = ChatCompletionMessageToolCall( + id="call_2", + type="function", + function=Function(name="fn", arguments="{}"), + ) + result = RubrikLogger._normalize_tool_calls([tc]) + assert len(result) == 1 + assert result[0].id == "call_2" + assert result[0].function.name == "fn" + + def test_unsupported_type_raises(self): + with pytest.raises(TypeError, match="Cannot normalize"): + RubrikLogger._normalize_tool_calls(["not_a_tool_call"]) + + +# -- Extract blocked tools ----------------------------------------------------- + + +class TestExtractBlockedTools: + def test_all_allowed_returns_none(self): + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tc = ChatCompletionMessageToolCall( + id="call_1", type="function", function=Function(name="fn", arguments="{}") + ) + service_resp = { + "choices": [ + { + "message": { + "tool_calls": [{"id": "call_1"}], + "content": "", + } + } + ] + } + result = RubrikLogger._extract_blocked_tools(service_resp, [tc]) + assert result is None + + def test_some_blocked_returns_explanation(self): + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tc1 = ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function(name="fn1", arguments="{}"), + ) + tc2 = ChatCompletionMessageToolCall( + id="call_2", + type="function", + function=Function(name="fn2", arguments="{}"), + ) + service_resp = { + "choices": [ + { + "message": { + "tool_calls": [{"id": "call_1"}], + "content": "blocked fn2", + } + } + ] + } + result = RubrikLogger._extract_blocked_tools(service_resp, [tc1, tc2]) + assert result is not None + assert "blocked fn2" in result + + def test_empty_choices_raises(self): + with pytest.raises(Exception, match="empty response"): + RubrikLogger._extract_blocked_tools({"choices": []}, []) + + def test_null_tool_calls_treated_as_all_blocked(self): + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tc = ChatCompletionMessageToolCall( + id="call_1", type="function", function=Function(name="fn", arguments="{}") + ) + service_resp = { + "choices": [ + { + "message": { + "tool_calls": None, + "content": "blocked everything", + } + } + ] + } + result = RubrikLogger._extract_blocked_tools(service_resp, [tc]) + assert result is not None + assert "blocked everything" in result + + def test_duplicate_ids_block_when_only_one_returned(self): + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tc1 = ChatCompletionMessageToolCall( + id="call_dup", + type="function", + function=Function(name="fn", arguments="{}"), + ) + tc2 = ChatCompletionMessageToolCall( + id="call_dup", + type="function", + function=Function(name="fn", arguments="{}"), + ) + service_resp = { + "choices": [ + { + "message": { + "tool_calls": [{"id": "call_dup"}], + "content": "blocked duplicate", + } + } + ] + } + result = RubrikLogger._extract_blocked_tools(service_resp, [tc1, tc2]) + assert result is not None + assert "blocked duplicate" in result + + +# -- Sanitize proxy server request ------------------------------------------- + + +class TestSanitizeProxyServerRequest: + def test_drops_headers_and_body(self): + proxy_request = { + "url": "/chat/completions", + "method": "POST", + "headers": { + "authorization": "Bearer sk-litellm-secret", + "cookie": "session=abc", + "content-type": "application/json", + }, + "body": {"api_key": "sk-upstream-secret", "model": "gpt-4"}, + } + result = RubrikLogger._sanitize_proxy_server_request(proxy_request) + assert result == {"url": "/chat/completions", "method": "POST"} + + def test_none_passthrough(self): + assert RubrikLogger._sanitize_proxy_server_request(None) is None + + def test_non_dict_passthrough(self): + assert RubrikLogger._sanitize_proxy_server_request("not a dict") == "not a dict" + + def test_partial_dict(self): + result = RubrikLogger._sanitize_proxy_server_request({"url": "/v1/messages"}) + assert result == {"url": "/v1/messages"} + + +# -- Resolve model ------------------------------------------------------------- + + +class TestResolveModel: + def test_model_from_response(self): + from unittest.mock import Mock + + response = Mock() + response.model = "gpt-4" + result = RubrikLogger._resolve_model({"response": response}, {}) + assert result == "gpt-4" + + def test_model_from_call_details(self): + result = RubrikLogger._resolve_model({}, {"model": "claude-3"}) + assert result == "claude-3" + + def test_fallback_to_unknown(self): + result = RubrikLogger._resolve_model({}, {}) + assert result == "unknown" + + def test_empty_model_on_response_returns_unknown(self): + from unittest.mock import Mock + + response = Mock() + response.model = "" + result = RubrikLogger._resolve_model( + {"response": response}, {"model": "fallback"} + ) + assert result == "unknown" diff --git a/tests/test_litellm/interactions/test_agents_http_handler.py b/tests/test_litellm/interactions/test_agents_http_handler.py new file mode 100644 index 00000000000..6947503e0bb --- /dev/null +++ b/tests/test_litellm/interactions/test_agents_http_handler.py @@ -0,0 +1,587 @@ +""" +Unit tests for litellm/interactions/agents/http_handler.py + +These tests exercise both the sync and async branches of every CRUD method +on AgentsHTTPHandler using stub httpx clients, plus the _is_async dispatch +branches, error mapping, and pre/post logging hooks. + +No real HTTP traffic is made. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.interactions.agents.http_handler import ( + AgentsHTTPHandler, + agents_http_handler, +) +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig +from litellm.llms.gemini.common_utils import GeminiError +from litellm.types.agents import ( + AgentCreateResponse, + AgentDeleteResult, + AgentListResponse, + AgentVersionsResponse, +) +from litellm.types.router import GenericLiteLLMParams + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_response(status_code: int = 200, json_data=None, text: str = "") -> MagicMock: + """Build a stub httpx-like response.""" + response = MagicMock() + response.status_code = status_code + response.text = text or (str(json_data) if json_data is not None else "") + response.headers = {} + if json_data is not None: + response.json.return_value = json_data + else: + response.json.return_value = {} + return response + + +def _make_sync_client() -> MagicMock: + client = MagicMock(spec=HTTPHandler) + return client + + +def _make_async_client() -> MagicMock: + client = MagicMock(spec=AsyncHTTPHandler) + client.post = AsyncMock() + client.get = AsyncMock() + client.delete = AsyncMock() + return client + + +def _make_logging_obj() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def handler() -> AgentsHTTPHandler: + return AgentsHTTPHandler() + + +@pytest.fixture +def config() -> GeminiAgentsConfig: + return GeminiAgentsConfig() + + +@pytest.fixture +def litellm_params() -> GenericLiteLLMParams: + return GenericLiteLLMParams(api_key="AIza-test") + + +# --------------------------------------------------------------------------- +# Module-level singleton sanity check +# --------------------------------------------------------------------------- + + +def test_module_singleton_is_agents_http_handler_instance(): + assert isinstance(agents_http_handler, AgentsHTTPHandler) + + +# --------------------------------------------------------------------------- +# CREATE +# --------------------------------------------------------------------------- + + +class TestCreateAgent: + def test_sync_returns_parsed_create_response(self, handler, config, litellm_params): + client = _make_sync_client() + client.post.return_value = _make_response( + 200, json_data={"id": "agent-x", "base_agent": "gemini-2.5-flash"} + ) + logging_obj = _make_logging_obj() + + result = handler.create_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=logging_obj, + extra_headers={"X-Test": "1"}, + extra_body={"foo": "bar"}, + client=client, + ) + + assert isinstance(result, AgentCreateResponse) + assert result.id == "agent-x" + client.post.assert_called_once() + kwargs = client.post.call_args.kwargs + assert kwargs["url"].endswith("/v1beta/agents") + assert kwargs["json"]["name"] == "agent-x" + assert kwargs["json"]["foo"] == "bar" + assert kwargs["headers"]["X-Test"] == "1" + logging_obj.pre_call.assert_called_once() + logging_obj.post_call.assert_called_once() + + def test_sync_dispatches_to_async_when_is_async( + self, handler, config, litellm_params + ): + client = _make_sync_client() + + result = handler.create_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + _is_async=True, + ) + + import asyncio + + assert asyncio.iscoroutine(result) + result.close() + + def test_sync_maps_http_error_via_config(self, handler, config, litellm_params): + client = _make_sync_client() + bad = _make_response(404, text="not found") + client.post.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + handler.create_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + @pytest.mark.asyncio + async def test_async_returns_parsed_create_response( + self, handler, config, litellm_params + ): + client = _make_async_client() + client.post.return_value = _make_response( + 200, json_data={"id": "agent-y", "base_agent": "gemini-2.5-flash"} + ) + + result = await handler.async_create_agent( + agents_api_config=config, + name="agent-y", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + extra_body={"baz": "qux"}, + client=client, + ) + + assert isinstance(result, AgentCreateResponse) + assert result.id == "agent-y" + client.post.assert_awaited_once() + + @pytest.mark.asyncio + async def test_async_maps_http_error_via_config( + self, handler, config, litellm_params + ): + client = _make_async_client() + bad = _make_response(500, text="server error") + client.post.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + await handler.async_create_agent( + agents_api_config=config, + name="agent-y", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + +# --------------------------------------------------------------------------- +# LIST +# --------------------------------------------------------------------------- + + +class TestListAgents: + def test_sync_returns_list_response(self, handler, config, litellm_params): + client = _make_sync_client() + client.get.return_value = _make_response( + 200, + json_data={ + "agents": [{"id": "a-1"}, {"id": "a-2"}], + "nextPageToken": "tok", + }, + ) + + result = handler.list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentListResponse) + assert len(result.agents) == 2 + assert result.next_page_token == "tok" + client.get.assert_called_once() + + def test_sync_dispatches_to_async_when_is_async( + self, handler, config, litellm_params + ): + client = _make_sync_client() + + result = handler.list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + _is_async=True, + ) + + import asyncio + + assert asyncio.iscoroutine(result) + result.close() + + def test_sync_maps_http_error_via_config(self, handler, config, litellm_params): + client = _make_sync_client() + bad = _make_response(403, text="forbidden") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + handler.list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + @pytest.mark.asyncio + async def test_async_returns_list_response(self, handler, config, litellm_params): + client = _make_async_client() + client.get.return_value = _make_response( + 200, json_data={"agents": [{"id": "a-1"}]} + ) + + result = await handler.async_list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentListResponse) + assert len(result.agents) == 1 + client.get.assert_awaited_once() + + @pytest.mark.asyncio + async def test_async_maps_http_error_via_config( + self, handler, config, litellm_params + ): + client = _make_async_client() + bad = _make_response(429, text="rate limited") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + await handler.async_list_agents( + agents_api_config=config, + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + +# --------------------------------------------------------------------------- +# GET +# --------------------------------------------------------------------------- + + +class TestGetAgent: + def test_sync_returns_get_response(self, handler, config, litellm_params): + client = _make_sync_client() + client.get.return_value = _make_response(200, json_data={"id": "agent-x"}) + + result = handler.get_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentCreateResponse) + assert result.id == "agent-x" + kwargs = client.get.call_args.kwargs + assert kwargs["url"].endswith("/v1beta/agents/agent-x") + + def test_sync_dispatches_to_async_when_is_async( + self, handler, config, litellm_params + ): + result = handler.get_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=_make_sync_client(), + _is_async=True, + ) + import asyncio + + assert asyncio.iscoroutine(result) + result.close() + + def test_sync_maps_http_error_via_config(self, handler, config, litellm_params): + client = _make_sync_client() + bad = _make_response(404, text="not found") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + handler.get_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + @pytest.mark.asyncio + async def test_async_returns_get_response(self, handler, config, litellm_params): + client = _make_async_client() + client.get.return_value = _make_response(200, json_data={"id": "agent-y"}) + + result = await handler.async_get_agent( + agents_api_config=config, + name="agent-y", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentCreateResponse) + assert result.id == "agent-y" + + @pytest.mark.asyncio + async def test_async_maps_http_error_via_config( + self, handler, config, litellm_params + ): + client = _make_async_client() + bad = _make_response(404, text="not found") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + await handler.async_get_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + +# --------------------------------------------------------------------------- +# DELETE +# --------------------------------------------------------------------------- + + +class TestDeleteAgent: + def test_sync_returns_delete_result(self, handler, config, litellm_params): + client = _make_sync_client() + client.delete.return_value = _make_response(200, json_data={}) + + result = handler.delete_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentDeleteResult) + assert result.name == "agent-x" + assert result.deleted is True + kwargs = client.delete.call_args.kwargs + assert kwargs["url"].endswith("/v1beta/agents/agent-x") + + def test_sync_dispatches_to_async_when_is_async( + self, handler, config, litellm_params + ): + result = handler.delete_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=_make_sync_client(), + _is_async=True, + ) + import asyncio + + assert asyncio.iscoroutine(result) + result.close() + + def test_sync_maps_http_error_via_config(self, handler, config, litellm_params): + client = _make_sync_client() + bad = _make_response(403, text="forbidden") + client.delete.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + handler.delete_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + @pytest.mark.asyncio + async def test_async_returns_delete_result(self, handler, config, litellm_params): + client = _make_async_client() + client.delete.return_value = _make_response(200, json_data={}) + + result = await handler.async_delete_agent( + agents_api_config=config, + name="agent-y", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentDeleteResult) + assert result.name == "agent-y" + assert result.deleted is True + + @pytest.mark.asyncio + async def test_async_maps_http_error_via_config( + self, handler, config, litellm_params + ): + client = _make_async_client() + bad = _make_response(500, text="server error") + client.delete.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + await handler.async_delete_agent( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + +# --------------------------------------------------------------------------- +# LIST VERSIONS +# --------------------------------------------------------------------------- + + +class TestListAgentVersions: + def test_sync_returns_versions_response(self, handler, config, litellm_params): + client = _make_sync_client() + client.get.return_value = _make_response( + 200, + json_data={ + "agentVersions": [ + {"agent": "agent-x", "name": "agents/agent-x/versions/v1"} + ], + "nextPageToken": "tok", + }, + ) + + result = handler.list_agent_versions( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentVersionsResponse) + assert len(result.agent_versions) == 1 + assert result.next_page_token == "tok" + kwargs = client.get.call_args.kwargs + assert kwargs["url"].endswith("/v1beta/agents/agent-x/versions") + + def test_sync_dispatches_to_async_when_is_async( + self, handler, config, litellm_params + ): + result = handler.list_agent_versions( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=_make_sync_client(), + _is_async=True, + ) + import asyncio + + assert asyncio.iscoroutine(result) + result.close() + + def test_sync_maps_http_error_via_config(self, handler, config, litellm_params): + client = _make_sync_client() + bad = _make_response(404, text="not found") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + handler.list_agent_versions( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + @pytest.mark.asyncio + async def test_async_returns_versions_response( + self, handler, config, litellm_params + ): + client = _make_async_client() + client.get.return_value = _make_response(200, json_data={"agentVersions": []}) + + result = await handler.async_list_agent_versions( + agents_api_config=config, + name="agent-y", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) + + assert isinstance(result, AgentVersionsResponse) + assert result.agent_versions == [] + + @pytest.mark.asyncio + async def test_async_maps_http_error_via_config( + self, handler, config, litellm_params + ): + client = _make_async_client() + bad = _make_response(500, text="server error") + client.get.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=bad + ) + + with pytest.raises(GeminiError): + await handler.async_list_agent_versions( + agents_api_config=config, + name="agent-x", + litellm_params=litellm_params, + logging_obj=_make_logging_obj(), + client=client, + ) diff --git a/tests/test_litellm/interactions/test_agents_main_and_utils.py b/tests/test_litellm/interactions/test_agents_main_and_utils.py new file mode 100644 index 00000000000..7c0183d20c6 --- /dev/null +++ b/tests/test_litellm/interactions/test_agents_main_and_utils.py @@ -0,0 +1,354 @@ +""" +Unit tests for litellm/interactions/agents/utils.py and main.py +focused on the managed agents SDK surface added in the +"Gemini managed agents support" PR. + +The tests mock the underlying HTTP handler so they cover the public +sync + async create/list/get/delete/list_versions entry points and the +small helper utilities without touching the network. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../..")) + +import litellm +from litellm.interactions.agents import ( + acreate, + adelete, + aget, + alist, + alist_versions, + create, + delete, + get, + list as list_agents, + list_versions, +) +from litellm.interactions.agents.main import ( + _get_agents_api_config, + _make_logging_obj, +) +from litellm.interactions.agents.utils import get_provider_agents_api_config +from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig +from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig + + +_HANDLER_PATH = "litellm.interactions.agents.main.agents_http_handler" + + +# --------------------------------------------------------------------------- +# utils.get_provider_agents_api_config +# --------------------------------------------------------------------------- + + +class TestGetProviderAgentsApiConfig: + def test_returns_gemini_config_for_gemini(self): + cfg = get_provider_agents_api_config("gemini") + assert isinstance(cfg, GeminiAgentsConfig) + assert isinstance(cfg, BaseAgentsAPIConfig) + + @pytest.mark.parametrize( + "provider", ["openai", "anthropic", "bedrock", "vertex_ai", "unknown"] + ) + def test_returns_none_for_non_gemini(self, provider): + assert get_provider_agents_api_config(provider) is None + + def test_returns_none_for_none(self): + assert get_provider_agents_api_config(None) is None + + +# --------------------------------------------------------------------------- +# main._get_agents_api_config +# --------------------------------------------------------------------------- + + +class TestGetAgentsApiConfig: + def test_returns_config_for_gemini(self): + cfg = _get_agents_api_config("gemini") + assert isinstance(cfg, GeminiAgentsConfig) + + def test_raises_bad_request_for_unsupported_provider(self): + with pytest.raises(litellm.BadRequestError) as excinfo: + _get_agents_api_config("openai") + assert "does not have a native" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# main._make_logging_obj +# --------------------------------------------------------------------------- + + +class TestMakeLoggingObj: + def test_calls_update_from_kwargs_and_returns_same_obj(self): + logging_obj = MagicMock() + kwargs = {"litellm_logging_obj": logging_obj, "litellm_call_id": "abc-123"} + + returned = _make_logging_obj( + kwargs=kwargs, + model="my-agent", + custom_llm_provider="gemini", + call_type="create_agent", + optional_params={"foo": "bar"}, + ) + + assert returned is logging_obj + logging_obj.update_from_kwargs.assert_called_once() + kwargs_call = logging_obj.update_from_kwargs.call_args.kwargs + assert kwargs_call["model"] == "my-agent" + assert kwargs_call["optional_params"] == {"foo": "bar"} + assert kwargs_call["custom_llm_provider"] == "gemini" + assert kwargs_call["litellm_params"]["litellm_call_id"] == "abc-123" + + +# --------------------------------------------------------------------------- +# Sync entry points: create / list / get / delete / list_versions +# --------------------------------------------------------------------------- + + +def _stub_handler(return_value): + """Build a stub AgentsHTTPHandler whose CRUD methods return *return_value*.""" + handler = MagicMock() + handler.create_agent.return_value = return_value + handler.list_agents.return_value = return_value + handler.get_agent.return_value = return_value + handler.delete_agent.return_value = return_value + handler.list_agent_versions.return_value = return_value + return handler + + +class TestSyncEntryPoints: + def test_create_passes_args_to_handler(self): + sentinel = MagicMock(name="create_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + response = create( + name="waverunner", + base_agent="gemini-2.5-flash", + instructions="be helpful", + base_environment={"type": "remote"}, + custom_llm_provider="gemini", + api_key="AIza-test", + extra_headers={"X-Test": "1"}, + extra_body={"foo": "bar"}, + ) + + assert response is sentinel + handler.create_agent.assert_called_once() + kw = handler.create_agent.call_args.kwargs + assert kw["name"] == "waverunner" + assert kw["_is_async"] is False + assert kw["extra_headers"] == {"X-Test": "1"} + assert kw["extra_body"] == {"foo": "bar"} + assert isinstance(kw["agents_api_config"], GeminiAgentsConfig) + + def test_create_defaults_custom_llm_provider_to_gemini(self): + sentinel = MagicMock(name="create_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + create(name="agent-x", api_key="AIza") + assert handler.create_agent.call_args.kwargs["_is_async"] is False + cfg = handler.create_agent.call_args.kwargs["agents_api_config"] + assert isinstance(cfg, GeminiAgentsConfig) + + def test_create_raises_for_unsupported_provider(self): + with pytest.raises(litellm.exceptions.BadRequestError): + create(name="agent-x", custom_llm_provider="openai", api_key="sk-x") + + def test_list_passes_args_to_handler(self): + sentinel = MagicMock(name="list_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + response = list_agents(custom_llm_provider="gemini", api_key="AIza") + assert response is sentinel + handler.list_agents.assert_called_once() + assert handler.list_agents.call_args.kwargs["_is_async"] is False + + def test_get_passes_args_to_handler(self): + sentinel = MagicMock(name="get_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + response = get(name="waverunner", api_key="AIza") + assert response is sentinel + kw = handler.get_agent.call_args.kwargs + assert kw["name"] == "waverunner" + assert kw["_is_async"] is False + + def test_delete_passes_args_to_handler(self): + sentinel = MagicMock(name="delete_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + response = delete(name="waverunner", api_key="AIza") + assert response is sentinel + kw = handler.delete_agent.call_args.kwargs + assert kw["name"] == "waverunner" + assert kw["_is_async"] is False + + def test_list_versions_passes_args_to_handler(self): + sentinel = MagicMock(name="versions_response") + with patch(_HANDLER_PATH, _stub_handler(sentinel)) as handler: + response = list_versions(name="waverunner", api_key="AIza") + assert response is sentinel + kw = handler.list_agent_versions.call_args.kwargs + assert kw["name"] == "waverunner" + assert kw["_is_async"] is False + + +# --------------------------------------------------------------------------- +# Async entry points +# --------------------------------------------------------------------------- + + +class TestAsyncEntryPoints: + """Async entry points delegate to their sync counterparts via run_in_executor.""" + + @pytest.mark.asyncio + async def test_acreate_dispatches_with_async_flag(self): + sentinel = MagicMock(name="acreate_response") + + def fake_create_agent(**kwargs): + assert kwargs["_is_async"] is True + assert kwargs["name"] == "waverunner" + return sentinel + + handler = MagicMock() + handler.create_agent.side_effect = fake_create_agent + + with patch(_HANDLER_PATH, handler): + response = await acreate( + name="waverunner", + base_agent="gemini-2.5-flash", + api_key="AIza", + ) + assert response is sentinel + + @pytest.mark.asyncio + async def test_acreate_awaits_coroutine_result(self): + async def _coro(): + return "async-value" + + handler = MagicMock() + handler.create_agent.return_value = _coro() + + with patch(_HANDLER_PATH, handler): + response = await acreate(name="waverunner", api_key="AIza") + + assert response == "async-value" + + @pytest.mark.asyncio + async def test_alist_dispatches_with_async_flag(self): + sentinel = MagicMock(name="alist_response") + + def fake_list_agents(**kwargs): + assert kwargs["_is_async"] is True + return sentinel + + handler = MagicMock() + handler.list_agents.side_effect = fake_list_agents + + with patch(_HANDLER_PATH, handler): + response = await alist(api_key="AIza") + assert response is sentinel + + @pytest.mark.asyncio + async def test_aget_dispatches_with_async_flag(self): + sentinel = MagicMock(name="aget_response") + + def fake_get_agent(**kwargs): + assert kwargs["_is_async"] is True + assert kwargs["name"] == "waverunner" + return sentinel + + handler = MagicMock() + handler.get_agent.side_effect = fake_get_agent + + with patch(_HANDLER_PATH, handler): + response = await aget(name="waverunner", api_key="AIza") + assert response is sentinel + + @pytest.mark.asyncio + async def test_adelete_dispatches_with_async_flag(self): + sentinel = MagicMock(name="adelete_response") + + def fake_delete_agent(**kwargs): + assert kwargs["_is_async"] is True + assert kwargs["name"] == "waverunner" + return sentinel + + handler = MagicMock() + handler.delete_agent.side_effect = fake_delete_agent + + with patch(_HANDLER_PATH, handler): + response = await adelete(name="waverunner", api_key="AIza") + assert response is sentinel + + @pytest.mark.asyncio + async def test_alist_versions_dispatches_with_async_flag(self): + sentinel = MagicMock(name="alist_versions_response") + + def fake_versions(**kwargs): + assert kwargs["_is_async"] is True + assert kwargs["name"] == "waverunner" + return sentinel + + handler = MagicMock() + handler.list_agent_versions.side_effect = fake_versions + + with patch(_HANDLER_PATH, handler): + response = await alist_versions(name="waverunner", api_key="AIza") + assert response is sentinel + + +# --------------------------------------------------------------------------- +# Async error wrapping: exception_type must be invoked +# --------------------------------------------------------------------------- + + +class TestAsyncErrorWrapping: + """If the underlying handler raises, async entry points re-raise via + litellm.exception_type so users get a normalised provider error.""" + + @pytest.mark.asyncio + async def test_acreate_wraps_exception(self): + handler = MagicMock() + handler.create_agent.side_effect = RuntimeError("kaboom") + + with patch(_HANDLER_PATH, handler): + with pytest.raises(Exception): + await acreate(name="waverunner", api_key="AIza") + + @pytest.mark.asyncio + async def test_aget_wraps_exception(self): + handler = MagicMock() + handler.get_agent.side_effect = RuntimeError("kaboom") + + with patch(_HANDLER_PATH, handler): + with pytest.raises(Exception): + await aget(name="waverunner", api_key="AIza") + + @pytest.mark.asyncio + async def test_alist_wraps_exception(self): + handler = MagicMock() + handler.list_agents.side_effect = RuntimeError("kaboom") + + with patch(_HANDLER_PATH, handler): + with pytest.raises(Exception): + await alist(api_key="AIza") + + @pytest.mark.asyncio + async def test_adelete_wraps_exception(self): + handler = MagicMock() + handler.delete_agent.side_effect = RuntimeError("kaboom") + + with patch(_HANDLER_PATH, handler): + with pytest.raises(Exception): + await adelete(name="waverunner", api_key="AIza") + + @pytest.mark.asyncio + async def test_alist_versions_wraps_exception(self): + handler = MagicMock() + handler.list_agent_versions.side_effect = RuntimeError("kaboom") + + with patch(_HANDLER_PATH, handler): + with pytest.raises(Exception): + await alist_versions(name="waverunner", api_key="AIza") diff --git a/tests/test_litellm/interactions/test_gemini_interactions_transformation.py b/tests/test_litellm/interactions/test_gemini_interactions_transformation.py index 758ff3ea38e..524589abf5e 100644 --- a/tests/test_litellm/interactions/test_gemini_interactions_transformation.py +++ b/tests/test_litellm/interactions/test_gemini_interactions_transformation.py @@ -1,23 +1,33 @@ """ Tests for Gemini Interactions API transformation. -Covers credential leak prevention changes: -- validate_environment sets x-goog-api-key header -- get_complete_url excludes API key from URL -- get/delete/cancel interaction request URLs exclude API key +Covers: +- validate_environment: x-goog-api-key header, Api-Revision schema selection +- get_complete_url: API key excluded from URL +- get/delete/cancel interaction request URLs +- transform_request: response_mime_type coalescing, image_config migration """ import os import sys -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest sys.path.insert(0, os.path.abspath("../../..")) +import litellm +from litellm.interactions.litellm_responses_transformation.streaming_iterator import ( + LiteLLMResponsesInteractionsStreamingIterator, +) from litellm.llms.gemini.interactions.transformation import ( GoogleAIStudioInteractionsConfig, ) +from litellm.types.llms.openai import ( + OutputTextDeltaEvent, + ResponseCompletedEvent, + ResponseCreatedEvent, +) from litellm.types.router import GenericLiteLLMParams _PATCH_GET_API_KEY = "litellm.llms.gemini.common_utils.GeminiModelInfo.get_api_key" @@ -76,6 +86,30 @@ class TestValidateEnvironment: assert headers["X-Custom"] == "value" assert headers["x-goog-api-key"] == "test-key" + def test_api_revision_new_schema_by_default(self, config): + # Default: use_legacy_interactions_schema=False → new steps schema + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = False + headers = config.validate_environment( + headers={}, model="gemini-2.5-flash", litellm_params=None + ) + assert headers["Api-Revision"] == "2026-05-20" + finally: + litellm.use_legacy_interactions_schema = original + + def test_api_revision_legacy_schema_when_flag_set(self, config): + # Flag on → legacy outputs schema until June 8, 2026 + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = True + headers = config.validate_environment( + headers={}, model="gemini-2.5-flash", litellm_params=None + ) + assert headers["Api-Revision"] == "2026-05-07" + finally: + litellm.use_legacy_interactions_schema = original + class TestGetCompleteUrl: def test_url_excludes_api_key(self, config): @@ -113,6 +147,357 @@ class TestGetCompleteUrl: ) +class TestTransformRequest: + def test_passes_environment_to_request_body(self, config): + request_body = config.transform_request( + model=None, + agent="my-custom-slides-agent", + input=[ + { + "type": "text", + "text": "Create a 5-slide presentation about AI trends.", + } + ], + optional_params={ + "environment": "remote", + "stream": False, + }, + litellm_params=GenericLiteLLMParams(api_key="test-api-key"), + headers={}, + ) + + assert request_body["agent"] == "my-custom-slides-agent" + assert request_body["environment"] == "remote" + assert request_body["stream"] is False + assert request_body["input"] == [ + {"type": "text", "text": "Create a 5-slide presentation about AI trends."} + ] + + def test_passes_environment_object_to_request_body(self, config): + environment_config = { + "type": "remote", + "sources": [{"type": "gcs", "uri": "gs://bucket/skills.zip"}], + "network": {"egress": "allow_all"}, + } + request_body = config.transform_request( + model=None, + agent="waverunner", + input="What is 2 + 2?", + optional_params={"environment": environment_config}, + litellm_params=GenericLiteLLMParams(api_key="test-api-key"), + headers={}, + ) + + assert request_body["environment"] == environment_config + + def test_passes_existing_environment_id_to_request_body(self, config): + env_id = "env-abc123" + request_body = config.transform_request( + model=None, + agent="my-custom-slides-agent", + input="Continue the presentation.", + optional_params={"environment": env_id}, + litellm_params=GenericLiteLLMParams(api_key="test-api-key"), + headers={}, + ) + + assert request_body["environment"] == env_id + + def test_stream_param_included_in_request_body(self, config): + """When stream=True is in optional_params, the request body must include it + so the proxy forwards the SSE streaming flag to Google's backend.""" + body = config.transform_request( + model="gemini-2.5-flash", + agent=None, + input="Hello", + optional_params={"stream": True}, + litellm_params=GenericLiteLLMParams(api_key="test-key"), + headers={}, + ) + + assert body.get("stream") is True + assert body.get("input") == "Hello" + + def test_stream_false_not_included_when_absent(self, config): + body = config.transform_request( + model="gemini-2.5-flash", + agent=None, + input="Hello", + optional_params={}, + litellm_params=GenericLiteLLMParams(api_key="test-key"), + headers={}, + ) + + assert "stream" not in body + + +class TestStreamingIterator: + def _make_iterator( + self, use_legacy: bool = False + ) -> LiteLLMResponsesInteractionsStreamingIterator: + original = litellm.use_legacy_interactions_schema + litellm.use_legacy_interactions_schema = use_legacy + try: + return LiteLLMResponsesInteractionsStreamingIterator( + model="gpt-5.4", + litellm_custom_stream_wrapper=MagicMock(), + request_input="hi", + optional_params={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + def _make_text_delta( + self, text: str, item_id: str = "item_1" + ) -> OutputTextDeltaEvent: + event = MagicMock(spec=OutputTextDeltaEvent) + event.delta = text + event.item_id = item_id + return event + + def _make_response_created(self) -> ResponseCreatedEvent: + event = MagicMock(spec=ResponseCreatedEvent) + event.response = MagicMock(id="resp_123") + return event + + def test_step_delta_includes_type_field(self): + """step.delta events must carry delta.type='text' so the UI can display them.""" + it = self._make_iterator(use_legacy=False) + it.sent_interaction_start = True + it.sent_content_start = True + + chunk = it._transform_responses_chunk_to_interactions_chunk( + self._make_text_delta("Hello") + ) + + assert chunk is not None + assert chunk.event_type == "step.delta" + assert chunk.delta == {"type": "text", "text": "Hello"} + + def test_content_delta_legacy_schema(self): + """Legacy schema emits content.delta with type and text fields.""" + it = self._make_iterator(use_legacy=True) + it.sent_interaction_start = True + it.sent_content_start = True + + chunk = it._transform_responses_chunk_to_interactions_chunk( + self._make_text_delta("Hello") + ) + + assert chunk is not None + assert chunk.event_type == "content.delta" + assert chunk.delta == {"type": "text", "text": "Hello"} + + def test_response_created_emits_interaction_created(self): + it = self._make_iterator(use_legacy=False) + + chunk = it._transform_responses_chunk_to_interactions_chunk( + self._make_response_created() + ) + + assert chunk is not None + assert chunk.event_type == "interaction.created" + assert chunk.id == "resp_123" + assert it.sent_interaction_start is True + + def test_response_created_emits_interaction_start_legacy(self): + it = self._make_iterator(use_legacy=True) + + chunk = it._transform_responses_chunk_to_interactions_chunk( + self._make_response_created() + ) + + assert chunk is not None + assert chunk.event_type == "interaction.start" + assert chunk.id == "resp_123" + + def test_text_delta_sequence_new_schema(self): + """First chunk yields created + step.start + step.delta; later chunks yield step.delta.""" + it = self._make_iterator(use_legacy=False) + + first_events = it._events_for_chunk(self._make_text_delta("Hello")) + assert [e.event_type for e in first_events] == [ + "interaction.created", + "step.start", + "step.delta", + ] + assert first_events[-1].delta == {"type": "text", "text": "Hello"} + assert it.sent_interaction_start is True + assert it.sent_content_start is True + + second_events = it._events_for_chunk(self._make_text_delta(" World")) + assert [e.event_type for e in second_events] == ["step.delta"] + assert second_events[0].delta == {"type": "text", "text": " World"} + + third_events = it._events_for_chunk(self._make_text_delta("!")) + assert [e.event_type for e in third_events] == ["step.delta"] + assert third_events[0].delta == {"type": "text", "text": "!"} + + def test_text_delta_sequence_legacy_schema(self): + """Legacy: first chunk yields interaction.start + content.start + content.delta.""" + it = self._make_iterator(use_legacy=True) + + first_events = it._events_for_chunk(self._make_text_delta("Hello")) + assert [e.event_type for e in first_events] == [ + "interaction.start", + "content.start", + "content.delta", + ] + assert first_events[-1].delta == {"type": "text", "text": "Hello"} + + second_events = it._events_for_chunk(self._make_text_delta(" World")) + assert [e.event_type for e in second_events] == ["content.delta"] + assert second_events[0].delta == {"type": "text", "text": " World"} + + def test_first_text_delta_without_item_id_uses_fallback_id(self): + it = self._make_iterator(use_legacy=False) + event = self._make_text_delta("Hi") + event.item_id = None + + events = it._events_for_chunk(event) + + assert events[0].event_type == "interaction.created" + assert events[0].id == f"interaction_{id(it)}" + + def test_first_text_delta_emits_text_via_compat_shim(self): + """The legacy single-chunk shim must surface the synthetic events AND the delta.""" + it = self._make_iterator(use_legacy=False) + + first = it._transform_responses_chunk_to_interactions_chunk( + self._make_text_delta("Hello") + ) + assert first is not None + assert first.event_type == "interaction.created" + + second = it.__next__() if it._pending_events else None + assert second is not None + assert second.event_type == "step.start" + + third = it.__next__() if it._pending_events else None + assert third is not None + assert third.event_type == "step.delta" + assert third.delta == {"type": "text", "text": "Hello"} + + def test_response_created_then_text_delta_emits_step_start_and_delta(self): + """Realistic flow: response.created arrives first, then text delta.""" + it = self._make_iterator(use_legacy=False) + + first = it._events_for_chunk(self._make_response_created()) + assert [e.event_type for e in first] == ["interaction.created"] + + second = it._events_for_chunk(self._make_text_delta("Hello")) + assert [e.event_type for e in second] == ["step.start", "step.delta"] + assert second[-1].delta == {"type": "text", "text": "Hello"} + + def test_no_text_token_is_dropped_during_streaming(self): + """Concatenated step.delta payloads must equal the upstream text.""" + it = self._make_iterator(use_legacy=False) + + chunks = ["Hello", " ", "world", "!"] + emitted_text = "" + for c in chunks: + for ev in it._events_for_chunk(self._make_text_delta(c)): + if ev.event_type == "step.delta": + assert ev.delta is not None + emitted_text += ev.delta["text"] + + assert emitted_text == "Hello world!" + + def test_stop_iteration_fallback_emits_completion_event(self): + """If upstream ends without ResponseCompletedEvent, terminal events still flow.""" + from unittest.mock import MagicMock + + text_event = self._make_text_delta("hi") + sync_iter = MagicMock() + sync_iter.__iter__ = lambda self: self + sync_iter.__next__ = MagicMock(side_effect=[text_event, StopIteration]) + + original = litellm.use_legacy_interactions_schema + litellm.use_legacy_interactions_schema = False + try: + it = LiteLLMResponsesInteractionsStreamingIterator( + model="gpt-5.4", + litellm_custom_stream_wrapper=sync_iter, + request_input="hi", + optional_params={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + emitted: list = [] + try: + while True: + emitted.append(next(it)) + except StopIteration: + pass + + event_types = [e.event_type for e in emitted] + assert event_types == [ + "interaction.created", + "step.start", + "step.delta", + "step.stop", + "interaction.completed", + ] + terminal = emitted[-1] + assert terminal.steps == [ + { + "type": "model_output", + "content": [{"type": "text", "text": "hi"}], + } + ] + # EOF-flushed terminal event must carry the same id as interaction.created. + assert terminal.id == emitted[0].id == "item_1" + + def test_response_completed_emits_stop_then_completion(self): + """ResponseCompletedEvent expands into step.stop + interaction.completed.""" + from unittest.mock import MagicMock + + text_event = self._make_text_delta("hi") + completed = MagicMock(spec=ResponseCompletedEvent) + completed.response = MagicMock(id="resp_999") + + sync_iter = MagicMock() + sync_iter.__iter__ = lambda self: self + sync_iter.__next__ = MagicMock(side_effect=[text_event, completed]) + + original = litellm.use_legacy_interactions_schema + litellm.use_legacy_interactions_schema = False + try: + it = LiteLLMResponsesInteractionsStreamingIterator( + model="gpt-5.4", + litellm_custom_stream_wrapper=sync_iter, + request_input="hi", + optional_params={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + emitted: list = [] + try: + while True: + emitted.append(next(it)) + except StopIteration: + pass + + event_types = [e.event_type for e in emitted] + assert event_types == [ + "interaction.created", + "step.start", + "step.delta", + "step.stop", + "interaction.completed", + ] + # StopIteration fallback path must NOT add a duplicate completion event. + assert event_types.count("interaction.completed") == 1 + # When the stream starts directly with a text delta (no preceding + # response.created), the terminal events must reuse the id derived from + # the first chunk's item_id rather than switching to response.id, so + # consumers can correlate the start and completion events by id. + assert emitted[0].id == "item_1" + assert emitted[-1].id == "item_1" + + class TestInteractionOperationUrls: """Test that get/delete/cancel interaction URLs exclude API key.""" @@ -171,3 +556,152 @@ class TestInteractionOperationUrls: litellm_params=GenericLiteLLMParams(api_key=None), headers={}, ) + + +class TestTransformRequestSchemaCoalescing: + """Test new-schema request coalescing (Api-Revision: 2026-05-20).""" + + def test_response_mime_type_folded_into_response_format(self, config): + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = False + body = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="summarise", + optional_params={ + "response_mime_type": "application/json", + "response_format": {"type": "object", "properties": {}}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + # response_mime_type must not appear as a top-level body key + assert "response_mime_type" not in body + rf = body["response_format"] + assert rf["type"] == "text" + assert rf["mime_type"] == "application/json" + assert "schema" in rf + + def test_image_config_moved_to_response_format(self, config): + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = False + body = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="draw a sunset", + optional_params={ + "generation_config": { + "temperature": 0.7, + "image_config": {"aspect_ratio": "1:1", "image_size": "1K"}, + } + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + # image_config removed from generation_config + assert "image_config" not in body.get("generation_config", {}) + # moved into response_format with type=image + rf = body["response_format"] + assert rf["type"] == "image" + assert rf["aspect_ratio"] == "1:1" + + def test_response_mime_type_skipped_when_response_format_is_list(self, config): + """Lists are already polymorphic; do not wrap them into schema.""" + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = False + rf_list = [ + {"type": "text", "mime_type": "application/json"}, + {"type": "image", "aspect_ratio": "1:1"}, + ] + body = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="multimodal", + optional_params={ + "response_format": rf_list, + "response_mime_type": "application/json", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + assert body["response_format"] == rf_list + assert "response_mime_type" not in body + + def test_image_config_appended_to_response_format_list_without_mutating_input( + self, config + ): + """When response_format is already a list, image_config must not mutate optional_params.""" + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = False + text_rf = {"type": "text", "mime_type": "application/json"} + optional_params = { + "response_format": [text_rf], + "generation_config": { + "image_config": {"aspect_ratio": "16:9", "image_size": "2K"}, + }, + } + original_rf = optional_params["response_format"] + + body = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="draw and summarise", + optional_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert optional_params["response_format"] is original_rf + assert len(optional_params["response_format"]) == 1 + assert body["response_format"] == [ + text_rf, + {"type": "image", "aspect_ratio": "16:9", "image_size": "2K"}, + ] + + # Retry must not append a second image entry into the caller's list. + body_retry = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="draw and summarise", + optional_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert len(optional_params["response_format"]) == 1 + assert body_retry["response_format"] == body["response_format"] + finally: + litellm.use_legacy_interactions_schema = original + + def test_legacy_schema_passes_fields_unchanged(self, config): + original = litellm.use_legacy_interactions_schema + try: + litellm.use_legacy_interactions_schema = True + body = config.transform_request( + model="gemini/gemini-2.5-flash", + agent=None, + input="hello", + optional_params={ + "response_mime_type": "application/json", + "generation_config": {"image_config": {"aspect_ratio": "16:9"}}, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + finally: + litellm.use_legacy_interactions_schema = original + + assert body["response_mime_type"] == "application/json" + assert body["generation_config"]["image_config"]["aspect_ratio"] == "16:9" diff --git a/tests/test_litellm/interactions/test_openapi_compliance.py b/tests/test_litellm/interactions/test_openapi_compliance.py index 1d3b6b8ae1e..aededaaca77 100644 --- a/tests/test_litellm/interactions/test_openapi_compliance.py +++ b/tests/test_litellm/interactions/test_openapi_compliance.py @@ -179,7 +179,10 @@ class TestResponseCompliance: # `status` is an output-only field; validate against the response schema. schema = spec_dict["components"]["schemas"]["Interaction"] status_prop = schema["properties"]["status"] - # Google Interactions API uses lowercase status values (updated Feb 2026) + # Google Interactions API uses lowercase status values (updated Feb 2026). + # Keep this an exact match: this test intentionally breaks CI when + # Google changes the live spec — that breakage is how we get notified + # to review the change. expected_statuses = [ "in_progress", "requires_action", @@ -187,6 +190,7 @@ class TestResponseCompliance: "failed", "cancelled", "incomplete", + "budget_exceeded", ] assert status_prop["enum"] == expected_statuses print(f"✓ Status enum values: {expected_statuses}") diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index c6961477a58..07ab29c5231 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -2078,6 +2078,146 @@ async def test_async_success_handler_preserves_response_cost_for_pass_through_en assert slo["response_cost"] > 0 +def test_process_hidden_params_recalculates_cost_after_failure_handler_zero(): + """ + Regression: PR #21844 preserved response_cost=0 set by failure_handler on failed + router retry attempts, so a later successful response with usage logged $0 spend. + """ + from datetime import datetime + + import litellm + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.utils import ModelResponse, Usage + + logging_obj = LiteLLMLoggingObj( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="test-retry-zero-cost", + function_id="test-retry-zero-cost", + ) + logging_obj.model_call_details["litellm_params"] = {"model": "openai/gpt-4o-mini"} + logging_obj.optional_params = {} + + err = litellm.RateLimitError( + message="rate limit", + llm_provider="openai", + model="openai/gpt-4o-mini", + ) + for _ in range(2): + logging_obj._failure_handler_helper_fn( + exception=err, + traceback_exception="", + start_time=datetime.now(), + end_time=datetime.now(), + ) + assert logging_obj.model_call_details.get("response_cost") == 0 + + result = ModelResponse( + id="success", + choices=[{"message": {"role": "assistant", "content": "ok"}}], + usage=Usage(prompt_tokens=9698, completion_tokens=30, total_tokens=9728), + ) + logging_obj._process_hidden_params_and_response_cost( + result, datetime.now(), datetime.now() + ) + + cost = logging_obj.model_call_details.get("response_cost") + assert cost is not None and cost > 0 + slo = logging_obj.model_call_details.get("standard_logging_object") or {} + assert slo.get("response_cost", 0) > 0 + + +def test_process_hidden_params_preserves_zero_cost_in_hidden_params(): + """Pass-through handlers often set response_cost on result._hidden_params (including 0).""" + from datetime import datetime + + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.utils import ModelResponse, Usage + + logging_obj = LiteLLMLoggingObj( + model="gemini-2.5-flash-lite", + messages=[{"role": "user", "content": "test"}], + stream=False, + call_type="pass_through_endpoint", + start_time=datetime.now(), + litellm_call_id="test-hidden-zero-cost", + function_id="test-hidden-zero-cost", + ) + logging_obj.model_call_details["litellm_params"] = { + "model": "gemini-2.5-flash-lite" + } + logging_obj.optional_params = {} + + result = ModelResponse( + id="batch-pending", + choices=[{"message": {"role": "assistant", "content": "pending"}}], + usage=Usage(prompt_tokens=100, completion_tokens=10, total_tokens=110), + ) + result._hidden_params = {"response_cost": 0.0} + + logging_obj._process_hidden_params_and_response_cost( + result, datetime.now(), datetime.now() + ) + + assert logging_obj.model_call_details.get("response_cost") == 0.0 + slo = logging_obj.model_call_details.get("standard_logging_object") or {} + assert slo.get("response_cost") == 0.0 + + +def test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zero(): + """After retry failures pin model_call_details to 0, success cost on _hidden_params wins.""" + from datetime import datetime + + import litellm + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.utils import ModelResponse, Usage + + logging_obj = LiteLLMLoggingObj( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="test-retry-hidden-cost", + function_id="test-retry-hidden-cost", + ) + logging_obj.model_call_details["litellm_params"] = {"model": "openai/gpt-4o-mini"} + logging_obj.optional_params = {} + + err = litellm.RateLimitError( + message="rate limit", + llm_provider="openai", + model="openai/gpt-4o-mini", + ) + for _ in range(2): + logging_obj._failure_handler_helper_fn( + exception=err, + traceback_exception="", + start_time=datetime.now(), + end_time=datetime.now(), + ) + assert logging_obj.model_call_details.get("response_cost") == 0 + + passthrough_cost = 0.00042 + result = ModelResponse( + id="success", + choices=[{"message": {"role": "assistant", "content": "ok"}}], + usage=Usage(prompt_tokens=9698, completion_tokens=30, total_tokens=9728), + ) + result._hidden_params = {"response_cost": passthrough_cost} + + logging_obj._process_hidden_params_and_response_cost( + result, datetime.now(), datetime.now() + ) + + assert logging_obj.model_call_details.get("response_cost") == passthrough_cost + slo = logging_obj.model_call_details.get("standard_logging_object") or {} + assert slo.get("response_cost") == passthrough_cost + + def test_function_setup_litellm_metadata_populates_metadata(): """ Test that function_setup() properly handles litellm_metadata (used by /v1/messages, diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index 3aa5f012467..324bace0e96 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -437,13 +437,38 @@ def test_gpt_4o_token_counter(): @pytest.mark.parametrize( "img_url", [ - "https://blog.purpureus.net/assets/blog/personal_key_rotation/simplified-asset-graph.jpg", + "https://example.com/test-image.png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAL0AAAC9CAMAAADRCYwCAAAAh1BMVEX///8AAAD8/Pz5+fkEBAT39/cJCQn09PRNTU3y8vIMDAwzMzPe3t7v7+8QEBCOjo7FxcXR0dHn5+elpaWGhoYYGBivr686OjocHBy0tLQtLS1TU1PY2Ni6urpaWlpERER3d3ecnJxoaGiUlJRiYmIlJSU4ODhBQUFycnKAgIDBwcFnZ2chISE7EjuwAAAI/UlEQVR4nO1caXfiOgz1bhJIyAJhX1JoSzv8/9/3LNlpYd4rhX6o4/N8Z2lKM2cURZau5JsQEhERERERERERERERERERERHx/wBjhDPC3OGN8+Cc5JeMuheaETSdO8vZFyCScHtmz2CsktoeMn7rLM1u3h0PMAEhyYX7v/Q9wQvoGdB0hlbzm45lEq/wd6y6G9aezvBk9AXwp1r3LHJIRsh6s2maxaJpmvqgvkC7WFS3loUnaFJtKRVUCEoV/RpCnHRvAsesVQ1hw+vd7Mpo+424tLs72NplkvQgcdrsvXkW/zJWqH/fA0FT84M/xnQJt4to3+ZLuanbM6X5lfXKHosO9COgREqpCR5i86pf2zPS7j9tTj+9nO7bQz3+xGEyGW9zqgQ1tyQ/VsxEDvce/4dcUPNb5OD9yXvR4Z2QisuP0xiGWPnemgugU5q/troHhGEjIF5sTOyW648aC0TssuaaCEsYEIkGzjWXOp3A0vVsf6kgRyqaDk+T7DIVWrb58b2tT5xpUucKwodOD/5LbrZC1ws6YSaBZJ/8xlh+XZSYXaMJ2ezNqjB3IPXuehPcx2U6b4t1dS/xNdFzguUt8ie7arnPeyCZroxLHzGgGdqVcspwafizPWEXBee+9G1OaufGdvNng/9C+gwgZ3PH3r87G6zXTZ5D5De2G2DeFoANXfbACkT+fxBQ22YFsTTJF9hjFVO6VbqxZXko4WJ8s52P4PnuxO5KRzu0/hlix1ySt8iXjgaQ+4IHPA9nVzNkdduM9LFT/Aacj4FtKrHA7iAw602Vnht6R8Vq1IOS+wNMKLYqayAYfRuufQPGeGb7sZogQQoLZrGPgZ6KoYn70Iw30O92BNEDpvwouCFn6wH2uS+EhRb3WF/HObZk3HuxfRQM3Y/Of/VH0n4MKNHZDiZvO9+m/ABALfkOcuar/7nOo7B95ACGVAFaz4jMiJwJhdaHBkySmzlGTu82gr6FSTik2kJvLnY9nOd/D90qcH268m3I/cgI1xg1maE5CuZYaWLH+UHANCIck0yt7Mx5zBm5vVHXHwChsZ35kKqUpmo5Svq5/fzfAI5g2vDtFPYo1HiEA85QrDeGm9g//LG7K0scO3sdpj2CBDgCa+0OFs0bkvVgnnM/QBDwllOMm+cN7vMSHlB7Uu4haHKaTwgGkv8tlK+hP8fzmFuK/RQTpaLPWvbd58yWIo66HHM0OsPoPhVqmtaEVL7N+wYcTLTbb0DLdgp23Eyy2VYJ2N7bkLFAAibtoLPe5sLt6Oa2bvU+zyeMa8wrixO0gRTn9tO9NCSThTLGqcqtsDvphlfmx/cPBZVvw24jg1LE2lPuEo35Mhi58U0I/Ga8n5w+NS8i34MAQLos5B1u0xL1ZvCVYVRw/Fs2q53KLaXJMWwOZZ/4MPYV19bAHmgGDKB6f01xoeJKFbl63q9J34KdaVNPJWztQyRkzA3KNs1AdAEDowMxh10emXTCx75CkurtbY/ZpdNDGdsn2UcHKHsQ8Ai3WZi48IfkvtjOhsLpuIRSKZTX9FA4o+0d6o/zOWqQzVJMynL9NsxhSJOaourq6nBVQBueMSyubsX2xHrmuABZN2Ns9jr5nwLFlLF/2R6atjW/67Yd11YQ1Z+kA9Zk9dPTM/o6dVo6HHVgC0JR8oUfmI93T9u3gvTG94bAH02Y5xeqRcjuwnKCK6Q2+ajl8KXJ3GSh22P3Zfx6S+n008ROhJn+JRIUVu6o7OXl8w1SeyhuqNDwNI7SjbK08QrqPxS95jy4G7nCXVq6G3HNu0LtK5J0e226CfC005WKK9sVvfxI0eUbcnzutfhWe3rpZHM0nZ/ny/N8tanKYlQ6VEW5Xuym8yV1zZX58vwGhZp/5tFfhybZabdbrQYOs8F+xEhmPsb0/nki6kIyVvzZzUASiOrTfF+Sj9bXC7DoJxeiV8tjQL6loSd0yCx7YyB6rPdLx31U2qCG3F/oXIuDuqd6LFO+4DNIJuxFZqSsU0ea88avovFnWKRYFYRQDfCfcGaBCLn4M4A1ntJ5E57vicwqq2enaZEF5nokCYu9TbKqCC5yCDfL+GhLxT4w4xEJs+anqgou8DOY2q8FMryjb2MehC1dRJ9s4g9NXeTwPkWON4RH+FhIe0AWR/S9ekvQ+t70XHeimGF78LzuU7d7PwrswdIG2VpgF8C53qVQsTDtBJc4CdnkQPbnZY9mbPdDFra3PCXBBQ5QBn2aQqtyhvlyYM4Hb2/mdhsxCUen04GZVvIJZw5PAamMOmjzq8Q+dzAKLXDQ3RUZItWsg4t7W2DP+JDrJDymoMH7E5zQtuEpG03GTIjGCW3LQqOYEsXgFc78x76NeRwY6SNM+IfQoh6myJKRBIcLYxZcwscJ/gI2isTBty2Po9IkYzP0/SS4hGlxRjFAG5z1Jt1LckiB57yWvo35EaolbvA+6fBa24xodL2YjsPpTnj3JgJOqhcgOeLVsYYwoK0wjY+m1D3rGc40CukkaHnkEjarlXrF1B9M6ECQ6Ow0V7R7N4G3LfOHAXtymoyXOb4QhaYHJ/gNBJUkxclpSs7DNcgWWDDmM7Ke5MJpGuioe7w5EOvfTunUKRzOh7G2ylL+6ynHrD54oQO3//cN3yVO+5qMVsPZq0CZIOx4TlcJ8+Vz7V5waL+7WekzUpRFMTnnTlSCq3X5usi8qmIleW/rit1+oQZn1WGSU/sKBYEqMNh1mBOc6PhK8yCfKHdUNQk8o/G19ZPTs5MYfai+DLs5vmee37zEyyH48WW3XA6Xw6+Az8lMhci7N/KleToo7PtTKm+RA887Kqc6E9dyqL/QPTugzMHLbLZtJKqKLFfzVWRNJ63c+95uWT/F7R0U5dDVvuS409AJXhJvD0EwWaWdW8UN11u/7+umaYjT8mJtzZwP/MD4r57fihiHlC5fylHfaqnJdro+Dr7DajvO+vi2EwyD70s8nCH71nzIO1l5Zl+v1DMCb5ebvCMkGHvobXy/hPumGLyX0218/3RyD1GRLOuf9u/OGQyDmto32yMiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv7GP8YjWPR/czH2AAAAAElFTkSuQmCC", ], ) -def test_img_url_token_counter(img_url): +def test_img_url_token_counter(img_url, monkeypatch): + """ + Verify get_image_dimensions returns valid (width, height) for both an + HTTPS URL and a base64 data URI. The HTTPS branch is exercised with a + mocked HTTP fetch so the test is hermetic - it can't break when a + third-party image URL goes away. + """ + import base64 from litellm.litellm_core_utils.token_counter import get_image_dimensions + # Minimal valid 1x1 PNG, served by the mocked safe_get for the URL case. + _tiny_png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" + ) + + if img_url.startswith(("http://", "https://")): + + class _FakeResponse: + headers = {"Content-Length": str(len(_tiny_png))} + + def read(self): + return _tiny_png + + monkeypatch.setattr( + "litellm.litellm_core_utils.token_counter.safe_get", + lambda client, url, **kw: _FakeResponse(), + ) + width, height = get_image_dimensions(data=img_url) print(width, height) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index a19752dc648..7d9e4768303 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -2476,6 +2476,120 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models(): ), f"output_config should not be set for {model}" +@pytest.mark.parametrize( + "reasoning_effort_value", + [ + # String shape — what callers send when using `reasoning_effort="low"` directly. + "low", + # Dict shape with `effort` only — what the Responses->Chat parser produces + # when `reasoning={"effort": "low"}` is set without `summary`. + {"effort": "low"}, + # Dict shape with `effort` AND `summary` — what the Responses->Chat parser + # produces when callers send `Reasoning(effort="low", summary="concise")`. + # PR #25359 added the dict-keeping branch for this case, but the Anthropic + # transformation must coerce the dict back to a string before mapping. + {"effort": "low", "summary": "concise"}, + {"effort": "low", "summary": "detailed"}, + ], +) +def test_reasoning_effort_accepts_dict_shape_for_adaptive_model(reasoning_effort_value): + """ + Adaptive-thinking (Claude 4.6+) branch: dict-shape reasoning_effort must + map to ``thinking.type='adaptive'`` + ``output_config.effort``. + + Regression test for the dict-shape ``reasoning_effort`` produced by the + Responses->Chat parser when ``summary`` is set on the request's + ``reasoning`` field. Before this fix, the Anthropic transformation guarded + on ``isinstance(value, str)`` and silently dropped the param — disabling + extended thinking entirely. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": reasoning_effort_value}, + optional_params={}, + model="claude-sonnet-4-6-20260219", + drop_params=False, + ) + + # thinking must be set (adaptive for 4.6+) + assert "thinking" in result, ( + f"thinking missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["thinking"]["type"] == "adaptive" + # output_config must carry the mapped effort + assert "output_config" in result, ( + f"output_config missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["output_config"]["effort"] == "low" + + +@pytest.mark.parametrize( + "reasoning_effort_value", + [ + "low", + {"effort": "low"}, + {"effort": "low", "summary": "concise"}, + ], +) +def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(reasoning_effort_value): + """ + Non-adaptive (pre-4.6) branch: dict-shape reasoning_effort must still map + to ``thinking.type='enabled'`` + ``budget_tokens``. ``output_config`` must + NOT be set on these models. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": reasoning_effort_value}, + optional_params={}, + model="claude-sonnet-4-5-20250929", + drop_params=False, + ) + + assert "thinking" in result, ( + f"thinking missing for reasoning_effort={reasoning_effort_value!r}" + ) + assert result["thinking"]["type"] == "enabled" + assert "budget_tokens" in result["thinking"] + assert result["thinking"]["budget_tokens"] > 0 + # Older models must not get adaptive-thinking output_config + assert "output_config" not in result, ( + f"output_config should not be set for non-adaptive model " + f"(reasoning_effort={reasoning_effort_value!r})" + ) + + +@pytest.mark.parametrize( + "bad_value", + [ + {"summary": "concise"}, # missing effort + {"effort": None}, # explicit None effort + {"effort": 123}, # non-string effort + ], +) +def test_reasoning_effort_unparseable_dict_is_dropped(bad_value): + """ + A dict shape that doesn't carry a usable ``effort`` key (e.g. only + ``summary`` is set, or the value is some other unexpected type) should be + silently dropped — not crash, not partially apply. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": bad_value}, + optional_params={}, + model="claude-sonnet-4-6-20260219", + drop_params=False, + ) + assert "thinking" not in result, ( + f"thinking should not be set for bad value {bad_value!r}" + ) + assert "output_config" not in result, ( + f"output_config should not be set for bad value {bad_value!r}" + ) + + @pytest.mark.parametrize( "model", [ diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index 4495e3f4101..b2e254901f4 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -2,6 +2,7 @@ import asyncio import json import os import sys +from unittest.mock import patch import pytest @@ -429,6 +430,31 @@ def test_output_config_forwarded_for_bedrock_chat_invoke_request(): assert result["max_tokens"] == 100 +def test_bedrock_chat_invoke_checks_output_config_support_with_bedrock_provider(): + config = AmazonAnthropicClaudeConfig() + messages = [{"role": "user", "content": "test"}] + optional_params = {"max_tokens": 100, "output_config": {"effort": "high"}} + + with patch( + "litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ) as mock_supports_factory: + result = config.transform_request( + model="us.anthropic.claude-opus-4-7", + messages=messages, + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + mock_supports_factory.assert_called_once_with( + model="us.anthropic.claude-opus-4-7", + custom_llm_provider="bedrock", + key="supports_output_config", + ) + assert result["output_config"] == {"effort": "high"} + + def test_output_format_removed_from_bedrock_invoke_request(): """ Test that output_format parameter is removed from Bedrock Invoke requests. diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 9ecdad1fcff..2e315a535f0 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -592,8 +592,15 @@ def test_remove_scope_from_cache_control(): assert request["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral" -def test_bedrock_messages_forwards_output_config(): - """Bedrock Invoke /v1/messages forwards ``output_config`` for adaptive Claude models.""" +def test_bedrock_messages_strips_output_config(): + """ + Ensure output_config is stripped from the request for models that do not + support it. + + Regression test for: https://github.com/BerriAI/litellm/issues/22797 + """ + from unittest.mock import patch + from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() @@ -605,21 +612,129 @@ def test_bedrock_messages_forwards_output_config(): }, } - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-opus-4-7", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=False, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + "output_config" not in result + ), "output_config should be stripped for models that don't support it" + assert result.get("max_tokens") == 4096 + + +def test_bedrock_messages_preserves_output_config_for_claude_4_6(): + """ + Ensure output_config is preserved for models that support it on Bedrock Invoke. + """ + from unittest.mock import patch + + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "output_config": { + "effort": "high", + }, + } + + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-6-v1", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + "output_config" in result + ), "output_config should be preserved for supported models" + assert result["output_config"] == {"effort": "high"} + assert result.get("max_tokens") == 4096 + + +def test_bedrock_messages_checks_output_config_support_with_bedrock_provider(): + from unittest.mock import patch + + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "output_config": { + "effort": "high", + }, + } + + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ) as mock_supports_factory: + result = cfg.transform_anthropic_messages_request( + model="us.anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + mock_supports_factory.assert_called_with( + model="us.anthropic.claude-opus-4-7", + custom_llm_provider="bedrock", + key="supports_output_config", ) + assert result["output_config"] == {"effort": "high"} + + +def test_bedrock_messages_forwards_output_config(): + """Bedrock Invoke /v1/messages forwards ``output_config`` for supported models.""" + from unittest.mock import patch + + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "output_config": { + "effort": "high", + }, + } + + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) assert result.get("output_config") == {"effort": "high"} - # Other params should be preserved assert result.get("max_tokens") == 4096 def test_bedrock_messages_forwards_output_config_with_output_format(): """``output_config`` is forwarded; ``output_format`` is converted to inline schema.""" + from unittest.mock import patch + from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() @@ -636,39 +751,60 @@ def test_bedrock_messages_forwards_output_config_with_output_format(): }, } - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-opus-4-7", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) assert result.get("output_config") == {"effort": "low"} assert "output_format" not in result -def test_bedrock_messages_forwards_output_config_for_non_adaptive_model(): - """``output_config`` is forwarded for non-adaptive models so the provider's error surfaces.""" +def test_bedrock_messages_strips_output_config_with_output_format(): + """ + When both output_config and output_format are present, output_format + is converted to inline schema and output_config is stripped for + unsupported models. + """ + from unittest.mock import patch + from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] optional_params = { "max_tokens": 4096, - "output_config": {"effort": "high"}, + "output_config": {"effort": "low"}, + "output_format": { + "type": "json_schema", + "schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + }, + }, } - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-3-haiku-20240307-v1:0", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=False, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) - assert result.get("output_config") == {"effort": "high"} - assert result.get("max_tokens") == 4096 + assert "output_config" not in result + assert "output_format" not in result def test_bedrock_messages_drop_params_strips_output_config_for_pre_4_5(): @@ -701,6 +837,8 @@ def test_bedrock_messages_drop_params_strips_output_config_for_pre_4_5(): def test_bedrock_messages_drop_params_keeps_output_config_for_4_7(): """``drop_params=True`` does not strip on opus-4-7 (supports effort).""" + from unittest.mock import patch + import litellm from litellm.types.router import GenericLiteLLMParams @@ -714,13 +852,17 @@ def test_bedrock_messages_drop_params_keeps_output_config_for_4_7(): original = litellm.drop_params litellm.drop_params = True try: - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-opus-4-7", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) finally: litellm.drop_params = original @@ -742,6 +884,8 @@ def test_bedrock_messages_maps_reasoning_effort_for_adaptive_model( reasoning_effort, expected_effort ): """``reasoning_effort`` maps to ``thinking`` + ``output_config.effort`` on /v1/messages.""" + from unittest.mock import patch + from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() @@ -751,13 +895,17 @@ def test_bedrock_messages_maps_reasoning_effort_for_adaptive_model( "reasoning_effort": reasoning_effort, } - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-opus-4-7", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) assert "reasoning_effort" not in result assert result.get("thinking") == {"type": "adaptive"} @@ -842,6 +990,8 @@ def test_bedrock_messages_invalid_reasoning_effort_raises_400(): def test_bedrock_messages_explicit_output_config_wins_over_reasoning_effort(): """Explicit ``output_config.effort`` wins over the ``reasoning_effort`` alias.""" + from unittest.mock import patch + from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() @@ -852,13 +1002,17 @@ def test_bedrock_messages_explicit_output_config_wins_over_reasoning_effort(): "output_config": {"effort": "max"}, } - result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-opus-4-7", - messages=messages, - anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), - headers={}, - ) + with patch( + "litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation._supports_factory", + return_value=True, + ): + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) assert "reasoning_effort" not in result assert result.get("output_config") == {"effort": "max"} @@ -994,7 +1148,7 @@ def test_bedrock_messages_allowlist_filters_anthropic_only_fields(): } result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-3-haiku-20240307-v1:0", + model="anthropic.claude-opus-4-7", messages=messages, anthropic_messages_optional_request_params=optional_params, litellm_params=GenericLiteLLMParams(), diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index 8fa9290d3de..c39fb427a01 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -14,18 +14,46 @@ from litellm.llms.bedrock.common_utils import BedrockModelInfo # --------------------------------------------------------------------------- # -# BEDROCK_RESPONSE_STREAM_SHAPE eager-load tests # +# get_bedrock_response_stream_shape lazy-load tests # # --------------------------------------------------------------------------- # -def test_bedrock_response_stream_shape_loaded_at_import(): +@pytest.fixture(autouse=True) +def _reset_bedrock_response_stream_shape_cache(): + """Prevent lru_cache leakage between tests in this module.""" + import litellm.llms.bedrock.common_utils as mod + + mod.get_bedrock_response_stream_shape.cache_clear() + yield + mod.get_bedrock_response_stream_shape.cache_clear() + + +def test_bedrock_response_stream_shape_lazy_loads_once(): """ - BEDROCK_RESPONSE_STREAM_SHAPE is resolved at module import time. + get_bedrock_response_stream_shape() loads from botocore at most once per process. + """ + from unittest.mock import MagicMock, patch + + import litellm.llms.bedrock.common_utils as mod + + sentinel = MagicMock() + with patch.object( + mod, "_load_bedrock_response_stream_shape", return_value=sentinel + ) as mock_load: + assert mod.get_bedrock_response_stream_shape() is sentinel + assert mod.get_bedrock_response_stream_shape() is sentinel + mock_load.assert_called_once() + + +def test_bedrock_response_stream_shape_loaded_on_first_access(): + """ + get_bedrock_response_stream_shape() loads once on first use. In a standard environment with botocore installed it must be non-None. """ - from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE + pytest.importorskip("botocore") + from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape - assert BEDROCK_RESPONSE_STREAM_SHAPE is not None + assert get_bedrock_response_stream_shape() is not None def test_bedrock_response_stream_shape_load_failure_returns_none(): @@ -38,6 +66,7 @@ def test_bedrock_response_stream_shape_load_failure_returns_none(): import litellm.llms.bedrock.common_utils as mod + pytest.importorskip("botocore") with patch( "botocore.loaders.Loader.load_service_model", side_effect=Exception("no data"), @@ -51,31 +80,29 @@ def test_bedrock_response_stream_shape_is_structure_shape(): The loaded shape should be the botocore StructureShape for ResponseStream, not a plain dict or any other type. """ + pytest.importorskip("botocore") from botocore.model import StructureShape - from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE + from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape - assert BEDROCK_RESPONSE_STREAM_SHAPE is not None, ( - "BEDROCK_RESPONSE_STREAM_SHAPE is None — botocore may not be installed" - ) - shape: StructureShape = BEDROCK_RESPONSE_STREAM_SHAPE # remove Optional + loaded_shape = get_bedrock_response_stream_shape() + assert ( + loaded_shape is not None + ), "get_bedrock_response_stream_shape() is None — botocore may not be installed" + shape: StructureShape = loaded_shape assert isinstance(shape, StructureShape) assert shape.name == "ResponseStream" -def test_bedrock_response_stream_shape_same_object_across_imports(): +def test_bedrock_response_stream_shape_same_object_across_calls(): """ - Both bedrock modules that use the shape must reference the identical object — - confirming the constant is not re-loaded per import. + Repeated calls must return the identical cached object. """ - from litellm.llms.bedrock.chat.invoke_handler import ( - BEDROCK_RESPONSE_STREAM_SHAPE as invoke_shape, - ) - from litellm.llms.bedrock.common_utils import ( - BEDROCK_RESPONSE_STREAM_SHAPE as common_shape, - ) + from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape - assert common_shape is invoke_shape + first = get_bedrock_response_stream_shape() + second = get_bedrock_response_stream_shape() + assert first is second def test_bedrock_event_stream_decoder_base_uses_module_shape(): @@ -95,19 +122,23 @@ def test_bedrock_event_stream_decoder_base_uses_module_shape(): def test_bedrock_parse_message_from_event_raises_on_none_shape(): """ - When BEDROCK_RESPONSE_STREAM_SHAPE is None (botocore unavailable), + When get_bedrock_response_stream_shape() returns None (botocore unavailable), _parse_message_from_event must raise BedrockError before touching the botocore parser — not an opaque AttributeError from inside botocore. """ from unittest.mock import MagicMock, patch import litellm.llms.bedrock.common_utils as mod - from litellm.llms.bedrock.common_utils import BedrockError, BedrockEventStreamDecoderBase + from litellm.llms.bedrock.common_utils import ( + BedrockError, + BedrockEventStreamDecoderBase, + ) - decoder = BedrockEventStreamDecoderBase() + decoder = BedrockEventStreamDecoderBase.__new__(BedrockEventStreamDecoderBase) + decoder.parser = MagicMock() mock_event = MagicMock() - with patch.object(mod, "BEDROCK_RESPONSE_STREAM_SHAPE", None): + with patch.object(mod, "get_bedrock_response_stream_shape", return_value=None): with pytest.raises(BedrockError) as exc_info: decoder._parse_message_from_event(mock_event) diff --git a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index 2498946bb5c..90a1c24bada 100644 --- a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -14,6 +14,7 @@ import pytest sys.path.insert(0, os.path.abspath("../../../../..")) +from litellm.llms.openai.common_utils import OpenAIError from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -201,3 +202,127 @@ class TestChatGPTResponsesAPITransformation: ) assert parsed.output_text == "Hello!" + + @pytest.mark.parametrize( + ("model_name", "response_model"), + [ + ("chatgpt/gpt-5.2-codex", "gpt-5.2-codex"), + ("chatgpt/gpt-5.3-codex", "gpt-5.3-codex"), + ], + ) + def test_chatgpt_non_stream_sse_response_recovers_output_items( + self, model_name: str, response_model: str + ): + config = ChatGPTResponsesAPIConfig() + response_payload = { + "id": "resp_test", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": response_model, + "output": [], + } + streamed_output_item = { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello from stream!"}], + } + sse_body = "\n".join( + [ + f"data: {json.dumps({'type': 'response.output_item.done', 'output_index': 0, 'item': streamed_output_item})}", + f"data: {json.dumps({'type': 'response.completed', 'response': response_payload})}", + "data: [DONE]", + "", + ] + ) + raw_response = httpx.Response( + 200, headers={"content-type": "text/event-stream"}, text=sse_body + ) + logging_obj = MagicMock() + + parsed = config.transform_response_api_response( + model=model_name, + raw_response=raw_response, + logging_obj=logging_obj, + ) + + assert parsed.output_text == "Hello from stream!" + + def test_chatgpt_non_stream_sse_recovers_whitespace_padded_chunks(self): + """Chunks with leading whitespace before `data:` must still parse. + + `_strip_sse_data_from_chunk` only matches the prefix at position 0, + so without an outer `.strip()` such chunks would fail JSON parsing + and silently drop the contained event. + """ + config = ChatGPTResponsesAPIConfig() + response_payload = { + "id": "resp_test", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "gpt-5.4", + "output": [], + } + streamed_output_item = { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Recovered from padded"}], + } + sse_body = "\n".join( + [ + f" data: {json.dumps({'type': 'response.output_item.done', 'output_index': 0, 'item': streamed_output_item})} ", + f"\tdata: {json.dumps({'type': 'response.completed', 'response': response_payload})}", + "data: [DONE]", + "", + ] + ) + raw_response = httpx.Response( + 200, headers={"content-type": "text/event-stream"}, text=sse_body + ) + logging_obj = MagicMock() + + parsed = config.transform_response_api_response( + model="chatgpt/gpt-5.4", + raw_response=raw_response, + logging_obj=logging_obj, + ) + + assert parsed.output_text == "Recovered from padded" + + @pytest.mark.parametrize( + "error_chunk", + [ + { + "type": "response.failed", + "response": {"error": {"message": "ChatGPT upstream failed"}}, + }, + { + "type": "error", + "error": {"message": "ChatGPT upstream failed"}, + }, + ], + ) + def test_chatgpt_non_stream_sse_response_raises_openai_error(self, error_chunk): + config = ChatGPTResponsesAPIConfig() + sse_body = "\n".join( + [ + f"data: {json.dumps(error_chunk)}", + "data: [DONE]", + "", + ] + ) + raw_response = httpx.Response( + 502, headers={"content-type": "text/event-stream"}, text=sse_body + ) + logging_obj = MagicMock() + + with pytest.raises(OpenAIError) as exc_info: + config.transform_response_api_response( + model="chatgpt/gpt-5.4", + raw_response=raw_response, + logging_obj=logging_obj, + ) + + assert "ChatGPT upstream failed" in str(exc_info.value) + assert exc_info.value.status_code == 502 diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index 279f16a3675..a29365544df 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -6,16 +6,29 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +import litellm + sys.path.insert( 0, os.path.abspath("../../../../..") ) # Adds the parent directory to the system path -from litellm import supports_reasoning +from litellm import get_model_info, supports_reasoning from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig from litellm.types.llms.openai import ChatCompletionToolCallFunctionChunk from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message +@pytest.fixture(autouse=True) +def force_local_model_cost(monkeypatch): + """Force local model cost map usage for all tests in this file.""" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + # Refresh model_cost from local map + import litellm + from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map + + litellm.model_cost = get_model_cost_map(url=litellm.model_cost_map_url) + + def test_handle_message_content_with_tool_calls(): config = FireworksAIConfig() message = Message( @@ -62,7 +75,6 @@ def test_handle_message_content_with_tool_calls(): def test_supports_reasoning_effort(): """Test that reasoning_effort is only supported for specific Fireworks AI models.""" - # Models that support reasoning_effort supported_models = [ "fireworks_ai/accounts/fireworks/models/qwen3-8b", "fireworks_ai/accounts/fireworks/models/qwen3-32b", @@ -72,11 +84,13 @@ def test_supports_reasoning_effort(): "fireworks_ai/accounts/fireworks/models/glm-4p5", "fireworks_ai/accounts/fireworks/models/glm-4p5-air", "fireworks_ai/accounts/fireworks/models/glm-4p6", + "fireworks_ai/accounts/fireworks/models/glm-4p7", + "fireworks_ai/accounts/fireworks/models/glm-5p1", "fireworks_ai/accounts/fireworks/models/gpt-oss-120b", "fireworks_ai/accounts/fireworks/models/gpt-oss-20b", + "fireworks_ai/glm-5p1", ] - # Models that don't support reasoning_effort unsupported_models = [ "fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct", "fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct", @@ -97,19 +111,74 @@ def test_get_supported_openai_params_reasoning_effort(): """Test that reasoning_effort is only included in supported params for models that support it.""" config = FireworksAIConfig() - # Model that supports reasoning_effort supported_params = config.get_supported_openai_params( - "fireworks_ai/accounts/fireworks/models/qwen3-8b" + "fireworks_ai/accounts/fireworks/models/glm-5p1" ) assert "reasoning_effort" in supported_params - # Model that doesn't support reasoning_effort unsupported_params = config.get_supported_openai_params( "fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct" ) assert "reasoning_effort" not in unsupported_params +def test_get_supported_openai_params_parallel_tool_calls(): + """Test that parallel_tool_calls is included for models that support function calling.""" + config = FireworksAIConfig() + + supported_params = config.get_supported_openai_params( + "fireworks_ai/accounts/fireworks/models/glm-4p6" + ) + assert "parallel_tool_calls" in supported_params + + unsupported_params = config.get_supported_openai_params( + "fireworks_ai/accounts/fireworks/models/glm-5p1" + ) + assert "parallel_tool_calls" not in unsupported_params + + +def test_get_supported_openai_params_parallel_tool_calls_without_tool_choice( + monkeypatch, +): + """Test that parallel_tool_calls is gated on tools, not tool_choice.""" + config = FireworksAIConfig() + model = "fireworks_ai/test-tools-without-tool-choice" + monkeypatch.setitem( + litellm.model_cost, + model, + { + "supports_function_calling": True, + "supports_tool_choice": False, + }, + ) + + supported_params = config.get_supported_openai_params(model) + + assert "tools" in supported_params + assert "parallel_tool_calls" in supported_params + assert "tool_choice" not in supported_params + + +def test_get_model_info_respects_explicit_fireworks_capabilities(): + """Test that get_model_info preserves explicit capability flags from the model map.""" + model_info = get_model_info("fireworks_ai/accounts/fireworks/models/glm-5p1") + + assert model_info["supports_function_calling"] is False + assert model_info["supports_reasoning"] is True + assert model_info["supports_tool_choice"] is False + + +def test_get_provider_info_omits_false_supports_reasoning(monkeypatch): + """Test that Fireworks only overrides supports_reasoning for supported models.""" + config = FireworksAIConfig() + model = "fireworks_ai/test-reasoning-false" + monkeypatch.setitem(litellm.model_cost, model, {"supports_reasoning": False}) + + info = config.get_provider_info(model) + + assert "supports_reasoning" not in info + + def test_add_transform_inline_image_block_skips_data_urls(): """ data: URLs must not have #transform=inline appended — doing so corrupts the @@ -234,6 +303,14 @@ def test_transform_messages_helper_removes_provider_specific_fields(): assert "provider_specific_fields" not in msg +def test_unmapped_model_fallback_function_calling(): + """Test that a model not in model_cost still defaults to supporting function calling for Fireworks.""" + config = FireworksAIConfig() + model = "fireworks_ai/unmapped-future-model" + info = config.get_provider_info(model) + assert info["supports_function_calling"] is True + + def test_transform_messages_helper_strips_thinking_blocks(): """thinking_blocks must not be forwarded to Fireworks chat completions.""" config = FireworksAIConfig() diff --git a/tests/test_litellm/llms/reducto/__init__.py b/tests/test_litellm/llms/reducto/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/test_litellm/llms/reducto/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_litellm/llms/reducto/test_cost.py b/tests/test_litellm/llms/reducto/test_cost.py new file mode 100644 index 00000000000..73340dc8729 --- /dev/null +++ b/tests/test_litellm/llms/reducto/test_cost.py @@ -0,0 +1,122 @@ +import litellm +import pytest + +from litellm.cost_calculator import completion_cost +from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo + + +def test_ocr_cost_prefers_credit_pricing_when_pages_processed_is_none(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {"ocr_cost_per_credit": 0.003}, + ) + + response = OCRResponse( + pages=[OCRPage(index=0, markdown="credit priced")], + model="parse-v3", + usage_info=OCRUsageInfo(pages_processed=None, credits=10), + ) + + cost = completion_cost( + completion_response=response, + model="reducto/parse-v3", + custom_llm_provider="reducto", + call_type="ocr", + ) + + assert cost == 0.03 + + +def test_ocr_cost_prefers_zero_credit_pricing_over_page_pricing(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: { + "ocr_cost_per_credit": 0.0, + "ocr_cost_per_page": 0.5, + }, + ) + + response = OCRResponse( + pages=[OCRPage(index=0, markdown="free credit priced")], + model="parse-v3", + usage_info=OCRUsageInfo(pages_processed=2, credits=10), + ) + + cost = completion_cost( + completion_response=response, + model="reducto/parse-v3", + custom_llm_provider="reducto", + call_type="ocr", + ) + + assert cost == 0.0 + + +def test_ocr_cost_falls_back_to_page_pricing(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {"ocr_cost_per_page": 0.5}, + ) + + response = OCRResponse( + pages=[OCRPage(index=0, markdown="page priced")], + model="mistral-ocr-latest", + usage_info=OCRUsageInfo(pages_processed=2), + ) + + cost = completion_cost( + completion_response=response, + model="mistral/mistral-ocr-latest", + custom_llm_provider="mistral", + call_type="ocr", + ) + + assert cost == 1.0 + + +def test_ocr_cost_returns_zero_when_no_pricing_and_no_pages(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {}, + ) + + response = OCRResponse( + pages=[OCRPage(index=0, markdown="unpriced")], + model="parse-v3", + usage_info=OCRUsageInfo(pages_processed=None, credits=5), + ) + + cost = completion_cost( + completion_response=response, + model="reducto/parse-v3", + custom_llm_provider="reducto", + call_type="ocr", + ) + + assert cost == 0.0 + + +def test_ocr_cost_raises_when_pages_processed_missing_for_page_pricing(monkeypatch): + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, custom_llm_provider=None: {"ocr_cost_per_page": 0.5}, + ) + + response = OCRResponse( + pages=[OCRPage(index=0, markdown="missing pages")], + model="mistral-ocr-latest", + usage_info=OCRUsageInfo(pages_processed=None), + ) + + with pytest.raises(ValueError, match="OCR response pages_processed is None"): + completion_cost( + completion_response=response, + model="mistral/mistral-ocr-latest", + custom_llm_provider="mistral", + call_type="ocr", + ) diff --git a/tests/test_litellm/llms/reducto/test_model_info.py b/tests/test_litellm/llms/reducto/test_model_info.py new file mode 100644 index 00000000000..de7a3ccba64 --- /dev/null +++ b/tests/test_litellm/llms/reducto/test_model_info.py @@ -0,0 +1,44 @@ +import uuid + +import litellm + +from litellm.utils import _invalidate_model_cost_lowercase_map + + +def test_reducto_provider_registration(): + model, custom_llm_provider, _, _ = litellm.get_llm_provider( + model="reducto/parse-v3" + ) + + assert model == "parse-v3" + assert custom_llm_provider == "reducto" + + +def test_get_model_info_preserves_ocr_cost_per_credit(): + test_model_name = f"reducto/test-cost-propagation-{uuid.uuid4().hex[:12]}" + previous_model_entry = litellm.model_cost.get(test_model_name) + _invalidate_model_cost_lowercase_map() + + try: + litellm.register_model( + { + test_model_name: { + "litellm_provider": "reducto", + "mode": "ocr", + "ocr_cost_per_credit": 0.003, + } + } + ) + + model_info = litellm.get_model_info( + model=test_model_name, + custom_llm_provider="reducto", + ) + + assert model_info.get("ocr_cost_per_credit") == 0.003 + finally: + if previous_model_entry is None: + litellm.model_cost.pop(test_model_name, None) + else: + litellm.model_cost[test_model_name] = previous_model_entry + _invalidate_model_cost_lowercase_map() diff --git a/tests/test_litellm/llms/reducto/test_parse_legacy.py b/tests/test_litellm/llms/reducto/test_parse_legacy.py new file mode 100644 index 00000000000..db19460baa3 --- /dev/null +++ b/tests/test_litellm/llms/reducto/test_parse_legacy.py @@ -0,0 +1,59 @@ +import json + +import litellm +import pytest + + +@pytest.fixture() +def disable_aiohttp_transport(): + original_disable_aiohttp = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = True + litellm.in_memory_llm_clients_cache.flush_cache() + try: + yield + finally: + litellm.disable_aiohttp_transport = original_disable_aiohttp + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.mark.asyncio +async def test_parse_legacy_wraps_enhance_under_options( + disable_aiohttp_transport, respx_mock +): + upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( + json={"file_id": "reducto://legacy.pdf"} + ) + parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( + json={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Legacy parse", + "blocks": [{"content": "Legacy parse", "bbox": {"page": 1}}], + } + ] + }, + } + ) + + response = await litellm.aocr( + model="reducto/parse-legacy", + document={ + "type": "file", + "file": b"%PDF-1.4 legacy", + "mime_type": "application/pdf", + }, + api_key="legacy-key", + api_base="https://platform.reducto.ai", + enhance={"agentic": [{"type": "table"}]}, + ) + + assert upload_route.called + assert parse_route.called + request_body = json.loads(parse_route.calls[0].request.read()) + assert request_body == { + "document_url": "reducto://legacy.pdf", + "options": {"enhance": {"agentic": [{"type": "table"}]}}, + } + assert response.pages[0].markdown == "Legacy parse" diff --git a/tests/test_litellm/llms/reducto/test_parse_v3.py b/tests/test_litellm/llms/reducto/test_parse_v3.py new file mode 100644 index 00000000000..140b9737dc0 --- /dev/null +++ b/tests/test_litellm/llms/reducto/test_parse_v3.py @@ -0,0 +1,152 @@ +import json + +import litellm +import pytest + + +def _reducto_parse_response() -> dict: + return { + "job_id": "job_123", + "usage": {"num_pages": 3, "credits": 3}, + "result": { + "chunks": [ + { + "content": "Page 1 block A", + "blocks": [ + { + "content": "Page 1 block A", + "bbox": {"page": 1}, + "kind": "text", + } + ], + }, + { + "content": "Page 2 block A", + "blocks": [ + { + "content": "Page 2 block A", + "bbox": {"page": 2}, + "kind": "table", + } + ], + }, + { + "content": "Page 1 block B", + "blocks": [ + { + "content": "Page 1 block B", + "bbox": {"page": 1}, + "kind": "text", + } + ], + }, + { + "content": "Page 3 block A", + "blocks": [ + { + "content": "Page 3 block A", + "bbox": {"page": 3}, + "kind": "figure", + } + ], + }, + ] + }, + } + + +@pytest.fixture() +def disable_aiohttp_transport(): + original_disable_aiohttp = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = True + litellm.in_memory_llm_clients_cache.flush_cache() + try: + yield + finally: + litellm.disable_aiohttp_transport = original_disable_aiohttp + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.mark.asyncio +async def test_parse_v3_file_upload_and_response_mapping( + disable_aiohttp_transport, respx_mock +): + upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( + json={"file_id": "reducto://uploaded.pdf"} + ) + parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( + json=_reducto_parse_response() + ) + + response = await litellm.aocr( + model="reducto/parse-v3", + document={ + "type": "file", + "file": b"%PDF-1.4 reducto", + "mime_type": "application/pdf", + }, + api_key="test-key", + api_base="https://platform.reducto.ai", + formatting={"table_output_format": "html"}, + retrieval={"chunk_mode": "section"}, + settings={"ocr_system": "standard"}, + ) + + assert upload_route.called + assert parse_route.called + assert len(upload_route.calls) == 1 + assert len(parse_route.calls) == 1 + + upload_request = upload_route.calls[0].request + assert upload_request.headers["authorization"] == "Bearer test-key" + assert "application/json" not in upload_request.headers["content-type"] + upload_body = upload_request.read() + assert b'filename="document"' in upload_body + assert b"application/pdf" in upload_body + + parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert parse_request_body["input"] == "reducto://uploaded.pdf" + assert parse_request_body["formatting"] == {"table_output_format": "html"} + assert parse_request_body["retrieval"] == {"chunk_mode": "section"} + assert parse_request_body["settings"] == {"ocr_system": "standard"} + + assert response.usage_info is not None + assert response.usage_info.credits == 3 + assert response.usage_info.pages_processed == 3 + assert len(response.pages) == 3 + assert response.pages[0].index == 0 + assert response.pages[0].markdown == "Page 1 block A\n\nPage 1 block B" + assert getattr(response.pages[0], "blocks")[0]["bbox"]["page"] == 1 + assert response.pages[1].markdown == "Page 2 block A" + assert response.pages[2].markdown == "Page 3 block A" + assert response._hidden_params["reducto_raw"]["usage"]["credits"] == 3 + + +@pytest.mark.asyncio +async def test_parse_v3_reducto_id_passthrough_skips_upload( + disable_aiohttp_transport, respx_mock +): + upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( + json={"file_id": "reducto://should-not-upload.pdf"} + ) + parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( + json=_reducto_parse_response() + ) + + response = await litellm.aocr( + model="reducto/parse-v3", + document={ + "type": "document_url", + "document_url": "reducto://already-uploaded.pdf", + }, + api_key="test-key", + api_base="https://platform.reducto.ai", + retrieval={"chunk_mode": "section"}, + ) + + assert not upload_route.called + assert parse_route.called + parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert parse_request_body["input"] == "reducto://already-uploaded.pdf" + assert parse_request_body["retrieval"]["chunk_mode"] == "section" + assert response.pages[0].markdown.startswith("Page 1 block A") diff --git a/tests/test_litellm/llms/reducto/test_upload.py b/tests/test_litellm/llms/reducto/test_upload.py new file mode 100644 index 00000000000..4fae90436bb --- /dev/null +++ b/tests/test_litellm/llms/reducto/test_upload.py @@ -0,0 +1,213 @@ +import json +import os +from unittest.mock import AsyncMock, Mock + +import httpx +import litellm +import pytest + +from litellm.llms.reducto.common import ( + extract_file_id_or_bytes, + upload_bytes_async, + upload_bytes_sync, +) + + +@pytest.fixture() +def disable_aiohttp_transport(monkeypatch): + original_disable_aiohttp = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = True + litellm.in_memory_llm_clients_cache.flush_cache() + monkeypatch.setenv("REDUCTO_API_KEY", "env-reducto-key") + try: + yield + finally: + litellm.disable_aiohttp_transport = original_disable_aiohttp + litellm.in_memory_llm_clients_cache.flush_cache() + os.environ.pop("REDUCTO_API_KEY", None) + + +@pytest.mark.asyncio +async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport): + with pytest.raises(litellm.BadRequestError, match="upload the file first"): + await litellm.aocr( + model="reducto/parse-v3", + document={ + "type": "document_url", + "document_url": "https://example.com/document.pdf", + }, + api_key="test-key", + api_base="https://platform.reducto.ai", + ) + + +@pytest.mark.asyncio +async def test_parse_v3_image_data_uri_upload_uses_image_mime( + disable_aiohttp_transport, respx_mock +): + upload_route = respx_mock.post("https://custom.reducto.test/upload").respond( + json={"file_id": "reducto://uploaded-image.png"} + ) + parse_route = respx_mock.post("https://custom.reducto.test/parse").respond( + json={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Image OCR", + "blocks": [{"content": "Image OCR", "bbox": {"page": 1}}], + } + ] + }, + } + ) + + response = await litellm.aocr( + model="reducto/parse-v3", + document={ + "type": "file", + "file": b"\x89PNG\r\n\x1a\npng", + "mime_type": "image/png", + }, + api_key="programmatic-key", + api_base="https://custom.reducto.test/", + ) + + assert upload_route.called + assert parse_route.called + upload_request = upload_route.calls[0].request + assert upload_request.headers["authorization"] == "Bearer programmatic-key" + assert b"image/png" in upload_request.read() + + parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert parse_request_body["input"] == "reducto://uploaded-image.png" + assert response.pages[0].markdown == "Image OCR" + + +@pytest.mark.asyncio +async def test_parse_v3_uses_programmatic_api_key_over_env( + disable_aiohttp_transport, respx_mock +): + upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( + json={"file_id": "reducto://uploaded.pdf"} + ) + parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( + json={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Programmatic auth", + "blocks": [ + {"content": "Programmatic auth", "bbox": {"page": 1}} + ], + } + ] + }, + } + ) + + await litellm.aocr( + model="reducto/parse-v3", + document={ + "type": "file", + "file": b"%PDF-1.4 auth", + "mime_type": "application/pdf", + }, + api_key="passed-key", + api_base="https://platform.reducto.ai", + ) + + assert upload_route.calls[0].request.headers["authorization"] == "Bearer passed-key" + assert parse_route.calls[0].request.headers["authorization"] == "Bearer passed-key" + + +def test_upload_bytes_sync_uses_shared_client(monkeypatch): + captured = {} + + def fake_post(*, url, headers, files, timeout): + captured["url"] = url + captured["headers"] = headers + captured["files"] = files + captured["timeout"] = timeout + return httpx.Response( + 200, + json={"file_id": "reducto://sync-upload"}, + request=httpx.Request("POST", url), + ) + + sync_post = Mock(side_effect=fake_post) + monkeypatch.setattr(litellm.module_level_client, "post", sync_post) + + class ForbiddenSyncClient: + def __init__(self, *args, **kwargs): + raise AssertionError("should not construct") + + monkeypatch.setattr(httpx, "Client", ForbiddenSyncClient) + + file_id = upload_bytes_sync( + raw_bytes=b"%PDF-1.4 sync", + mime="application/pdf", + api_key="sync-key", + api_base="https://sync.reducto.test/", + ) + + assert file_id == "reducto://sync-upload" + sync_post.assert_called_once() + assert captured["url"] == "https://sync.reducto.test/upload" + assert captured["headers"] == {"Authorization": "Bearer sync-key"} + assert captured["files"]["file"] == ( + "document", + b"%PDF-1.4 sync", + "application/pdf", + ) + + +@pytest.mark.asyncio +async def test_upload_bytes_async_uses_shared_aclient(monkeypatch): + captured = {} + + async def fake_post(*, url, headers, files, timeout): + captured["url"] = url + captured["headers"] = headers + captured["files"] = files + captured["timeout"] = timeout + return httpx.Response( + 200, + json={"file_id": "reducto://async-upload"}, + request=httpx.Request("POST", url), + ) + + async_post = AsyncMock(side_effect=fake_post) + monkeypatch.setattr(litellm.module_level_aclient, "post", async_post) + + class ForbiddenAsyncClient: + def __init__(self, *args, **kwargs): + raise AssertionError("should not construct") + + monkeypatch.setattr(httpx, "AsyncClient", ForbiddenAsyncClient) + + file_id = await upload_bytes_async( + raw_bytes=b"%PDF-1.4 async", + mime="application/pdf", + api_key="async-key", + api_base="https://async.reducto.test/", + ) + + assert file_id == "reducto://async-upload" + async_post.assert_awaited_once() + assert captured["url"] == "https://async.reducto.test/upload" + assert captured["headers"] == {"Authorization": "Bearer async-key"} + assert captured["files"]["file"] == ( + "document", + b"%PDF-1.4 async", + "application/pdf", + ) + + +def test_extract_file_id_or_bytes_raises_on_malformed_data_uri(): + with pytest.raises(litellm.BadRequestError, match="Invalid Reducto data URI"): + extract_file_id_or_bytes("data:application/pdf", model="reducto/parse-v3") + + with pytest.raises(litellm.BadRequestError, match="Invalid Reducto base64 payload"): + extract_file_id_or_bytes("data:;base64,!!!not-base64", model="reducto/parse-v3") diff --git a/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py b/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py index 9d7706557b5..7e13459bca1 100644 --- a/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py +++ b/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py @@ -12,18 +12,46 @@ from litellm.llms.sagemaker.completion.transformation import SagemakerConfig # --------------------------------------------------------------------------- # -# SAGEMAKER_RESPONSE_STREAM_SHAPE eager-load tests # +# get_sagemaker_response_stream_shape lazy-load tests # # --------------------------------------------------------------------------- # -def test_sagemaker_response_stream_shape_loaded_at_import(): +@pytest.fixture(autouse=True) +def _reset_sagemaker_response_stream_shape_cache(): + """Prevent lru_cache leakage between tests in this module.""" + import litellm.llms.sagemaker.common_utils as mod + + mod.get_sagemaker_response_stream_shape.cache_clear() + yield + mod.get_sagemaker_response_stream_shape.cache_clear() + + +def test_sagemaker_response_stream_shape_lazy_loads_once(): """ - SAGEMAKER_RESPONSE_STREAM_SHAPE is resolved at module import time. + get_sagemaker_response_stream_shape() loads from botocore at most once per process. + """ + from unittest.mock import MagicMock, patch + + import litellm.llms.sagemaker.common_utils as mod + + sentinel = MagicMock() + with patch.object( + mod, "_load_sagemaker_response_stream_shape", return_value=sentinel + ) as mock_load: + assert mod.get_sagemaker_response_stream_shape() is sentinel + assert mod.get_sagemaker_response_stream_shape() is sentinel + mock_load.assert_called_once() + + +def test_sagemaker_response_stream_shape_loaded_on_first_access(): + """ + get_sagemaker_response_stream_shape() loads once on first use. In a standard environment with botocore installed it must be non-None. """ - from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + pytest.importorskip("botocore") + from litellm.llms.sagemaker.common_utils import get_sagemaker_response_stream_shape - assert SAGEMAKER_RESPONSE_STREAM_SHAPE is not None + assert get_sagemaker_response_stream_shape() is not None def test_sagemaker_response_stream_shape_load_failure_returns_none(): @@ -36,6 +64,7 @@ def test_sagemaker_response_stream_shape_load_failure_returns_none(): import litellm.llms.sagemaker.common_utils as mod + pytest.importorskip("botocore") with patch( "botocore.loaders.Loader.load_service_model", side_effect=Exception("no data"), @@ -49,14 +78,16 @@ def test_sagemaker_response_stream_shape_is_structure_shape(): The loaded shape should be the botocore StructureShape for InvokeEndpointWithResponseStreamOutput, not a plain dict or any other type. """ + pytest.importorskip("botocore") from botocore.model import StructureShape - from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + from litellm.llms.sagemaker.common_utils import get_sagemaker_response_stream_shape - assert SAGEMAKER_RESPONSE_STREAM_SHAPE is not None, ( - "SAGEMAKER_RESPONSE_STREAM_SHAPE is None — botocore may not be installed" - ) - shape: StructureShape = SAGEMAKER_RESPONSE_STREAM_SHAPE # remove Optional + shape = get_sagemaker_response_stream_shape() + assert ( + shape is not None + ), "get_sagemaker_response_stream_shape() is None — botocore may not be installed" + shape: StructureShape = shape # remove Optional assert isinstance(shape, StructureShape) assert shape.name == "InvokeEndpointWithResponseStreamOutput" @@ -64,29 +95,25 @@ def test_sagemaker_response_stream_shape_is_structure_shape(): def test_sagemaker_response_stream_shape_not_reloaded_on_new_decoder(): """ Creating multiple AWSEventStreamDecoder instances must not trigger - additional botocore Loader calls — the shape is resolved once at import - time and reused. + additional botocore Loader calls — the shape is cached after first access. """ - from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + from litellm.llms.sagemaker.common_utils import get_sagemaker_response_stream_shape - decoder_a = AWSEventStreamDecoder(model="test-model-a") - decoder_b = AWSEventStreamDecoder(model="test-model-b") + decoder_a = AWSEventStreamDecoder.__new__(AWSEventStreamDecoder) + decoder_b = AWSEventStreamDecoder.__new__(AWSEventStreamDecoder) - # Both decoders should use the same pre-loaded shape object (identity check) assert "_response_stream_shape_cache" not in decoder_a.__dict__ assert "_response_stream_shape_cache" not in decoder_b.__dict__ - # The module constant is still the same object - from litellm.llms.sagemaker.common_utils import ( - SAGEMAKER_RESPONSE_STREAM_SHAPE as shape_after, - ) - assert SAGEMAKER_RESPONSE_STREAM_SHAPE is shape_after + first = get_sagemaker_response_stream_shape() + second = get_sagemaker_response_stream_shape() + assert first is second def test_sagemaker_parse_message_from_event_raises_on_none_shape(): """ - When SAGEMAKER_RESPONSE_STREAM_SHAPE is None (botocore unavailable), - _parse_message_from_event must raise ValueError before touching the + When get_sagemaker_response_stream_shape() returns None (botocore unavailable), + _parse_message_from_event must raise SagemakerError before touching the botocore parser — not an opaque AttributeError from inside botocore. """ from unittest.mock import MagicMock, patch @@ -94,10 +121,14 @@ def test_sagemaker_parse_message_from_event_raises_on_none_shape(): import litellm.llms.sagemaker.common_utils as mod from litellm.llms.sagemaker.common_utils import SagemakerError - decoder = AWSEventStreamDecoder(model="test-model") + decoder = AWSEventStreamDecoder.__new__(AWSEventStreamDecoder) + decoder.model = "test-model" + decoder.parser = MagicMock() + decoder.content_blocks = [] + decoder.is_messages_api = None mock_event = MagicMock() - with patch.object(mod, "SAGEMAKER_RESPONSE_STREAM_SHAPE", None): + with patch.object(mod, "get_sagemaker_response_stream_shape", return_value=None): with pytest.raises(SagemakerError) as exc_info: decoder._parse_message_from_event(mock_event) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 1e0ad04c3c2..45b9f4293fa 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -2097,6 +2097,125 @@ def test_is_gemini_3_or_newer(): assert VertexGeminiConfig._is_gemini_3_or_newer("") == False +def test_forward_gemini_function_call_id_vertex_vs_google_ai_studio(): + """Vertex AI rejects `id` on function_call/function_response; Google AI Studio accepts it on Gemini 3.5+.""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + model = "gemini-3.5-flash" + assert ( + VertexGeminiConfig._forward_gemini_function_call_id(model, "vertex_ai") is False + ) + assert ( + VertexGeminiConfig._forward_gemini_function_call_id(model, "vertex_ai_beta") + is False + ) + assert VertexGeminiConfig._forward_gemini_function_call_id(model, "gemini") is True + assert VertexGeminiConfig._forward_gemini_function_call_id(model, None) is False + assert ( + VertexGeminiConfig._forward_gemini_function_call_id( + "gemini-2.5-flash", "gemini" + ) + is False + ) + + +def test_vertex_ai_gemini_35_tool_calls_omit_function_call_id(): + """Regression: Vertex must not send OpenAI tool_call id inside Gemini function_call parts.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + messages = [ + {"role": "user", "content": "Explore this directory"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_50e7e0fe0989464a89f188eda443", + "type": "function", + "function": { + "name": "read", + "arguments": '{"filePath": "/tmp"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_50e7e0fe0989464a89f188eda443", + "content": "ok", + }, + ] + + contents = _gemini_convert_messages_with_history( + messages=messages, + model="gemini-3.5-flash", + custom_llm_provider="vertex_ai", + ) + + for content in contents: + for part in content.get("parts", []): + fc = part.get("function_call") + if fc is not None: + assert "id" not in fc, f"Vertex payload must not include id: {fc}" + fr = part.get("function_response") + if fr is not None: + assert "id" not in fr, f"Vertex payload must not include id: {fr}" + + +def test_google_ai_studio_gemini_35_tool_calls_include_function_call_id(): + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + tool_call_id = "call_50e7e0fe0989464a89f188eda443" + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": tool_call_id, + "type": "function", + "function": { + "name": "read", + "arguments": '{"filePath": "/tmp"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": "ok", + }, + ] + + contents = _gemini_convert_messages_with_history( + messages=messages, + model="gemini-3.5-flash", + custom_llm_provider="gemini", + ) + + function_call_ids = [] + function_response_ids = [] + for content in contents: + for part in content.get("parts", []): + fc = part.get("function_call") + if fc is not None: + function_call_ids.append(fc.get("id")) + fr = part.get("function_response") + if fr is not None: + function_response_ids.append(fr.get("id")) + + assert function_call_ids == [tool_call_id] + assert function_response_ids == [tool_call_id] + + def test_reasoning_effort_maps_to_thinking_level_gemini_3(): """Test that reasoning_effort maps to thinking_level AND includeThoughts for Gemini 3+ models""" from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( @@ -2959,6 +3078,38 @@ def test_vertex_ai_gemini3_tool_combination_no_drop(): assert len(tools) == 3 +def test_vertex_ai_mixed_tools_and_web_search_options_drops_search(): + """ + When function tools and web_search_options are sent separately (Codex-style), + search tools are dropped unless include_server_side_tool_invocations is set. + """ + v = VertexGeminiConfig() + optional_params: dict = {} + non_default_params = { + "tools": [ + { + "type": "function", + "function": {"name": "exec_command", "description": "Run a command"}, + } + ], + "web_search_options": {}, + } + + result = v.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="gemini-3.5-flash", + drop_params=True, + ) + + assert not result.get("include_server_side_tool_invocations") + tool_keys = set() + for tool in result.get("tools", []): + tool_keys.update(tool.keys()) + assert "function_declarations" in tool_keys + assert "googleSearch" not in tool_keys + + def test_vertex_ai_openai_web_search_tool_transformation(): """ Test that OpenAI-style web_search and web_search_preview tools are transformed to googleSearch. @@ -3499,7 +3650,12 @@ def test_video_metadata_supported_for_all_gemini_models(): } ] - for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]: + for model in [ + "gemini-1.5-pro", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-3-pro-preview", + ]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = None @@ -3509,19 +3665,25 @@ def test_video_metadata_supported_for_all_gemini_models(): break assert file_part is not None, f"{model}: file part should exist" - assert "video_metadata" in file_part, f"{model}: video_metadata should be present" + assert ( + "video_metadata" in file_part + ), f"{model}: video_metadata should be present" assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" # Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global for model in ["gemini-3-pro-preview"]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = next(p for p in contents[0]["parts"] if "file_data" in p) - assert "media_resolution" in file_part, f"{model}: media_resolution should be present" + assert ( + "media_resolution" in file_part + ), f"{model}: media_resolution should be present" for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = next(p for p in contents[0]["parts"] if "file_data" in p) - assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set" + assert ( + "media_resolution" not in file_part + ), f"{model}: per-part media_resolution should not be set" def test_chunk_parser_handles_prompt_feedback_block(): @@ -4154,8 +4316,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_in_prompt(): # DOCUMENT tokens should be included in text_tokens: 8 (TEXT) + 774 (DOCUMENT) = 782 assert result.prompt_tokens_details is not None - assert result.prompt_tokens_details.text_tokens == 782, \ - "DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)" + assert ( + result.prompt_tokens_details.text_tokens == 782 + ), "DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)" # Verify completion token details assert result.completion_tokens_details is not None @@ -4190,8 +4353,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_cached(): # DOCUMENT cached tokens map to cached_text_tokens, so: # text_tokens = (8 TEXT + 774 DOCUMENT) - 400 cached = 382 - assert result.prompt_tokens_details.text_tokens == 382, \ - "text_tokens should be (8 + 774) - 400 cached = 382" + assert ( + result.prompt_tokens_details.text_tokens == 382 + ), "text_tokens should be (8 + 774) - 400 cached = 382" assert result.prompt_tokens_details.cached_tokens == 400 @@ -4661,7 +4825,9 @@ def test_mid_stream_429_error_raises_during_iteration(): { "content": { "role": "model", - "parts": [{"text": "Let me think about this...", "thought": True}], + "parts": [ + {"text": "Let me think about this...", "thought": True} + ], }, "index": 0, } @@ -4681,7 +4847,9 @@ def test_mid_stream_429_error_raises_during_iteration(): { "content": { "role": "model", - "parts": [{"text": "I'll generate the image now.", "thought": True}], + "parts": [ + {"text": "I'll generate the image now.", "thought": True} + ], }, "index": 0, } diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex.py b/tests/test_litellm/llms/vertex_ai/test_vertex.py index be0e59e8b7d..ec73e5e42be 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex.py @@ -1517,39 +1517,31 @@ def test_vertex_parallel_tool_calls_true(): assert "tools" in optional_params -def test_vertex_parallel_tool_calls_false_multiple_tools_error(): +def test_vertex_parallel_tool_calls_false_multiple_tools_dropped(): """ - Test that parallel_tool_calls = False with multiple tools raises UnsupportedParamsError - when drop_params is False. + parallel_tool_calls=False with multiple tools is dropped for Gemini + (unsupported upstream). Request should succeed without the param. """ tools = [ {"type": "function", "function": {"name": "get_weather"}}, {"type": "function", "function": {"name": "get_time"}}, ] - with pytest.raises(litellm.utils.UnsupportedParamsError) as excinfo: - get_optional_params( - model="gemini-1.5-pro", - custom_llm_provider="vertex_ai", - tools=tools, - parallel_tool_calls=False, - ) - assert ( - "`parallel_tool_calls=False` is not supported by Gemini when multiple tools are" - in str(excinfo.value) + optional_params = get_optional_params( + model="gemini-1.5-pro", + custom_llm_provider="vertex_ai", + tools=tools, + parallel_tool_calls=False, ) + assert "parallel_tool_calls" not in optional_params + assert "tools" in optional_params - # works when specified as "functions" - with pytest.raises(litellm.utils.UnsupportedParamsError) as excinfo: - get_optional_params( - model="gemini-1.5-pro", - custom_llm_provider="vertex_ai", - functions=tools, - parallel_tool_calls=False, - ) - assert ( - "`parallel_tool_calls=False` is not supported by Gemini when multiple tools are" - in str(excinfo.value) + optional_params = get_optional_params( + model="gemini-1.5-pro", + custom_llm_provider="vertex_ai", + functions=tools, + parallel_tool_calls=False, ) + assert "parallel_tool_calls" not in optional_params def test_vertex_parallel_tool_calls_false_single_tool(): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index 88aac07a0c9..2cf97081806 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -1,3 +1,4 @@ +import asyncio import json import os import sys @@ -1448,3 +1449,474 @@ class TestVertexBase: aws_creds = supplier.get_aws_security_credentials(context=None, request=None) assert isinstance(aws_creds, AwsSecurityCredentials) + + @pytest.mark.asyncio + async def test_single_flight_refresh(self): + """Under high concurrency, only one coroutine should refresh expired credentials.""" + import asyncio + + vertex_base = VertexBase() + + mock_creds = MagicMock() + mock_creds.token = "expired-token" + mock_creds.expired = True + mock_creds.expiry = None + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + + refresh_call_count = 0 + + with ( + patch.object( + vertex_base, "load_auth", return_value=(mock_creds, "project-1") + ), + patch.object(vertex_base, "refresh_auth") as mock_refresh, + ): + + async def slow_refresh(creds): + nonlocal refresh_call_count + refresh_call_count += 1 + await asyncio.sleep(0.05) # simulate network latency + creds.token = "refreshed-token" + creds.expired = False + + # refresh_auth is sync, but we need to count calls. + # get_access_token_async wraps it with asyncify, so the sync side_effect works. + def sync_refresh_impl(creds): + nonlocal refresh_call_count + refresh_call_count += 1 + creds.token = "refreshed-token" + creds.expired = False + + mock_refresh.side_effect = sync_refresh_impl + + # Launch 50 concurrent requests + tasks = [ + vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + for _ in range(50) + ] + results = await asyncio.gather(*tasks) + + # All should return the refreshed token + for token, project in results: + assert token == "refreshed-token" + assert project == "project-1" + + # refresh_auth should be called exactly once (single-flight) + assert ( + refresh_call_count == 1 + ), f"Expected 1 refresh call, got {refresh_call_count}" + + @pytest.mark.asyncio + async def test_async_reauthentication_uses_async_single_flight(self): + """Concurrent async reauth should reload once without using the sync path.""" + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + stale_creds = MagicMock() + stale_creds.token = "expired-token" + stale_creds.token_state = TokenState.INVALID + stale_creds.project_id = "project-1" + stale_creds.quota_project_id = "project-1" + + refreshed_creds = MagicMock() + refreshed_creds.token = "refreshed-token" + refreshed_creds.token_state = TokenState.FRESH + refreshed_creds.project_id = "project-1" + refreshed_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + cache_key = (json.dumps(credentials), "project-1") + vertex_base._credentials_project_mapping[cache_key] = ( + stale_creds, + "project-1", + ) + + load_call_count = 0 + + def load_auth_impl(*_args, **_kwargs): + nonlocal load_call_count + load_call_count += 1 + return refreshed_creds, "project-1" + + with ( + patch.object( + vertex_base, + "refresh_auth", + side_effect=Exception("Reauthentication is needed"), + ), + patch.object(vertex_base, "load_auth", side_effect=load_auth_impl), + patch.object(vertex_base, "get_access_token") as mock_get_access_token, + ): + results = await asyncio.gather( + *[ + vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + for _ in range(10) + ] + ) + + assert results == [("refreshed-token", "project-1")] * 10 + assert load_call_count == 1 + mock_get_access_token.assert_not_called() + + @pytest.mark.asyncio + async def test_background_refresh_when_near_expiry(self): + """When token_state is STALE (within the 3:45 REFRESH_THRESHOLD window), + return the current token immediately and refresh in the background — + zero added latency.""" + import asyncio + + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + + # Simulate STALE state: token is usable but near expiry. + mock_creds = MagicMock() + mock_creds.token = "near-expiry-token" + mock_creds.token_state = TokenState.STALE + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + + with ( + patch.object( + vertex_base, "load_auth", return_value=(mock_creds, "project-1") + ), + patch.object(vertex_base, "refresh_auth") as mock_refresh, + ): + + def mock_refresh_impl(creds): + creds.token = "refreshed-token" + creds.token_state = TokenState.FRESH + + mock_refresh.side_effect = mock_refresh_impl + + token, project = await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + + # Should return the current (still usable) token immediately + assert token == "near-expiry-token" + + # Let the background refresh task run + await asyncio.sleep(0.05) + + assert mock_refresh.called, "Background refresh should have been triggered" + + @pytest.mark.asyncio + async def test_stale_malformed_token_blocks_on_refresh(self): + """Malformed STALE tokens should refresh instead of failing validation.""" + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + + mock_creds = MagicMock() + mock_creds.token = None + mock_creds.token_state = TokenState.STALE + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + + with ( + patch.object( + vertex_base, "load_auth", return_value=(mock_creds, "project-1") + ), + patch.object(vertex_base, "refresh_auth") as mock_refresh, + ): + + def mock_refresh_impl(creds): + creds.token = "refreshed-token" + creds.token_state = TokenState.FRESH + + mock_refresh.side_effect = mock_refresh_impl + + token, project = await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + + assert mock_refresh.called + assert token == "refreshed-token" + assert project == "project-1" + + @pytest.mark.asyncio + async def test_fresh_token_skips_refresh(self): + """Credentials not marked expired by google-auth should not trigger refresh.""" + vertex_base = VertexBase() + + mock_creds = MagicMock() + mock_creds.token = "fresh-token" + mock_creds.expired = False + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + cache_key = (json.dumps(credentials), "project-1") + vertex_base._credentials_project_mapping[cache_key] = ( + mock_creds, + "project-1", + ) + + with patch.object(vertex_base, "refresh_auth") as mock_refresh: + token, project = await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + + assert not mock_refresh.called, "Fresh token should not trigger refresh" + assert token == "fresh-token" + + @pytest.mark.asyncio + async def test_background_refresh_task_removed_after_completion(self): + """Completed background-refresh tasks must be evicted from + _background_refresh_tasks so the dict does not grow unboundedly.""" + import asyncio + + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + + mock_creds = MagicMock() + mock_creds.token = "near-expiry-token" + mock_creds.token_state = TokenState.STALE + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + + with ( + patch.object( + vertex_base, "load_auth", return_value=(mock_creds, "project-1") + ), + patch.object(vertex_base, "refresh_auth") as mock_refresh, + ): + + def mock_refresh_impl(creds): + creds.token = "refreshed-token" + creds.token_state = TokenState.FRESH + + mock_refresh.side_effect = mock_refresh_impl + + await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + + # Allow the background task to complete. + await asyncio.sleep(0.1) + + # After completion the entry should have been removed by the done-callback. + assert len(vertex_base._background_refresh_tasks) == 0, ( + "Completed background refresh task was not removed from " + "_background_refresh_tasks" + ) + + @pytest.mark.asyncio + async def test_background_refresh_tasks_no_accumulation_across_many_keys(self): + """With many distinct credential keys the dict must not hold completed tasks.""" + import asyncio + import json as _json + + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + + num_keys = 20 + + for i in range(num_keys): + mock_creds = MagicMock() + mock_creds.token = f"token-{i}" + mock_creds.token_state = TokenState.STALE + mock_creds.project_id = f"project-{i}" + mock_creds.quota_project_id = f"project-{i}" + + credentials = {"type": "service_account", "project_id": f"project-{i}"} + + with ( + patch.object( + vertex_base, + "load_auth", + return_value=(mock_creds, f"project-{i}"), + ), + patch.object(vertex_base, "refresh_auth") as mock_refresh, + ): + + def mock_refresh_impl(creds, idx=i): + creds.token = f"refreshed-{idx}" + creds.token_state = TokenState.FRESH + + mock_refresh.side_effect = mock_refresh_impl + + await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id=f"project-{i}", + custom_llm_provider="vertex_ai", + ) + + # Let all background tasks finish. + await asyncio.sleep(0.1) + + assert len(vertex_base._background_refresh_tasks) == 0, ( + f"Expected 0 tasks after all refreshes completed, " + f"found {len(vertex_base._background_refresh_tasks)}" + ) + + @pytest.mark.asyncio + async def test_async_refresh_lock_shared_while_in_use(self): + """Concurrent callers for the same key must coordinate on the same lock.""" + vertex_base = VertexBase() + key = ("creds", "project-1") + + lock_a = vertex_base._acquire_async_refresh_lock(key) + try: + async with lock_a: + lock_b = vertex_base._acquire_async_refresh_lock(key) + try: + assert lock_a is lock_b, ( + "While a coroutine still holds the lock, concurrent callers must " + "receive the same Lock instance to preserve single-flight." + ) + finally: + vertex_base._release_async_refresh_lock(key, lock_b) + finally: + vertex_base._release_async_refresh_lock(key, lock_a) + + @pytest.mark.asyncio + async def test_async_refresh_lock_pruned_after_release(self): + """get_access_token_async must drop the per-key Lock from the registry + once no coroutine is using it, so the dict stays bounded in + high-cardinality deployments. Without this, every distinct credential + leaks a Lock object for the lifetime of the process.""" + from google.auth.credentials import TokenState + + vertex_base = VertexBase() + + for i in range(10): + mock_creds = MagicMock() + mock_creds.token = f"refreshed-{i}" + mock_creds.token_state = TokenState.FRESH + mock_creds.project_id = f"project-{i}" + mock_creds.quota_project_id = f"project-{i}" + + credentials = {"type": "service_account", "project_id": f"project-{i}"} + + with ( + patch.object( + vertex_base, + "load_auth", + return_value=(mock_creds, f"project-{i}"), + ), + patch.object(vertex_base, "refresh_auth"), + ): + await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id=f"project-{i}", + custom_llm_provider="vertex_ai", + ) + + assert len(vertex_base._async_refresh_locks) == 0, ( + "expected per-key locks to be pruned once no coroutine holds or " + f"waits on them; found {len(vertex_base._async_refresh_locks)}" + ) + assert len(vertex_base._async_refresh_lock_refcounts) == 0 + + @pytest.mark.asyncio + async def test_async_refresh_lock_kept_while_waiter_pending(self): + """The prune must not run while another coroutine is still waiting on + the lock — otherwise the waiter ends up on a lock that's been replaced + in the registry and single-flight breaks.""" + vertex_base = VertexBase() + key = ("creds", "project-1") + + holder_lock = vertex_base._acquire_async_refresh_lock(key) + release_holder = asyncio.Event() + + async def hold_then_release(): + async with holder_lock: + await release_holder.wait() + vertex_base._release_async_refresh_lock(key, holder_lock) + + holder = asyncio.create_task(hold_then_release()) + await asyncio.sleep(0) # let holder grab the lock + + async def queue_for_lock(): + waiter_lock = vertex_base._acquire_async_refresh_lock(key) + try: + async with waiter_lock: + pass + finally: + vertex_base._release_async_refresh_lock(key, waiter_lock) + + waiter = asyncio.create_task(queue_for_lock()) + await asyncio.sleep(0) # let waiter queue on the lock + + assert ( + vertex_base._async_refresh_locks.get(key) is holder_lock + ), "lock with active holder/waiter must not be pruned" + + release_holder.set() + await holder + await waiter + + assert key not in vertex_base._async_refresh_locks + assert key not in vertex_base._async_refresh_lock_refcounts + + @pytest.mark.asyncio + async def test_fast_path_no_lock(self): + """Cached fresh credentials should return without acquiring the lock.""" + import datetime + + vertex_base = VertexBase() + + try: + from google.auth import _helpers as google_auth_helpers + + now = google_auth_helpers.utcnow() + except ImportError: + now = datetime.datetime.utcnow() + + mock_creds = MagicMock() + mock_creds.token = "cached-token" + mock_creds.expired = False + mock_creds.expiry = now + datetime.timedelta(minutes=30) + mock_creds.project_id = "project-1" + mock_creds.quota_project_id = "project-1" + + credentials = {"type": "service_account", "project_id": "project-1"} + cache_key = (json.dumps(credentials), "project-1") + vertex_base._credentials_project_mapping[cache_key] = ( + mock_creds, + "project-1", + ) + + # Spy on _acquire_async_refresh_lock to verify it's never called + with patch.object( + vertex_base, + "_acquire_async_refresh_lock", + wraps=vertex_base._acquire_async_refresh_lock, + ) as mock_get_lock: + token, project = await vertex_base._ensure_access_token_async( + credentials=credentials, + project_id="project-1", + custom_llm_provider="vertex_ai", + ) + + assert token == "cached-token" + assert not mock_get_lock.called, "Fast path should not acquire lock" diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gpt_oss/test_vertex_ai_gpt_oss_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gpt_oss/test_vertex_ai_gpt_oss_transformation.py index b16fc2bc44d..f617a8db850 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gpt_oss/test_vertex_ai_gpt_oss_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/gpt_oss/test_vertex_ai_gpt_oss_transformation.py @@ -118,7 +118,7 @@ async def test_vertex_ai_gpt_oss_simple_request(): "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler" ) as mock_http_handler, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_ai_partner_models.main.VertexAIPartnerModels._ensure_access_token", return_value=("fake-token", "pathrise-convert-1606954137718"), ), patch.dict( @@ -217,7 +217,7 @@ async def test_vertex_ai_gpt_oss_reasoning_effort(): "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler" ) as mock_http_handler, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_ai_partner_models.main.VertexAIPartnerModels._ensure_access_token", return_value=("fake-token", "pathrise-convert-1606954137718"), ), patch.dict( diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/qwen/test_vertex_ai_qwen_global_endpoint.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/qwen/test_vertex_ai_qwen_global_endpoint.py index bf6e0a5f2cd..5a86325b7fd 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/qwen/test_vertex_ai_qwen_global_endpoint.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/qwen/test_vertex_ai_qwen_global_endpoint.py @@ -7,7 +7,6 @@ These tests verify that: 3. The completion() and responses() API work with Qwen models """ -import json import os import sys from unittest.mock import MagicMock, patch, AsyncMock @@ -179,7 +178,7 @@ async def test_vertex_ai_qwen_global_endpoint_url(): "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler" ) as mock_http_handler, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_ai_partner_models.main.VertexAIPartnerModels._ensure_access_token", return_value=("fake-token", "test-project"), ), patch.dict( diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/test_partner_models_credential_reuse.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/test_partner_models_credential_reuse.py new file mode 100644 index 00000000000..b20442a032e --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/test_partner_models_credential_reuse.py @@ -0,0 +1,220 @@ +""" +Test that VertexBase subclasses (PartnerModels, Gemma, ModelGarden) reuse +cached credentials instead of creating a new VertexLLM instance on every request. +""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.llms.vertex_ai.vertex_ai_partner_models.main import ( + VertexAIPartnerModels, +) +from litellm.llms.vertex_ai.vertex_gemma_models.main import VertexAIGemmaModels +from litellm.llms.vertex_ai.vertex_model_garden.main import VertexAIModelGardenModels + + +def _mock_vertexai(): + """Return a MagicMock that satisfies the vertexai import guards.""" + m = MagicMock() + m.preview = MagicMock() + m.preview.language_models = MagicMock() + return m + + +class TestVertexBaseSubclassInit: + """All VertexBase subclasses must call super().__init__() so that + the credential cache is initialized.""" + + @pytest.mark.parametrize( + "cls", + [VertexAIPartnerModels, VertexAIGemmaModels, VertexAIModelGardenModels], + ids=["PartnerModels", "Gemma", "ModelGarden"], + ) + def test_init_calls_super(self, cls): + instance = cls() + assert hasattr(instance, "_credentials_project_mapping") + assert isinstance(instance._credentials_project_mapping, dict) + assert hasattr(instance, "access_token") + assert hasattr(instance, "project_id") + + +class TestPartnerModelsCredentialReuse: + def test_completion_uses_self_ensure_access_token(self): + """completion() should call self._ensure_access_token, not create a + throwaway VertexLLM instance.""" + partner = VertexAIPartnerModels() + + with ( + patch.dict(sys.modules, {"vertexai": _mock_vertexai()}), + patch.object( + partner, + "_ensure_access_token", + return_value=("cached-token", "test-project"), + ) as mock_ensure, + patch( + "litellm.llms.vertex_ai.vertex_ai_partner_models.main.base_llm_http_handler" + ) as mock_handler, + ): + mock_handler.completion.return_value = "response" + + partner.completion( + model="meta/llama-3.1-405b-instruct-maas", + messages=[{"role": "user", "content": "hello"}], + model_response=MagicMock(), + print_verbose=lambda *a, **kw: None, + encoding=MagicMock(), + logging_obj=MagicMock(), + api_base=None, + optional_params={}, + custom_prompt_dict={}, + headers=None, + timeout=30.0, + litellm_params={}, + vertex_project="test-project", + vertex_location="us-central1", + vertex_credentials='{"type": "service_account"}', + ) + + mock_ensure.assert_called_once_with( + credentials='{"type": "service_account"}', + project_id="test-project", + custom_llm_provider="vertex_ai", + ) + + def test_credential_cache_shared_across_calls(self): + """Two successive completion() calls should hit load_auth only once.""" + partner = VertexAIPartnerModels() + + mock_creds = MagicMock() + mock_creds.token = "my-token" + mock_creds.expired = False + mock_creds.project_id = "proj" + mock_creds.quota_project_id = "proj" + + with ( + patch.dict(sys.modules, {"vertexai": _mock_vertexai()}), + patch.object( + partner, "load_auth", return_value=(mock_creds, "proj") + ) as mock_load, + patch( + "litellm.llms.vertex_ai.vertex_ai_partner_models.main.base_llm_http_handler" + ) as mock_handler, + ): + mock_handler.completion.return_value = "resp" + + common_kwargs = dict( + model="meta/llama-3.1-405b-instruct-maas", + messages=[{"role": "user", "content": "hi"}], + model_response=MagicMock(), + print_verbose=lambda *a, **kw: None, + encoding=MagicMock(), + logging_obj=MagicMock(), + api_base=None, + optional_params={}, + custom_prompt_dict={}, + headers=None, + timeout=30.0, + litellm_params={}, + vertex_project="proj", + vertex_location="us-central1", + vertex_credentials='{"type": "service_account"}', + ) + + partner.completion(**common_kwargs) + partner.completion(**common_kwargs) + + assert mock_load.call_count == 1 + + +class TestGemmaModelsCredentialReuse: + def test_completion_uses_self_ensure_access_token(self): + """completion() should call self._ensure_access_token, not create a + throwaway VertexLLM instance.""" + gemma = VertexAIGemmaModels() + + mock_gemma_config = MagicMock() + mock_gemma_config.return_value.completion.return_value = "response" + + with ( + patch.dict(sys.modules, {"vertexai": _mock_vertexai()}), + patch.object( + gemma, + "_ensure_access_token", + return_value=("cached-token", "test-project"), + ) as mock_ensure, + patch( + "litellm.llms.vertex_ai.vertex_gemma_models.transformation.VertexGemmaConfig", + mock_gemma_config, + ), + ): + gemma.completion( + model="gemma/gemma-3-12b-it-1234567890", + messages=[{"role": "user", "content": "hello"}], + model_response=MagicMock(), + print_verbose=lambda *a, **kw: None, + encoding=MagicMock(), + logging_obj=MagicMock(), + api_base="https://123.us-central1-1.prediction.vertexai.goog/v1/projects/proj/locations/us-central1/endpoints/456:predict", + optional_params={}, + custom_prompt_dict={}, + headers=None, + timeout=30.0, + litellm_params={}, + vertex_project="test-project", + vertex_location="us-central1", + vertex_credentials='{"type": "service_account"}', + ) + + mock_ensure.assert_called_once_with( + credentials='{"type": "service_account"}', + project_id="test-project", + custom_llm_provider="vertex_ai", + ) + + +class TestModelGardenCredentialReuse: + def test_completion_uses_self_ensure_access_token(self): + """completion() should call self._ensure_access_token, not create a + throwaway VertexLLM instance.""" + garden = VertexAIModelGardenModels() + + mock_handler = MagicMock() + mock_handler.return_value.completion.return_value = "response" + + with ( + patch.dict(sys.modules, {"vertexai": _mock_vertexai()}), + patch.object( + garden, + "_ensure_access_token", + return_value=("cached-token", "test-project"), + ) as mock_ensure, + patch( + "litellm.llms.openai_like.chat.handler.OpenAILikeChatHandler", + mock_handler, + ), + ): + garden.completion( + model="openai/5464397967697903616", + messages=[{"role": "user", "content": "hello"}], + model_response=MagicMock(), + print_verbose=lambda *a, **kw: None, + encoding=MagicMock(), + logging_obj=MagicMock(), + api_base=None, + optional_params={}, + custom_prompt_dict={}, + headers=None, + timeout=30.0, + litellm_params={}, + vertex_project="test-project", + vertex_location="us-central1", + vertex_credentials='{"type": "service_account"}', + ) + + mock_ensure.assert_called_once_with( + credentials='{"type": "service_account"}', + project_id="test-project", + custom_llm_provider="vertex_ai", + ) diff --git a/tests/test_litellm/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py index 3e3e8901706..b1c8f7234ce 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_gemma_models/test_vertex_gemma_transformation.py @@ -122,17 +122,19 @@ class TestVertexGemmaCompletion: # Mock the async HTTP handler and Vertex authentication with ( patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler" - ) as mock_http_handler, + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" + ) as mock_get_client, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_gemma_models.main.VertexAIGemmaModels._ensure_access_token", return_value=("fake-access-token", "PROJECT_ID"), ), ): + mock_client = Mock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = mock_vertex_response - mock_http_handler.return_value.post = AsyncMock(return_value=mock_response) + mock_client.post = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client # Call litellm.acompletion() response = await litellm.acompletion( @@ -145,7 +147,7 @@ class TestVertexGemmaCompletion: ) # Verify the request sent to Vertex - call_args = mock_http_handler.return_value.post.call_args + call_args = mock_client.post.call_args assert call_args is not None, "HTTP handler was not called" request_data = call_args.kwargs["json"] @@ -210,17 +212,19 @@ class TestVertexGemmaCompletion: with ( patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler" - ) as mock_http_handler, + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" + ) as mock_get_client, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_gemma_models.main.VertexAIGemmaModels._ensure_access_token", return_value=("fake-access-token", "test-project"), ), ): + mock_client = Mock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = invalid_response - mock_http_handler.return_value.post = AsyncMock(return_value=mock_response) + mock_client.post = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client # Should raise exception (wrapped as APIConnectionError by LiteLLM) with pytest.raises(APIConnectionError) as exc_info: @@ -286,7 +290,7 @@ class TestVertexGemmaCompletion: "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" ) as mock_get_client, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_gemma_models.main.VertexAIGemmaModels._ensure_access_token", return_value=("fake-access-token", "PROJECT_ID"), ), ): @@ -388,7 +392,7 @@ class TestVertexGemmaCompletion: "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" ) as mock_get_client, patch( - "litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexLLM._ensure_access_token", + "litellm.llms.vertex_ai.vertex_gemma_models.main.VertexAIGemmaModels._ensure_access_token", return_value=("fake-access-token", "PROJECT_ID"), ), ): @@ -429,3 +433,123 @@ class TestVertexGemmaCompletion: # Verify other parameters are present assert "messages" in instance assert instance["@requestFormat"] == "chatCompletions" + + @pytest.mark.asyncio + async def test_acompletion_filters_context_management(self): + """ + Test that context_management is filtered out from the request. + + Vertex AI Gemma's chatCompletions wrapper does not understand + `context_management` (an Anthropic / OpenAI Responses API concept). + It must be stripped from the request body so the upstream endpoint + does not reject the request with an unknown-field error. + """ + mock_vertex_response = { + "deployedModelId": "1207280419999999999", + "model": "projects/993702345710/locations/us-central1/models/gemma-3-12b-it-1222199011122", + "modelDisplayName": "gemma-3-12b-it-1222199011122", + "modelVersionId": "1", + "predictions": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": None, + "message": { + "content": "ok", + "reasoning_content": None, + "role": "assistant", + "tool_calls": [], + }, + "stop_reason": None, + } + ], + "created": 1759863903, + "id": "chatcmpl-test-ctxmgmt", + "model": "google/gemma-3-12b-it", + "object": "chat.completion", + "prompt_logprobs": None, + "usage": { + "completion_tokens": 1, + "prompt_tokens": 5, + "prompt_tokens_details": None, + "total_tokens": 6, + }, + }, + } + + with ( + patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client" + ) as mock_get_client, + patch( + "litellm.llms.vertex_ai.vertex_gemma_models.main.VertexAIGemmaModels._ensure_access_token", + return_value=("fake-access-token", "PROJECT_ID"), + ), + ): + mock_client = Mock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_vertex_response + mock_client.post = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + # Use `allowed_openai_params` so context_management actually + # reaches the transformation layer (otherwise the upstream + # validator drops it before we can prove the transformation + # strips it). This mirrors the real-world scenario where a + # caller explicitly opts in to forwarding an arbitrary param. + await litellm.acompletion( + model="vertex_ai/gemma/gemma-3-12b-it-1222199011122", + messages=[{"role": "user", "content": "Test"}], + context_management=[ + {"type": "compaction", "compact_threshold": 200000} + ], + allowed_openai_params=["context_management"], + api_base="https://test.us-central1-project.prediction.vertexai.goog/v1/projects/PROJECT_ID/locations/us-central1/endpoints/ENDPOINT_ID:predict", + vertex_project="PROJECT_ID", + vertex_location="us-central1", + ) + + call_args = mock_client.post.call_args + assert call_args is not None, "HTTP client was not called" + + request_data = call_args.kwargs["json"] + print("request body=", json.dumps(request_data, indent=4)) + instance = request_data["instances"][0] + + assert ( + "context_management" not in instance + ), "context_management should not be forwarded to Vertex Gemma" + assert instance["@requestFormat"] == "chatCompletions" + assert "messages" in instance + + def test_transform_request_strips_context_management(self): + """ + Direct unit test for VertexGemmaConfig.transform_request: verify that + `context_management` is stripped from `optional_params` regardless of + how it was supplied to the transformation layer. + """ + from litellm.llms.vertex_ai.vertex_gemma_models.transformation import ( + VertexGemmaConfig, + ) + + config = VertexGemmaConfig() + result = config.transform_request( + model="gemma-3-12b-it", + messages=[{"role": "user", "content": "hi"}], + optional_params={ + "max_tokens": 32, + "context_management": [ + {"type": "compaction", "compact_threshold": 200000} + ], + }, + litellm_params={}, + headers={}, + ) + + assert "instances" in result + instance = result["instances"][0] + assert instance["@requestFormat"] == "chatCompletions" + assert "context_management" not in instance + assert instance.get("max_tokens") == 32 diff --git a/tests/test_litellm/llms/xai/test_xai_chat_transformation.py b/tests/test_litellm/llms/xai/test_xai_chat_transformation.py index 3ae8dfc3c0b..5c1f0f704d7 100644 --- a/tests/test_litellm/llms/xai/test_xai_chat_transformation.py +++ b/tests/test_litellm/llms/xai/test_xai_chat_transformation.py @@ -119,3 +119,19 @@ class TestXAIParallelToolCalls: assert result.get("parallel_tool_calls") is True assert len(result["messages"]) == 1 assert result["messages"][0]["role"] == "user" + + +class TestXAIUsageNormalization: + def test_preserves_reasoning_tokens_in_total_usage(self): + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=200) + + XAIChatConfig._normalize_openai_compatible_usage_totals(usage) + + assert usage.total_tokens == 200 + + def test_preserves_reasoning_tokens_in_streaming_usage(self): + usage = {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 200} + + XAIChatConfig._normalize_openai_compatible_usage_totals(usage) + + assert usage["total_tokens"] == 200 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py index 66b96785f69..9f2feddb0e3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py @@ -1229,6 +1229,143 @@ def test_validate_trusted_redirect_uri_rejects_fragment_and_bad_scheme(): assert exc.value.status_code == 400, uri +def test_validate_trusted_redirect_uri_accepts_cursor_native_callback(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback") + + +def test_validate_trusted_redirect_uri_rejects_unlisted_native_callback( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setenv("MCP_TRUSTED_NATIVE_REDIRECT_URIS", "") + # Clear defaults by patching — env-only path for this test + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback" + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_accepts_env_native_redirect_uri( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "vscode://my-app/oauth/callback", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "vscode://my-app/oauth/callback") + + +def test_validate_trusted_redirect_uri_rejects_native_callback_with_fragment(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback#frag" + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_rejects_native_callback_with_query(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, + "cursor://anysphere.cursor-mcp/oauth/callback?injected=anything", + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_native_path_case_insensitive(monkeypatch): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "myapp://host/MyPath", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "myapp://host/MyPath") + + +def test_validate_trusted_redirect_uri_native_wildcard_respects_path_boundary( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "cursor://anysphere.cursor-mcp/oauth/callback*", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback/extra" + ) + with pytest.raises(HTTPException): + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback-2" + ) + + +def test_validate_trusted_redirect_uri_native_wildcard_directory_prefix( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "cursor://anysphere.cursor-mcp/oauth/*", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback") + + def test_validate_trusted_redirect_uri_rejects_scheme_mismatch_on_same_host(): """Regression: an attacker who can serve http on the proxy's own host (e.g. by MITMing an unencrypted LAN hop) must not be able to diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index e1eddfc9c7a..f2fd73f3f22 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -774,6 +774,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): extra_headers=None, add_prefix=True, raw_headers=None, + user_api_key_auth=None, ): if server.name == "working_server": # Working server returns tools @@ -879,6 +880,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): extra_headers=None, add_prefix=True, raw_headers=None, + user_api_key_auth=None, ): # All servers fail raise Exception(f"Server {server.name} connection failed") @@ -1339,6 +1341,7 @@ async def test_list_tools_single_server_unprefixed_names(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ): tool = MagicMock() tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" @@ -1420,6 +1423,7 @@ async def test_list_tools_multiple_servers_prefixed_names(): extra_headers=None, add_prefix=True, raw_headers=None, + user_api_key_auth=None, ): tool = MagicMock() # When multiple servers, add_prefix should be True -> prefixed names @@ -1686,6 +1690,7 @@ async def test_list_tools_filters_by_key_team_permissions(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ): # Return 4 tools, but only 2 should be allowed tool1 = MagicMock() @@ -1795,6 +1800,7 @@ async def test_list_tools_with_team_tool_permissions_inheritance(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ): # Return 4 tools tool1 = MagicMock() @@ -1890,6 +1896,7 @@ async def test_list_tools_with_no_tool_permissions_shows_all(): extra_headers=None, add_prefix=False, raw_headers=None, + user_api_key_auth=None, ): # Return 3 tools tool1 = MagicMock() @@ -1988,6 +1995,7 @@ async def test_list_tools_strips_prefix_when_matching_permissions(): extra_headers=None, add_prefix=True, raw_headers=None, + user_api_key_auth=None, ): # Return tools WITH prefix (as they come from MCP server) tool1 = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index ef1c09aa815..d7078412a44 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -322,6 +322,7 @@ class TestMCPServerManager: mcp_auth_header=None, mcp_protocol_version=None, raw_headers=None, + user_api_key_auth=None, ): if server.name == "github": tool1 = MagicMock() @@ -376,6 +377,7 @@ class TestMCPServerManager: mcp_auth_header=None, mcp_protocol_version=None, raw_headers=None, + user_api_key_auth=None, ): assert mcp_auth_header == "legacy-token" # Should use legacy header tool = MagicMock() @@ -414,6 +416,7 @@ class TestMCPServerManager: mcp_auth_header=None, mcp_protocol_version=None, raw_headers=None, + user_api_key_auth=None, ): assert ( mcp_auth_header == "server-specific-token" @@ -1004,6 +1007,7 @@ class TestMCPServerManager: mcp_auth_header=None, mcp_protocol_version=None, raw_headers=None, + user_api_key_auth=None, ): assert ( mcp_auth_header == "server-specific-token" @@ -1801,6 +1805,258 @@ class TestMCPServerManager: assert len(tools_unprefixed) == 1 assert tools_unprefixed[0].name == "send_email" + @pytest.mark.asyncio + async def test_get_tools_from_server_jwt_skipped_when_mcp_auth_header_set(self): + """When a per-user mcp_auth_header is resolved, JWT injection must be skipped. + + MCPClient._get_auth_headers() applies extra_headers AFTER writing + Authorization from auth_value, so an injected JWT would clobber the + user's per-server OAuth token. Regression test for that interaction. + """ + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="zapier", + name="zapier", + transport=MCPTransport.http, + ) + + manager._create_mcp_client = AsyncMock(return_value=object()) + manager._fetch_tools_with_timeout = AsyncMock(return_value=[]) + + user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + + with ( + patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.get_mcp_jwt_signer", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.inject_mcp_jwt_headers_for_upstream", + new=AsyncMock(return_value={"Authorization": "Bearer signed-jwt"}), + ) as mock_inject, + ): + # Case A: mcp_auth_header present -> JWT must NOT be injected + await manager._get_tools_from_server( + server, + mcp_auth_header="oauth-user-token", + user_api_key_auth=user_auth, + ) + mock_inject.assert_not_called() + + # Case B: no mcp_auth_header -> JWT injection runs as before + await manager._get_tools_from_server( + server, + user_api_key_auth=user_auth, + ) + mock_inject.assert_awaited_once() + + def test_resolve_mcp_server_for_tool_call_via_prefixed_name(self): + """Resolution succeeds when the prefixed tool name is in the mapping.""" + manager = MCPServerManager() + server = MCPServer( + server_id="jira", + name="jira", + transport=MCPTransport.http, + ) + manager.registry = {"jira": server} + manager.tool_name_to_mcp_server_name_mapping["jira-search_issues"] = "jira" + manager.tool_name_to_mcp_server_name_mapping["search_issues"] = "jira" + + resolved = manager._resolve_mcp_server_for_tool_call("jira", "search_issues") + assert resolved is server + + def test_resolve_mcp_server_for_tool_call_via_alias(self): + """Resolution falls back to alias/server_name match in the registry.""" + manager = MCPServerManager() + server = MCPServer( + server_id="srv-uuid-123", + name="zapier", + alias="zapier-alias", + transport=MCPTransport.http, + ) + manager.registry = {"srv-uuid-123": server} + manager.tool_name_to_mcp_server_name_mapping["create_zap"] = "zapier" + + resolved = manager._resolve_mcp_server_for_tool_call( + "zapier-alias", "create_zap" + ) + assert resolved is server + + def test_resolve_mcp_server_for_tool_call_unknown_tool_with_empty_mapping(self): + """Server-name match alone must not let unknown tools through when the + mapping has no entries for that server (e.g. listing has not completed + or the server is OAuth2 and the user has not yet listed tools). + """ + manager = MCPServerManager() + server = MCPServer( + server_id="srv-uuid-123", + name="zapier", + alias="zapier-alias", + transport=MCPTransport.http, + ) + manager.registry = {"srv-uuid-123": server} + + with pytest.raises(ValueError, match="Tool create_zap not found"): + manager._resolve_mcp_server_for_tool_call("zapier-alias", "create_zap") + + def test_resolve_mcp_server_for_tool_call_fallback_to_unprefixed_lookup(self): + """Fallback to unprefixed _get_mcp_server_from_tool_name when other paths fail.""" + manager = MCPServerManager() + server = MCPServer( + server_id="linear", + name="linear", + transport=MCPTransport.http, + ) + manager.registry = {"linear": server} + manager.tool_name_to_mcp_server_name_mapping["create_issue"] = "linear" + + # server_name is empty so the fallback unprefixed lookup runs and matches. + resolved = manager._resolve_mcp_server_for_tool_call("", "create_issue") + assert resolved is server + + def test_resolve_mcp_server_for_tool_call_raises_when_not_found(self): + """ValueError is raised when no resolution path finds the tool.""" + manager = MCPServerManager() + with pytest.raises(ValueError, match="Tool .* not found"): + manager._resolve_mcp_server_for_tool_call("nonexistent", "ghost_tool") + + def test_resolve_mcp_server_for_tool_call_unknown_tool_with_known_server(self): + """Server-name match alone must not let unknown tools slip through. + + If the registry has tools for this server but neither the prefixed nor + unprefixed tool name is in the mapping, raise rather than returning the + server (would otherwise allow tool enumeration via name spoofing). + """ + manager = MCPServerManager() + server = MCPServer( + server_id="github", + name="github", + transport=MCPTransport.http, + ) + manager.registry = {"github": server} + # Mapping has *some* tools for github but not "missing_tool". + manager.tool_name_to_mcp_server_name_mapping["github-list_repos"] = "github" + manager.tool_name_to_mcp_server_name_mapping["list_repos"] = "github" + + with pytest.raises(ValueError, match="Tool missing_tool not found"): + manager._resolve_mcp_server_for_tool_call("github", "missing_tool") + + @pytest.mark.asyncio + async def test_resolve_oauth2_headers_skipped_when_not_user_oauth(self): + """Returns input headers unchanged when server does not need user OAuth.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="plain", + name="plain", + transport=MCPTransport.http, + ) + # needs_user_oauth_token defaults to False. + user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="bob") + + result = await manager._resolve_oauth2_headers_for_tool_call( + server, oauth2_headers=None, user_api_key_auth=user_auth + ) + assert result is None + + @pytest.mark.asyncio + async def test_resolve_oauth2_headers_returns_client_supplied_token(self): + """Returns the client's oauth2_headers as-is when already set.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="oauth-srv", + name="oauth-srv", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + assert server.needs_user_oauth_token is True + user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + supplied = {"Authorization": "Bearer client-supplied"} + + result = await manager._resolve_oauth2_headers_for_tool_call( + server, oauth2_headers=supplied, user_api_key_auth=user_auth + ) + assert result is supplied + + @pytest.mark.asyncio + async def test_resolve_oauth2_headers_looks_up_stored_token(self): + """Falls back to stored per-user OAuth headers when no token is supplied.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="oauth-srv", + name="oauth-srv", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + stored = {"Authorization": "Bearer stored-user-token"} + + with patch( + "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db", + new=AsyncMock(return_value=stored), + ) as mock_lookup: + result = await manager._resolve_oauth2_headers_for_tool_call( + server, oauth2_headers=None, user_api_key_auth=user_auth + ) + + assert result == stored + mock_lookup.assert_awaited_once() + + @pytest.mark.asyncio + async def test_resolve_oauth2_headers_swallows_lookup_exception(self): + """Returns supplied headers (None) when the stored-token lookup raises.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="oauth-srv", + name="oauth-srv", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + + with patch( + "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db", + new=AsyncMock(side_effect=RuntimeError("redis down")), + ): + result = await manager._resolve_oauth2_headers_for_tool_call( + server, oauth2_headers=None, user_api_key_auth=user_auth + ) + assert result is None + + @pytest.mark.asyncio + async def test_resolve_oauth2_headers_no_user_id(self): + """Skip lookup entirely when user_api_key_auth has no user_id.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="oauth-srv", + name="oauth-srv", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + # user_id is None -> lookup must not happen + user_auth = UserAPIKeyAuth(api_key="sk-test") + + with patch( + "litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db", + new=AsyncMock(return_value={"Authorization": "Bearer x"}), + ) as mock_lookup: + result = await manager._resolve_oauth2_headers_for_tool_call( + server, oauth2_headers=None, user_api_key_auth=user_auth + ) + assert result is None + mock_lookup.assert_not_called() + def test_create_prefixed_tools_updates_mapping_for_both_forms(self): """_create_prefixed_tools should populate mapping for prefixed and original names even when not adding prefix in output.""" manager = MCPServerManager() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index f4feac68fcc..593facd9279 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -1,5 +1,6 @@ import json from typing import Any, Dict, Optional +from unittest.mock import MagicMock import pytest from fastapi import HTTPException @@ -796,6 +797,25 @@ class TestCallToolRestAPI: raising=False, ) + mock_server = MagicMock() + mock_server.server_id = "server-1" + + def fake_get_mcp_server_by_id(server_id): + return mock_server if server_id == "server-1" else None + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + fake_get_mcp_server_by_id, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_name", + lambda *args, **kwargs: None, + raising=False, + ) + request_payload = { "server_id": "server-1", "name": "demo-tool", diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 26f04a4abcb..35a3bd7f657 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3016,3 +3016,340 @@ async def test_team_member_budget_check_zero_per_member_row_still_blocks(): proxy_logging_obj=proxy_logging_obj, ) assert exc_info.value.max_budget == 0.0 + + +# --- resolve_and_validate_end_user_id --------------------------------------- + + +@pytest.fixture +def _validate_flag_on(monkeypatch): + """Enable opt-in DB validation for the duration of a test.""" + import litellm + + monkeypatch.setattr(litellm, "validate_end_user_id_in_db", True) + monkeypatch.setattr(litellm, "max_end_user_budget_id", None) + + +def _validation_cache(): + cache = MagicMock() + cache.async_get_cache = AsyncMock(return_value=None) + cache.async_set_cache = AsyncMock() + return cache + + +def _patch_validation_helpers(monkeypatch, *, end_user=None, user=None, fuzzy=None): + """Stub out the DB helpers resolve_and_validate_end_user_id delegates to.""" + from litellm.proxy.auth import auth_checks + + monkeypatch.setattr( + auth_checks, "get_end_user_object", AsyncMock(return_value=end_user) + ) + monkeypatch.setattr(auth_checks, "get_user_object", AsyncMock(return_value=user)) + monkeypatch.setattr( + auth_checks, "_get_fuzzy_user_object", AsyncMock(return_value=fuzzy) + ) + + +@pytest.mark.asyncio +async def test_resolve_end_user_returns_none_for_none_input( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + assert ( + await resolve_and_validate_end_user_id( + raw_end_user_id=None, + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + is None + ) + + +@pytest.mark.asyncio +async def test_resolve_end_user_passes_through_when_flag_disabled(monkeypatch): + """Default behaviour: flag is off, arbitrary ids pass through untouched.""" + import litellm + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + monkeypatch.setattr(litellm, "validate_end_user_id_in_db", False) + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="codex-session-abc", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "codex-session-abc" + cache.async_set_cache.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_end_user_passes_through_when_no_prisma_client( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="alice@example.com", + prisma_client=None, + user_api_key_cache=cache, + ) + assert result == "alice@example.com" + + +@pytest.mark.asyncio +async def test_resolve_end_user_matches_end_user_table(_validate_flag_on, monkeypatch): + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch, end_user=MagicMock()) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="customer-123", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "customer-123" + cache.async_set_cache.assert_awaited_once() + kwargs = cache.async_set_cache.await_args.kwargs + assert kwargs["key"] == "end_user_validation:customer-123" + assert kwargs["value"] == "valid" + + +@pytest.mark.asyncio +async def test_resolve_end_user_matches_user_table_by_user_id( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch, user=MagicMock()) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="user-xyz", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "user-xyz" + # email fallback should not run for a non-email input + auth_checks._get_fuzzy_user_object.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_end_user_matches_user_table_by_email( + _validate_flag_on, monkeypatch +): + """Email-shaped ids route through get_user_object with user_email set. + + The fuzzy lookup must happen inside get_user_object so it shares the + _should_check_db throttle and user_api_key_cache — no direct raw + Prisma calls on the auth path. + """ + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch, user=MagicMock()) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="Alice@Example.com", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "Alice@Example.com" + auth_checks.get_user_object.assert_awaited_once() + user_kwargs = auth_checks.get_user_object.await_args.kwargs + assert user_kwargs["user_id"] == "Alice@Example.com" + assert user_kwargs["user_email"] == "Alice@Example.com" + # email branch must not bypass the cached helper with a raw fuzzy call + auth_checks._get_fuzzy_user_object.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_end_user_non_email_id_does_not_pass_user_email( + _validate_flag_on, monkeypatch +): + """Non-email ids skip the email fuzzy path to avoid a pointless DB hit.""" + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch, user=MagicMock()) + cache = _validation_cache() + + await resolve_and_validate_end_user_id( + raw_end_user_id="user-xyz", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + auth_checks.get_user_object.assert_awaited_once() + user_kwargs = auth_checks.get_user_object.await_args.kwargs + assert user_kwargs["user_email"] is None + + +@pytest.mark.asyncio +async def test_resolve_end_user_drops_codex_opaque_identifier( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch) # all helpers return None + cache = _validation_cache() + + codex_id = ( + "user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de" + "_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569" + ) + result = await resolve_and_validate_end_user_id( + raw_end_user_id=codex_id, + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result is None + cache.async_set_cache.assert_awaited_once() + kwargs = cache.async_set_cache.await_args.kwargs + assert kwargs["value"] == "invalid" + + +@pytest.mark.asyncio +async def test_resolve_end_user_preserves_id_when_default_budget_configured( + _validate_flag_on, monkeypatch +): + """Don't drop unregistered ids when litellm.max_end_user_budget_id is set. + + The default end-user budget is applied downstream when the id is present + but not found in the db — dropping the id here would bypass those limits. + """ + import litellm + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + monkeypatch.setattr(litellm, "max_end_user_budget_id", "default-budget") + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="new-customer", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "new-customer" + + +@pytest.mark.asyncio +async def test_resolve_end_user_drops_unknown_email(_validate_flag_on, monkeypatch): + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="stranger@example.com", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result is None + + +@pytest.mark.asyncio +async def test_resolve_end_user_uses_cached_valid_result( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch) + cache = _validation_cache() + cache.async_get_cache = AsyncMock(return_value="valid") + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="alice@example.com", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result == "alice@example.com" + auth_checks.get_end_user_object.assert_not_awaited() + auth_checks.get_user_object.assert_not_awaited() + auth_checks._get_fuzzy_user_object.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_end_user_uses_cached_invalid_result( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + _patch_validation_helpers(monkeypatch, end_user=MagicMock()) + cache = _validation_cache() + cache.async_get_cache = AsyncMock(return_value="invalid") + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="bogus", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + assert result is None + # Despite a matching row configured, helpers aren't called — cache wins. + auth_checks.get_end_user_object.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_end_user_swallows_db_errors_and_returns_none( + _validate_flag_on, monkeypatch +): + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + monkeypatch.setattr( + auth_checks, + "get_end_user_object", + AsyncMock(side_effect=Exception("db down")), + ) + monkeypatch.setattr( + auth_checks, + "get_user_object", + AsyncMock(side_effect=Exception("db down")), + ) + cache = _validation_cache() + + result = await resolve_and_validate_end_user_id( + raw_end_user_id="alice@example.com", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) + # DB errors shouldn't raise through the auth path — treat as unknown. + assert result is None + + +@pytest.mark.asyncio +async def test_resolve_end_user_reraises_budget_exceeded( + _validate_flag_on, monkeypatch +): + """BudgetExceededError from get_end_user_object must bubble up so the + auth path enforces spend limits instead of silently dropping the id.""" + import litellm + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id + + monkeypatch.setattr( + auth_checks, + "get_end_user_object", + AsyncMock( + side_effect=litellm.BudgetExceededError(current_cost=10.0, max_budget=5.0) + ), + ) + cache = _validation_cache() + + with pytest.raises(litellm.BudgetExceededError): + await resolve_and_validate_end_user_id( + raw_end_user_id="customer-over-budget", + prisma_client=MagicMock(), + user_api_key_cache=cache, + ) diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 08035fb7173..68e1636d380 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -597,6 +597,315 @@ def test_get_end_user_id_falls_back_to_deprecated_user_header_name(): assert result == "user-legacy" +class TestCoerceUserIdToStr: + """Unit tests for the _coerce_user_id_to_str helper.""" + + def test_plain_string_is_returned_verbatim(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str("alice@example.com") == "alice@example.com" + + def test_string_is_stripped(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str(" bob ") == "bob" + + def test_codex_opaque_identifier_is_preserved(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + codex_id = ( + "user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de" + "_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569" + ) + assert _coerce_user_id_to_str(codex_id) == codex_id + + def test_none_returns_none(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str(None) is None + + def test_empty_string_returns_none(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str("") is None + assert _coerce_user_id_to_str(" ") is None + + def test_dict_returns_none(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + payload = { + "device_id": "abc", + "account_uuid": "", + "session_id": "c284b8cb", + } + assert _coerce_user_id_to_str(payload) is None + + def test_list_returns_none(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str(["a", "b"]) is None + + def test_json_encoded_dict_string_passes_through_by_default(self): + """JSON-encoded dict strings are preserved unless opt-in flag is on. + + This preserves backwards compatibility: existing deployments that + intentionally pass JSON-encoded user identifiers keep working. + """ + import litellm + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + blob = ( + '{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",' + '"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}' + ) + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = False + try: + assert _coerce_user_id_to_str(blob) == blob + finally: + litellm.validate_end_user_id_in_db = original + + def test_json_encoded_dict_string_returns_none_when_validation_enabled(self): + import litellm + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + # Same broken shape we saw in spend logs, but pre-stringified to JSON. + blob = ( + '{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",' + '"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}' + ) + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = True + try: + assert _coerce_user_id_to_str(blob) is None + finally: + litellm.validate_end_user_id_in_db = original + + def test_json_encoded_list_string_passes_through_by_default(self): + import litellm + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = False + try: + assert _coerce_user_id_to_str('["a","b"]') == '["a","b"]' + finally: + litellm.validate_end_user_id_in_db = original + + def test_json_encoded_list_string_returns_none_when_validation_enabled(self): + import litellm + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = True + try: + assert _coerce_user_id_to_str('["a","b"]') is None + finally: + litellm.validate_end_user_id_in_db = original + + def test_int_returns_str(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str(12345) == "12345" + + def test_bool_returns_none(self): + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + # bool is an int subclass — reject explicitly, never produce "True"/"False". + assert _coerce_user_id_to_str(True) is None + assert _coerce_user_id_to_str(False) is None + + def test_brace_string_that_isnt_json_is_kept(self): + """A string starting with `{` but failing to parse stays as-is.""" + from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str + + assert _coerce_user_id_to_str("{not json") == "{not json" + + +class TestGetEndUserIdDropsMalformedBodyValues: + """Tests that get_end_user_id_from_request_body drops dict-shaped values + rather than stringifying them into spend logs.""" + + def test_dict_user_falls_through_to_litellm_metadata(self): + request_body = { + "user": { + "device_id": "abc", + "session_id": "c284b8cb", + }, + "litellm_metadata": {"user": "alice@example.com"}, + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == "alice@example.com" + + def test_dict_user_with_no_other_sources_returns_none(self): + request_body = { + "user": {"device_id": "abc", "session_id": "xyz"}, + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result is None + + def test_json_encoded_user_string_passes_through_by_default(self): + """JSON-encoded user strings pass through unless validation is opted in. + + Gating behind ``litellm.validate_end_user_id_in_db`` keeps existing + deployments that send JSON-encoded identifiers working until they + explicitly opt into the stricter extraction. + """ + import litellm + + blob = ( + '{"device_id":"d5abe9199ee7759a","account_uuid":"",' + '"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}' + ) + request_body = {"user": blob} + + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = False + try: + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + finally: + litellm.validate_end_user_id_in_db = original + + assert result == blob + + def test_json_encoded_user_string_returns_none_when_validation_enabled(self): + import litellm + + request_body = { + "user": ( + '{"device_id":"d5abe9199ee7759a","account_uuid":"",' + '"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}' + ), + } + + original = litellm.validate_end_user_id_in_db + litellm.validate_end_user_id_in_db = True + try: + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + finally: + litellm.validate_end_user_id_in_db = original + + assert result is None + + def test_plain_string_user_is_preserved(self): + request_body = {"user": "alice@example.com"} + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == "alice@example.com" + + def test_codex_opaque_user_is_preserved(self): + codex_id = ( + "user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de" + "_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569" + ) + request_body = {"user": codex_id} + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == codex_id + + def test_int_user_is_coerced_to_string(self): + request_body = {"user": 12345} + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == "12345" + + def test_list_user_falls_through(self): + request_body = { + "user": ["a", "b"], + "safety_identifier": "alice@example.com", + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == "alice@example.com" + + def test_dict_safety_identifier_returns_none(self): + request_body = { + "safety_identifier": {"device_id": "abc"}, + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result is None + + def test_dict_metadata_user_id_returns_none(self): + request_body = { + "metadata": {"user_id": {"device_id": "abc"}}, + } + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result is None + + def test_whitespace_user_falls_through(self): + request_body = {"user": " ", "safety_identifier": "alice@example.com"} + + with patch("litellm.proxy.proxy_server.general_settings", {}): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers={} + ) + + assert result == "alice@example.com" + + def test_dict_user_header_falls_through_to_body(self): + """A dict-shaped value in a configured user-id header is dropped, not stringified.""" + general_settings = {"user_header_name": "x-custom-user-id"} + # A header value will normally be a str, but be defensive: the coercion + # must drop anything that isn't a usable identifier. + headers = {"x-custom-user-id": {"device_id": "abc"}} + request_body = {"user": "alice@example.com"} + + with ( + patch( + "litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers", + return_value=None, + ), + patch("litellm.proxy.proxy_server.general_settings", general_settings), + ): + result = get_end_user_id_from_request_body( + request_body=request_body, request_headers=headers + ) + + assert result == "alice@example.com" + + def _make_deployment_dict( model_name: str, tpm: Optional[int] = None, rpm: Optional[int] = None ) -> dict: diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 77aa03032a7..f38ac5c2000 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -249,3 +249,241 @@ def test_get_complete_model_list_byok_wildcard_expansion(): assert len(result) > 0 assert all(m.startswith("openai/") for m in result) assert "openai/*" not in result + + +def test_get_complete_model_list_expands_team_scoped_wildcard_with_stored_credential( + monkeypatch, +): + """ + Team-scoped BYOK wildcard deployments are stored under an internal model_name, + with the public wildcard name in model_info.team_public_model_name. + """ + import litellm + from litellm import Router + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_complete_model_list + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={ + "api_key": "stored-openai-key", + "api_base": "https://example.openai.test/v1", + }, + ) + ], + ) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["api_key"] = litellm_params.api_key + captured_params["api_base"] = litellm_params.api_base + captured_params["credential_name"] = litellm_params.litellm_credential_name + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + router = Router( + model_list=[ + { + "model_name": "model_name_team-1_generated", + "litellm_params": { + "model": "openai/*", + "custom_llm_provider": "openai", + "litellm_credential_name": "openai-credential", + }, + "model_info": { + "team_id": "team-1", + "team_public_model_name": "openai/*", + }, + } + ] + ) + + result = get_complete_model_list( + key_models=[], + team_models=["openai/*"], + proxy_model_list=[], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + team_id="team-1", + ) + + assert "openai/gpt-4o" in result + assert captured_params == { + "provider": "openai", + "api_key": "stored-openai-key", + "api_base": "https://example.openai.test/v1", + "credential_name": None, + } + + +def test_wildcard_credential_hydration_preserves_deployment_params( + monkeypatch, +): + import litellm + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={ + "api_key": "stored-openai-key", + "api_version": "credential-version", + "model": "openai/wrong-model", + "unexpected_field": "unexpected-value", + }, + ) + ], + ) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["model"] = litellm_params.model + captured_params["api_key"] = litellm_params.api_key + captured_params["api_version"] = litellm_params.api_version + captured_params["credential_name"] = litellm_params.litellm_credential_name + captured_params["has_unexpected_field"] = hasattr( + litellm_params, "unexpected_field" + ) + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + result = get_known_models_from_wildcard( + wildcard_model="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + custom_llm_provider="openai", + api_version="deployment-version", + litellm_credential_name="openai-credential", + ), + ) + + assert result == ["openai/gpt-4o"] + assert captured_params == { + "provider": "openai", + "model": "openai/*", + "api_key": "stored-openai-key", + "api_version": "deployment-version", + "credential_name": None, + "has_unexpected_field": False, + } + + +def test_wildcard_credential_hydration_preserves_missing_credential_name( + monkeypatch, +): + import litellm + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + + monkeypatch.setattr(litellm, "credential_list", []) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["api_key"] = litellm_params.api_key + captured_params["credential_name"] = litellm_params.litellm_credential_name + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + result = get_known_models_from_wildcard( + wildcard_model="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + custom_llm_provider="openai", + api_key=None, + litellm_credential_name="missing-credential", + ), + ) + + assert result == ["openai/gpt-4o"] + assert captured_params == { + "provider": "openai", + "api_key": None, + "credential_name": "missing-credential", + } + + +@pytest.mark.asyncio +async def test_get_available_models_for_user_expands_query_team_wildcard( + monkeypatch, +): + import litellm + from litellm import Router + from litellm.proxy.auth import model_checks + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import get_available_models_for_user + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={"api_key": "stored-openai-key"}, + ) + ], + ) + + def fake_get_provider_models(provider, litellm_params=None): + assert litellm_params.api_key == "stored-openai-key" + assert litellm_params.litellm_credential_name is None + return ["gpt-4o-mini"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + router = Router( + model_list=[ + { + "model_name": "model_name_team-1_generated", + "litellm_params": { + "model": "openai/*", + "custom_llm_provider": "openai", + "litellm_credential_name": "openai-credential", + }, + "model_info": { + "team_id": "team-1", + "team_public_model_name": "openai/*", + }, + } + ] + ) + + result = await get_available_models_for_user( + user_api_key_dict=UserAPIKeyAuth( + api_key="sk-test", + models=[], + team_id="team-1", + team_models=["openai/*"], + ), + llm_router=router, + general_settings={}, + user_model=None, + team_id="team-1", + ) + + assert "openai/gpt-4o-mini" in result diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 442625c75a7..defd3bbcdcd 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -3335,3 +3335,125 @@ async def test_master_key_auth_substitutes_alias_for_api_key(): finally: for k, v in _orig.items(): setattr(_proxy_server_mod, k, v) + + +@pytest.mark.asyncio +async def test_user_api_key_auth_sets_end_user_id_when_builder_skips_it(): + """Defense-in-depth: ``_user_api_key_auth_builder`` has multiple + early-return paths (master_key=None, /user/auth route, JWT + short-circuits) that bypass the end-user resolution block. The wrapper + must still attribute spend logs to the request-supplied end-user when + none of those paths set it. + + Krrish flagged the removal of this fallback as a regression risk; this + test pins the behaviour so future refactors don't silently drop it. + """ + from fastapi import Request + from starlette.datastructures import URL + + import litellm.proxy.proxy_server as _proxy_server_mod + + builder_token = UserAPIKeyAuth(api_key="sk-test", user_id="u1") + # builder did NOT set end_user_id (e.g. master_key=None early return) + assert builder_token.end_user_id is None + + request = Request( + scope={ + "type": "http", + "headers": [(b"content-type", b"application/json")], + "method": "POST", + } + ) + request._url = URL(url="/chat/completions") + request._body = json.dumps( + {"model": "gpt-4o", "user": "alice@example.com"} + ).encode() + + attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) + originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} + try: + for k, v in attrs.items(): + setattr(_proxy_server_mod, k, v) + # Stub the builder so the test doesn't have to traverse the full + # auth state machine; we only care about the wrapper's safety net. + with ( + patch( + "litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder", + new_callable=AsyncMock, + return_value=builder_token, + ), + patch( + "litellm.proxy.auth.user_api_key_auth._run_centralized_common_checks", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route", + ), + ): + result = await user_api_key_auth(request=request, api_key="Bearer sk-test") + + # Validation flag is False by default → pass-through, raw value lands + # on the auth obj instead of being silently dropped. + assert result.end_user_id == "alice@example.com" + finally: + for k, v in originals.items(): + setattr(_proxy_server_mod, k, v) + + +@pytest.mark.asyncio +async def test_user_api_key_auth_does_not_overwrite_end_user_id_set_by_builder(): + """When the builder already resolved the end-user id (the primary + path), the wrapper-level safety net must not run a second resolution + pass — that would re-extract from the request body and could + overwrite a value the builder explicitly chose to set.""" + from fastapi import Request + from starlette.datastructures import URL + + import litellm.proxy.proxy_server as _proxy_server_mod + + builder_token = UserAPIKeyAuth( + api_key="sk-test", user_id="u1", end_user_id="builder-resolved-id" + ) + + request = Request( + scope={ + "type": "http", + "headers": [(b"content-type", b"application/json")], + "method": "POST", + } + ) + request._url = URL(url="/chat/completions") + request._body = json.dumps( + {"model": "gpt-4o", "user": "different-id-from-body"} + ).encode() + + attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) + originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} + try: + for k, v in attrs.items(): + setattr(_proxy_server_mod, k, v) + with ( + patch( + "litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder", + new_callable=AsyncMock, + return_value=builder_token, + ), + patch( + "litellm.proxy.auth.user_api_key_auth._run_centralized_common_checks", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route", + ), + patch( + "litellm.proxy.auth.user_api_key_auth.resolve_and_validate_end_user_id", + new_callable=AsyncMock, + ) as mock_resolve, + ): + result = await user_api_key_auth(request=request, api_key="Bearer sk-test") + + assert result.end_user_id == "builder-resolved-id" + mock_resolve.assert_not_awaited() + finally: + for k, v in originals.items(): + setattr(_proxy_server_mod, k, v) diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index b4343f6b2e1..3d7cb1e35f3 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -16,6 +16,7 @@ sys.path.insert( import litellm from litellm.proxy._types import ProxyException from litellm.proxy.common_utils.http_parsing_utils import ( + _is_form_content_type, _read_request_body, _safe_get_request_headers, _safe_get_request_parsed_body, @@ -853,3 +854,145 @@ class TestGetTagsFromRequestBodyStringCoerce: tags = get_tags_from_request_body({"metadata": {"tags": ["x"]}}) assert tags == ["x"] + + +class TestIsFormContentType: + @pytest.mark.parametrize( + "content_type", + [ + "application/x-www-form-urlencoded", + "multipart/form-data", + "multipart/form-data; boundary=----WebKitFormBoundary", + "Application/X-WWW-Form-Urlencoded", + " multipart/form-data ", + "application/x-www-form-urlencoded; charset=utf-8", + ], + ) + def test_form_types_match(self, content_type): + assert _is_form_content_type(content_type) is True + + @pytest.mark.parametrize( + "content_type", + [ + "", + "application/json", + "application/json; charset=utf-8", + "application/form-json", + "multiform/anything", + "application/json; xform=1", + "application/xml-with-form-data-but-not-actually", + "text/plain", + "form", + ], + ) + def test_non_form_types_rejected(self, content_type): + assert _is_form_content_type(content_type) is False + + +class TestReadRequestBodyNonCanonicalContentType: + """A JSON body with a ``"form"``-substring Content-Type must parse as JSON.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "content_type", + [ + "application/form-json", + "application/json; xform=1", + "multiform/anything", + ], + ) + async def test_json_body_with_formlike_content_type_parses_as_json( + self, content_type + ): + payload = {"user_config": {"model_list": []}, "model": "x"} + + mock_request = MagicMock() + mock_request.body = AsyncMock(return_value=orjson.dumps(payload)) + mock_request.form = AsyncMock(return_value={}) + mock_request.headers = {"content-type": content_type} + mock_request.scope = {} + + result = await _read_request_body(mock_request) + assert result == payload + mock_request.form.assert_not_called() + + @pytest.mark.asyncio + async def test_real_form_post_still_parsed_as_form(self): + mock_request = MagicMock() + mock_request.form = AsyncMock(return_value={"k": "v"}) + mock_request.body = AsyncMock(return_value=b"") + mock_request.headers = {"content-type": "application/x-www-form-urlencoded"} + mock_request.scope = {} + + result = await _read_request_body(mock_request) + assert result == {"k": "v"} + mock_request.form.assert_awaited_once() + + +class TestReadRequestBodyFormParseFailure: + """ + A failed ``request.form()`` parse (e.g. multipart with missing boundary) + must surface as a 400, not silently return ``{}`` — otherwise the + auth-time pre-read sees an empty body while a later raw-body re-read + sees the original payload, defeating every banned-param check. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "raised_exception", + [ + ValueError("Missing boundary in multipart."), + AssertionError("malformed chunk"), + RuntimeError("form parser exploded"), + ], + ) + async def test_form_parse_failure_raises_400(self, raised_exception): + mock_request = MagicMock() + mock_request.form = AsyncMock(side_effect=raised_exception) + mock_request.headers = {"content-type": "multipart/form-data"} + mock_request.scope = {} + + with pytest.raises(ProxyException) as exc_info: + await _read_request_body(mock_request) + assert str(exc_info.value.code) == "400" + + +class TestGetRequestBody: + @pytest.mark.asyncio + async def test_json_with_charset_param_parses_as_json(self): + payload = {"k": "v"} + mock_request = MagicMock() + mock_request.method = "POST" + mock_request.body = AsyncMock(return_value=orjson.dumps(payload)) + mock_request.headers = {"content-type": "application/json; charset=utf-8"} + mock_request.scope = {} + + result = await get_request_body(mock_request) + assert result == payload + + @pytest.mark.asyncio + async def test_form_post_routes_to_form_data(self): + mock_request = MagicMock() + mock_request.method = "POST" + mock_request.headers = {"content-type": "multipart/form-data; boundary=x"} + mock_request.form = AsyncMock(return_value={"k": "v"}) + mock_request.scope = {} + + result = await get_request_body(mock_request) + assert result == {"k": "v"} + + @pytest.mark.asyncio + async def test_substring_match_no_longer_accepted(self): + mock_request = MagicMock() + mock_request.method = "POST" + mock_request.headers = {"content-type": "application/form-json"} + mock_request.scope = {} + + with pytest.raises(ValueError, match="Unsupported content type"): + await get_request_body(mock_request) + + @pytest.mark.asyncio + async def test_non_post_returns_empty(self): + mock_request = MagicMock() + mock_request.method = "GET" + assert await get_request_body(mock_request) == {} diff --git a/tests/test_litellm/proxy/google_endpoints/test_interactions_agent_param.py b/tests/test_litellm/proxy/google_endpoints/test_interactions_agent_param.py index 1063f59afb6..f3cec320532 100644 --- a/tests/test_litellm/proxy/google_endpoints/test_interactions_agent_param.py +++ b/tests/test_litellm/proxy/google_endpoints/test_interactions_agent_param.py @@ -1,75 +1,103 @@ """ -Test for interactions endpoint agent parameter handling. +Tests for managed-agent interaction routing. -Tests that the /v1beta/interactions endpoint correctly extracts -the `agent` parameter as a fallback when `model` is not provided. +Custom Gemini agents are identified by ``agent`` (name/id), not ``model``. +The proxy must not pass the agent name as ``model`` or LiteLLM may route to +openai/* wildcards instead of Gemini interactions. """ +from unittest.mock import MagicMock, patch + import pytest class TestInteractionsAgentParameter: - """Test agent parameter handling in interactions endpoint.""" + """Proxy endpoint must keep agent and model separate.""" - def test_agent_parameter_fallback_logic(self): - """ - Test the core logic: model or agent extraction. - - This tests the fix in endpoints.py line ~267: - model=data.get("model") or data.get("agent") - """ - # Case 1: Only agent provided (Deep Research use case) + def test_create_interaction_uses_model_only_from_body(self): + """POST /v1beta/interactions: model kwarg is only the request's model field.""" data = { - "agent": "deep-research-pro-preview-12-2025", - "input": "Research quantum computing", - "background": True, + "agent": "mqy-custom-slides-agent", + "input": "hello", } - model = data.get("model") or data.get("agent") - assert model == "deep-research-pro-preview-12-2025" + # Fixed behavior: do NOT fall back agent → model + model_for_routing = data.get("model") + assert model_for_routing is None + assert data.get("agent") == "mqy-custom-slides-agent" - # Case 2: Only model provided (normal use case) + def test_model_field_still_used_when_present(self): data = { "model": "gemini-2.5-flash", - "input": "Hello world", + "input": "hello", } - model = data.get("model") or data.get("agent") - assert model == "gemini-2.5-flash" + model_for_routing = data.get("model") + assert model_for_routing == "gemini-2.5-flash" - # Case 3: Both provided (model takes precedence) - data = { - "model": "gemini-2.5-flash", - "agent": "deep-research-pro-preview-12-2025", - "input": "Test", - } - model = data.get("model") or data.get("agent") - assert model == "gemini-2.5-flash" - # Case 4: Neither provided - data = { - "input": "Test", - } - model = data.get("model") or data.get("agent") - assert model is None +class TestInteractionsAgentOnlyProviderRouting: + """SDK: agent-only create must not call get_llm_provider on the agent name.""" - def test_route_type_in_skip_model_routing_list(self): - """ - Test that acreate_interaction is in the list of routes - that skip model-based routing. + @patch("litellm.interactions.main.interactions_http_handler") + @patch("litellm.interactions.main.get_provider_interactions_api_config") + @patch("litellm.get_llm_provider") + def test_agent_only_skips_get_llm_provider( + self, + mock_get_llm_provider, + mock_get_config, + mock_handler, + ): + from litellm.interactions.main import create + from litellm.types.interactions import InteractionsAPIResponse - This tests the fix in route_llm_request.py. - """ - # The list of routes that skip model routing for interactions - skip_model_routing_routes = [ - "acreate_interaction", - "aget_interaction", - "adelete_interaction", - "acancel_interaction", - ] + mock_get_config.return_value = MagicMock() + mock_handler.create_interaction.return_value = InteractionsAPIResponse( + id="int-1", + status="completed", + object="interaction", + ) - # acreate_interaction should be in the list (this is the fix) - assert "acreate_interaction" in skip_model_routing_routes + logging_obj = MagicMock() + create( + agent="mqy-custom-slides-agent", + input="test", + custom_llm_provider="gemini", + litellm_logging_obj=logging_obj, + ) - # All interaction routes should be covered - assert "aget_interaction" in skip_model_routing_routes - assert "adelete_interaction" in skip_model_routing_routes - assert "acancel_interaction" in skip_model_routing_routes + mock_get_llm_provider.assert_not_called() + call_kwargs = mock_handler.create_interaction.call_args.kwargs + assert call_kwargs["agent"] == "mqy-custom-slides-agent" + assert call_kwargs["model"] is None + assert call_kwargs["custom_llm_provider"] == "gemini" + + @patch("litellm.interactions.main.interactions_http_handler") + @patch("litellm.interactions.main.get_provider_interactions_api_config") + @patch("litellm.get_llm_provider") + def test_proxy_mistake_model_equals_agent_is_corrected( + self, + mock_get_llm_provider, + mock_get_config, + mock_handler, + ): + """If model was wrongly set to the agent name, clear it before the HTTP call.""" + from litellm.interactions.main import create + from litellm.types.interactions import InteractionsAPIResponse + + mock_get_config.return_value = MagicMock() + mock_handler.create_interaction.return_value = InteractionsAPIResponse( + id="int-1", + status="completed", + object="interaction", + ) + + logging_obj = MagicMock() + create( + model="mqy-custom-slides-agent", + agent="mqy-custom-slides-agent", + input="test", + custom_llm_provider="gemini", + litellm_logging_obj=logging_obj, + ) + + mock_get_llm_provider.assert_not_called() + assert mock_handler.create_interaction.call_args.kwargs["model"] is None diff --git a/tests/test_litellm/proxy/google_endpoints/test_managed_agents_model_param.py b/tests/test_litellm/proxy/google_endpoints/test_managed_agents_model_param.py new file mode 100644 index 00000000000..5485d0f2929 --- /dev/null +++ b/tests/test_litellm/proxy/google_endpoints/test_managed_agents_model_param.py @@ -0,0 +1,199 @@ +""" +Tests verifying that managed-agent proxy endpoints never pass the agent name +as the ``model`` parameter to ``base_process_llm_request``. + +Passing ``model=`` would cause ``common_processing_pre_call_logic`` +to write the agent name into ``self.data["model"]``, which triggers spurious +model-alias mapping, rate-limiting lookups, and logging tied to a +non-existent model deployment. The agent name is already carried in +``data["name"]`` and must not pollute the ``model`` slot. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _build_agents_client(): + """Build a TestClient whose auth dependency is overridden to a PROXY_ADMIN + user. Using ``dependency_overrides`` is the only reliable way to bypass the + real ``user_api_key_auth`` for FastAPI route tests — patching the module- + level name does not affect the function reference captured by ``Depends``. + The PROXY_ADMIN role also bypasses the caller-supplied-api_key guard so + these tests can focus on the ``model=None`` invariant. + """ + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.google_endpoints.agents_endpoints import router as agents_router + + app = FastAPI() + app.include_router(agents_router) + + async def _fake_user_api_key_auth(): + return UserAPIKeyAuth( + api_key="sk-test", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + app.dependency_overrides[user_api_key_auth] = _fake_user_api_key_auth + return TestClient(app) + + +def _patch_proxy_server_imports(client=None): + """Return a context-manager that stubs _proxy_server_imports so tests + don't need a running proxy.""" + mock_srv = { + "general_settings": {}, + "llm_router": MagicMock(), + "proxy_config": MagicMock(), + "proxy_logging_obj": MagicMock(), + "select_data_generator": None, + "user_api_base": None, + "user_max_tokens": None, + "user_model": None, + "user_request_timeout": None, + "user_temperature": None, + "version": "0.0.0", + } + return patch( + "litellm.proxy.google_endpoints.agents_endpoints._proxy_server_imports", + return_value=mock_srv, + ) + + +def _patch_base_process(return_value=None): + if return_value is None: + return_value = {"name": "agents/my-agent", "displayName": "My Agent"} + return patch( + "litellm.proxy.google_endpoints.agents_endpoints.ProxyBaseLLMRequestProcessing.base_process_llm_request", + new_callable=AsyncMock, + return_value=return_value, + ) + + +def _patch_auth(): + """Deprecated no-op kept for call-site compatibility. + + ``_build_agents_client`` now installs a FastAPI ``dependency_overrides`` + entry that injects a PROXY_ADMIN ``UserAPIKeyAuth``, so individual tests + no longer need to patch the module-level ``user_api_key_auth`` name. + """ + return patch("os.getpid") + + +class TestManagedAgentsModelParam: + """Endpoints must pass model=None, not the agent name, to base_process_llm_request.""" + + def test_create_agent_passes_model_none(self): + """POST /v1beta/agents: model kwarg must be None, not the name field.""" + try: + client = _build_agents_client() + except ImportError as exc: + pytest.skip(f"Skipping: missing dependency {exc}") + + with ( + _patch_proxy_server_imports(), + _patch_base_process() as mock_process, + _patch_auth(), + ): + client.post( + "/v1beta/agents", + json={ + "name": "my-custom-slides-agent", + "base_agent": "waverunner", + "instructions": "Be helpful.", + }, + ) + + mock_process.assert_called_once() + kwargs = mock_process.call_args.kwargs + assert kwargs["model"] is None, ( + f"create_gemini_agent must not pass model={kwargs['model']!r}; " + "the agent name must stay in data['name'], not pollute data['model']" + ) + assert kwargs["route_type"] == "acreate_agent" + + def test_get_agent_passes_model_none(self): + """GET /v1beta/agents/{name}: model kwarg must be None.""" + try: + client = _build_agents_client() + except ImportError as exc: + pytest.skip(f"Skipping: missing dependency {exc}") + + with ( + _patch_proxy_server_imports(), + _patch_base_process() as mock_process, + _patch_auth(), + ): + client.get("/v1beta/agents/my-custom-slides-agent") + + mock_process.assert_called_once() + kwargs = mock_process.call_args.kwargs + assert ( + kwargs["model"] is None + ), f"get_gemini_agent must not pass model={kwargs['model']!r}" + assert kwargs["route_type"] == "aget_agent" + + def test_delete_agent_passes_model_none(self): + """DELETE /v1beta/agents/{name}: model kwarg must be None.""" + try: + client = _build_agents_client() + except ImportError as exc: + pytest.skip(f"Skipping: missing dependency {exc}") + + with ( + _patch_proxy_server_imports(), + _patch_base_process() as mock_process, + _patch_auth(), + ): + client.delete("/v1beta/agents/my-custom-slides-agent") + + mock_process.assert_called_once() + kwargs = mock_process.call_args.kwargs + assert ( + kwargs["model"] is None + ), f"delete_gemini_agent must not pass model={kwargs['model']!r}" + assert kwargs["route_type"] == "adelete_agent" + + def test_list_agent_versions_passes_model_none(self): + """GET /v1beta/agents/{name}/versions: model kwarg must be None.""" + try: + client = _build_agents_client() + except ImportError as exc: + pytest.skip(f"Skipping: missing dependency {exc}") + + with ( + _patch_proxy_server_imports(), + _patch_base_process() as mock_process, + _patch_auth(), + ): + client.get("/v1beta/agents/my-custom-slides-agent/versions") + + mock_process.assert_called_once() + kwargs = mock_process.call_args.kwargs + assert ( + kwargs["model"] is None + ), f"list_gemini_agent_versions must not pass model={kwargs['model']!r}" + assert kwargs["route_type"] == "alist_agent_versions" + + def test_list_agents_already_passes_model_none(self): + """GET /v1beta/agents: existing list endpoint already passes model=None — keep it so.""" + try: + client = _build_agents_client() + except ImportError as exc: + pytest.skip(f"Skipping: missing dependency {exc}") + + with ( + _patch_proxy_server_imports(), + _patch_base_process(return_value={"agents": []}) as mock_process, + _patch_auth(), + ): + client.get("/v1beta/agents") + + mock_process.assert_called_once() + kwargs = mock_process.call_args.kwargs + assert kwargs["model"] is None + assert kwargs["route_type"] == "alist_agents" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index a3247d2e557..71178c4826c 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -2073,6 +2073,226 @@ def test_get_http_exception_includes_assessments_and_identifier(): assert exc.detail["assessments"][0]["matches"][0]["match"] == "[REDACTED]" +def test_extract_violation_category_names_mixed_policies(): + """Topic names, content-filter types, PII types, and managed-word types + flatten into a single category-name list — using only the operator- + defined `name`/`type` labels.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "topicPolicy": { + "topics": [ + {"name": "Fiduciary Advice", "action": "BLOCKED"}, + {"name": "Tax Advice", "action": "BLOCKED"}, + ] + }, + "contentPolicy": { + "filters": [{"type": "VIOLENCE", "action": "BLOCKED"}] + }, + "wordPolicy": { + "managedWordLists": [{"type": "PROFANITY", "action": "BLOCKED"}], + }, + "sensitiveInformationPolicy": { + "piiEntities": [{"type": "EMAIL", "action": "BLOCKED"}] + }, + } + ], + } + names = g._extract_violation_category_names(response) + assert "Fiduciary Advice" in names + assert "Tax Advice" in names + assert "VIOLENCE" in names + assert "PROFANITY" in names + assert "EMAIL" in names + + +def test_extract_violation_category_names_does_not_leak_user_input(): + """SECURITY: customWords.match is the raw user-submitted word that + triggered the rule, and an unnamed regex match is the actual sensitive + value (e.g. a credit-card number). Neither must appear in + violation_categories — otherwise the content the guardrail blocked + leaks straight into telemetry backends.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "wordPolicy": { + "customWords": [ + {"match": "secret-codeword-abc-123", "action": "BLOCKED"} + ], + }, + "sensitiveInformationPolicy": { + "regexes": [{"match": "4111-1111-1111-1111", "action": "BLOCKED"}] + }, + } + ], + } + names = g._extract_violation_category_names(response) + assert "secret-codeword-abc-123" not in names + assert "4111-1111-1111-1111" not in names + assert names == [] + + +def test_extract_violation_category_names_named_regex_uses_name(): + """A regex with a `name` field surfaces that operator-defined label + (safe to log), not the matched value.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "regexes": [ + { + "name": "credit-card-pattern", + "match": "4111-1111-1111-1111", + "action": "BLOCKED", + } + ] + } + } + ], + } + names = g._extract_violation_category_names(response) + assert names == ["credit-card-pattern"] + + +def test_extract_violation_category_names_skips_anonymized(): + """ANONYMIZED entries are not blocks — they must not contribute to the + violation_categories list.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [{"type": "NAME", "action": "ANONYMIZED"}] + } + } + ], + } + assert g._extract_violation_category_names(response) == [] + + +def test_extract_violation_category_names_no_assessments(): + """Empty / missing assessments → empty list, not an error.""" + g = _make_guardrail() + assert g._extract_violation_category_names({"action": "NONE"}) == [] + assert g._extract_violation_category_names({"assessments": None}) == [] + + +@pytest.mark.asyncio +async def test_make_bedrock_api_request_forwards_guardrail_action(): + """Bedrock's top-level ``action`` string must be propagated through + ``tracing_detail`` so downstream loggers (OTEL, ...) can surface the + raw provider verdict as a queryable attribute without re-parsing the + redacted guardrail_response blob.""" + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT" + ) + mock_credentials = MagicMock() + mock_credentials.access_key = "k" + mock_credentials.secret_key = "s" + mock_credentials.token = None + + mock_bedrock_response = MagicMock() + mock_bedrock_response.status_code = 200 + mock_bedrock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "topicPolicy": { + "topics": [{"name": "Fiduciary Advice", "action": "BLOCKED"}] + } + } + ], + } + + request_data = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "hi"}], + } + + with ( + patch.object( + guardrail.async_handler, "post", new_callable=AsyncMock + ) as mock_post, + patch.object( + guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), + patch.object(guardrail, "_prepare_request", return_value=MagicMock()), + patch.object( + guardrail, + "add_standard_logging_guardrail_information_to_request_data", + ) as mock_log, + patch.object( + guardrail, + "_get_http_exception_for_blocked_guardrail", + return_value=Exception("blocked"), + ), + ): + mock_post.return_value = mock_bedrock_response + + with pytest.raises(Exception): + await guardrail.make_bedrock_api_request( + source="INPUT", + messages=request_data["messages"], + request_data=request_data, + ) + + tracing_detail = mock_log.call_args.kwargs["tracing_detail"] + assert tracing_detail is not None + assert tracing_detail["guardrail_action"] == "GUARDRAIL_INTERVENED" + + +@pytest.mark.asyncio +async def test_make_bedrock_api_request_omits_guardrail_action_when_missing(): + """If the Bedrock response omits ``action`` (older / partial payloads), + the field must be left off ``tracing_detail`` rather than written as + ``None`` — downstream code expects strings or absence, not nulls.""" + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT" + ) + mock_credentials = MagicMock() + mock_credentials.access_key = "k" + mock_credentials.secret_key = "s" + mock_credentials.token = None + + mock_bedrock_response = MagicMock() + mock_bedrock_response.status_code = 200 + mock_bedrock_response.json.return_value = {"assessments": []} + + with ( + patch.object( + guardrail.async_handler, "post", new_callable=AsyncMock + ) as mock_post, + patch.object( + guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), + patch.object(guardrail, "_prepare_request", return_value=MagicMock()), + patch.object( + guardrail, + "add_standard_logging_guardrail_information_to_request_data", + ) as mock_log, + ): + mock_post.return_value = mock_bedrock_response + + await guardrail.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hi"}], + request_data={"model": "gpt-4o", "messages": []}, + ) + + tracing_detail = mock_log.call_args.kwargs["tracing_detail"] + # No violation categories and no action ⇒ tracing_detail stays None + # (the hook collapses an empty dict before forwarding). + if tracing_detail is not None: + assert "guardrail_action" not in tracing_detail + + def test_get_http_exception_no_blocked_assessments_omits_field(): """L3: when no assessments are blocked, the `assessments` key is omitted entirely.""" g = _make_guardrail() diff --git a/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py b/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py index b17b3270787..cb2276ab39d 100644 --- a/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py +++ b/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py @@ -219,7 +219,7 @@ def test_build_claims_scope_with_tool(): def test_build_claims_scope_without_tool(): - """_build_claims() includes mcp:tools/list when no specific tool is called.""" + """_build_claims() emits only mcp:tools/list when no specific tool is called.""" signer = _make_signer() user_dict = _make_user_api_key_dict() data: Dict[str, Any] = {} @@ -227,10 +227,11 @@ def test_build_claims_scope_without_tool(): claims = signer._build_claims(user_dict, data) scopes = set(claims["scope"].split()) - assert "mcp:tools/call" in scopes assert "mcp:tools/list" in scopes + # List-only JWTs must NOT carry mcp:tools/call — least-privilege + assert "mcp:tools/call" not in scopes # No per-tool call scope when no tool name was given - assert not any(s.endswith(":call") and s != "mcp:tools/call" for s in scopes) + assert not any(s.endswith(":call") for s in scopes) def test_build_claims_act_fallback_to_litellm_proxy(): @@ -338,7 +339,7 @@ async def test_hook_skips_non_mcp_call_types(): user_dict = _make_user_api_key_dict() data = {"messages": [{"role": "user", "content": "hello"}]} - for call_type in ("completion", "acompletion", "embedding", "list_mcp_tools"): + for call_type in ("completion", "acompletion", "embedding"): original_data = {**data} result = await signer.async_pre_call_hook( user_api_key_dict=user_dict, @@ -351,6 +352,33 @@ async def test_hook_skips_non_mcp_call_types(): ), f"extra_headers should not be set for {call_type}" +@pytest.mark.asyncio +async def test_hook_signs_list_mcp_tools(): + """async_pre_call_hook() signs JWT for list_mcp_tools with list scope.""" + signer = _make_signer( + issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300 + ) + user_dict = _make_user_api_key_dict(user_id="alice", team_id="backend") + data = {"mcp_tool_name": "should_be_cleared"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="list_mcp_tools", + ) + + assert isinstance(result, dict) + assert "extra_headers" in result + assert result["extra_headers"]["Authorization"].startswith("Bearer ") + token = result["extra_headers"]["Authorization"].removeprefix("Bearer ") + decoded = _decode_unverified(token) + scopes = set(decoded["scope"].split()) + assert "mcp:tools/list" in scopes + # List-only JWTs must NOT carry mcp:tools/call — least-privilege + assert "mcp:tools/call" not in scopes + + @pytest.mark.asyncio async def test_signed_token_is_verifiable(): """The JWT injected by the hook can be verified against the JWKS public key.""" @@ -1128,3 +1156,116 @@ async def test_hook_raises_401_when_jwt_verification_fails(): ) assert exc_info.value.status_code == 401 + + +# --- _build_scope branches: call_mcp_tool with empty tool name, list_mcp_tools --- + + +def test_build_scope_call_type_call_mcp_tool_without_tool_name(): + """call_mcp_tool with empty tool name emits a generic mcp:tools/call only.""" + signer = _make_signer() + scope = signer._build_scope("", call_type="call_mcp_tool") + scopes = set(scope.split()) + assert scopes == {"mcp:tools/call"} + + +def test_build_scope_call_type_list_mcp_tools_only_list(): + """list_mcp_tools (no tool) emits only mcp:tools/list, never tools/call.""" + signer = _make_signer() + scope = signer._build_scope("", call_type="list_mcp_tools") + scopes = set(scope.split()) + assert scopes == {"mcp:tools/list"} + + +def test_build_scope_default_is_list_only_when_no_call_type(): + """No call_type and no tool falls through to tools/list (least-privilege default).""" + signer = _make_signer() + scope = signer._build_scope("") + scopes = set(scope.split()) + assert "mcp:tools/list" in scopes + assert "mcp:tools/call" not in scopes + + +# --- inject_mcp_jwt_headers_for_upstream --- + + +@pytest.mark.asyncio +async def test_inject_mcp_jwt_returns_unchanged_when_signer_not_configured(): + """No signer configured -> return a fresh copy of extra_headers untouched.""" + import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as mod + from litellm.proxy._types import UserAPIKeyAuth + + mod._mcp_jwt_signer_instance = None + headers = {"X-Trace-Id": "abc"} + user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + + result = await mod.inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=user_dict, + extra_headers=headers, + ) + assert result == headers + assert result is not headers # must be a copy + + +@pytest.mark.asyncio +async def test_inject_mcp_jwt_returns_unchanged_when_user_dict_none(): + """No user_api_key_dict -> short-circuit without invoking the signer.""" + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + inject_mcp_jwt_headers_for_upstream, + ) + + _make_signer() # ensure instance is created + result = await inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=None, + extra_headers={"X-Trace-Id": "abc"}, + ) + assert result == {"X-Trace-Id": "abc"} + + +@pytest.mark.asyncio +async def test_inject_mcp_jwt_signs_for_list_tools_path(): + """When for_list_tools=True, signer is invoked with list_mcp_tools call_type.""" + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + inject_mcp_jwt_headers_for_upstream, + ) + + _make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300) + user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + + result = await inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=user_dict, + extra_headers={"X-Trace": "1"}, + raw_headers={"Authorization": "Bearer incoming.opaque.token"}, + for_list_tools=True, + ) + assert result["X-Trace"] == "1" + assert result["Authorization"].startswith("Bearer ") + token = result["Authorization"].removeprefix("Bearer ") + decoded = _decode_unverified(token) + scopes = set(decoded["scope"].split()) + assert scopes == {"mcp:tools/list"} + + +@pytest.mark.asyncio +async def test_inject_mcp_jwt_signs_for_tool_call_path(): + """for_list_tools=False with a tool name signs a call_mcp_tool JWT.""" + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + inject_mcp_jwt_headers_for_upstream, + ) + + _make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300) + user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice") + + result = await inject_mcp_jwt_headers_for_upstream( + user_api_key_dict=user_dict, + for_list_tools=False, + mcp_tool_name="search_web", + ) + assert result["Authorization"].startswith("Bearer ") + token = result["Authorization"].removeprefix("Bearer ") + decoded = _decode_unverified(token) + scopes = set(decoded["scope"].split()) + assert "mcp:tools/call" in scopes + assert "mcp:tools/search_web:call" in scopes diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 83317157847..23216542f35 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2218,6 +2218,7 @@ class TestCLIKeyRegenerationFlow: # Mock request mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://internal-proxy.local/" # Test data session_key = "cli-session-4567890" @@ -2242,11 +2243,14 @@ class TestCLIKeyRegenerationFlow: "user_code_verified": False, "session_data": None, } - mock_request.url_for.return_value = ( - "https://test.example.com/sso/cli/complete/cli-session-4567890" - ) - with ( + patch.dict( + os.environ, + { + "PROXY_BASE_URL": "https://test.example.com", + "SERVER_ROOT_PATH": "", + }, + ), patch( "litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db", return_value=mock_user_info, @@ -2290,6 +2294,10 @@ class TestCLIKeyRegenerationFlow: assert result.status_code == 200 # Verify response contains success message (response is HTML) assert result.body is not None + assert ( + 'action="https://test.example.com/sso/cli/complete/cli-session-4567890"' + in result.body.decode() + ) @pytest.mark.asyncio async def test_cli_poll_key_returns_teams_for_selection(self): diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 46a55fc7468..2aacb0299e7 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -483,6 +483,136 @@ class TestProxyInitializationHelpers: assert appended_params["connection_limit"] == 5 assert appended_params["pool_timeout"] == expected_timeout + def test_build_db_connection_url_params_defaults(self): + from litellm.proxy.proxy_cli import _build_db_connection_url_params + + params = _build_db_connection_url_params(connection_limit=10, pool_timeout=60) + assert params == {"connection_limit": 10, "pool_timeout": 60} + + def test_build_db_connection_url_params_omits_none_timeouts(self): + from litellm.proxy.proxy_cli import _build_db_connection_url_params + + params = _build_db_connection_url_params( + connection_limit=10, + pool_timeout=60, + connect_timeout=None, + socket_timeout=None, + ) + assert "connect_timeout" not in params + assert "socket_timeout" not in params + + def test_build_db_connection_url_params_includes_optional_timeouts(self): + from litellm.proxy.proxy_cli import _build_db_connection_url_params + + params = _build_db_connection_url_params( + connection_limit=10, + pool_timeout=60, + connect_timeout=15, + socket_timeout=120, + ) + assert params["connect_timeout"] == 15 + assert params["socket_timeout"] == 120 + + def test_build_db_connection_url_params_extras_override_defaults(self): + from litellm.proxy.proxy_cli import _build_db_connection_url_params + + params = _build_db_connection_url_params( + connection_limit=10, + pool_timeout=60, + extra_params={ + "pgbouncer": "true", + "statement_cache_size": 0, + "pool_timeout": 5, + }, + ) + assert params["pgbouncer"] == "true" + assert params["statement_cache_size"] == 0 + assert params["pool_timeout"] == 5 + + @patch("subprocess.run") + @patch("atexit.register") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") + @patch( + "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False + ) + def test_db_connection_extra_params_forwarded_to_url( + self, + mock_should_update, + mock_setup_db, + mock_atexit_register, + mock_subprocess_run, + ): + from click.testing import CliRunner + + from litellm.proxy.proxy_cli import run_server + + runner = CliRunner() + mock_subprocess_run.return_value = MagicMock(returncode=0) + + mock_proxy_module = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( + return_value={ + "general_settings": { + "database_url": "postgresql://test:test@localhost:5432/test", + "database_connect_timeout": 15, + "database_socket_timeout": 120, + "database_extra_connection_params": { + "pgbouncer": "true", + "statement_cache_size": 0, + }, + } + } + ) + + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL") + } + + with ( + patch.dict(os.environ, clean_env, clear=True), + patch.dict( + "sys.modules", + { + "proxy_server": mock_proxy_module, + "litellm.proxy.proxy_server": mock_proxy_module, + }, + ), + patch( + "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" + ) as mock_get_args, + patch( + "litellm.proxy.proxy_cli.append_query_params", + side_effect=lambda url, params: str(url), + ) as mock_append_query_params, + ): + mock_get_args.return_value = { + "app": "litellm.proxy.proxy_server:app", + "host": "localhost", + "port": 8000, + } + + result = runner.invoke( + run_server, + ["--local", "--config", "test-config.yaml", "--skip_server_startup"], + ) + + assert ( + result.exit_code == 0 + ), f"exit_code={result.exit_code}, output={result.output}" + mock_append_query_params.assert_called() + appended_params = mock_append_query_params.call_args.args[1] + assert appended_params["connect_timeout"] == 15 + assert appended_params["socket_timeout"] == 120 + assert appended_params["pgbouncer"] == "true" + assert appended_params["statement_cache_size"] == 0 + @patch("uvicorn.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 73d53631622..ae0996d16d5 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5065,6 +5065,66 @@ async def test_async_data_generator_uses_direct_stream_fast_path_without_callbac mock_response.aclose.assert_awaited_once() +@pytest.mark.asyncio +async def test_async_data_generator_passes_through_google_native_sse_bytes(): + """ + Google-native streamGenerateContent yields raw SSE bytes; they must not be + re-wrapped as data: b'data: {...}'. + """ + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.proxy_server import async_data_generator + from litellm.proxy.utils import ProxyLogging + + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + mock_request_data = { + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "test"}], + } + gemini_event = b'data: {"candidates": [{"content": "hi"}]}\n\n' + gemini_event_without_terminator = b'data: {"candidates": [{"content": "there"}]}' + raw_payload = b'{"partial": true}' + + class MockStream: + def __aiter__(self): + return self._stream() + + async def _stream(self): + yield gemini_event + yield gemini_event_without_terminator + yield raw_payload + + async def aclose(self): + pass + + mock_response = MockStream() + mock_response.aclose = AsyncMock() + mock_proxy_logging_obj = MagicMock(spec=ProxyLogging) + mock_proxy_logging_obj.has_streaming_callbacks.return_value = False + mock_proxy_logging_obj.needs_iterator_wrap.return_value = False + mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False + mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock() + mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock() + mock_proxy_logging_obj.post_call_failure_hook = AsyncMock() + + with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj): + with patch.object(ProxyLogging, "_fire_deferred_stream_logging"): + yielded_data = [] + async for data in async_data_generator( + mock_response, mock_user_api_key_dict, mock_request_data + ): + yielded_data.append(data) + + yielded_text = [ + chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk + for chunk in yielded_data + ] + assert yielded_text[0] == gemini_event.decode("utf-8") + assert yielded_text[1] == gemini_event_without_terminator.decode("utf-8") + "\n\n" + assert yielded_text[2] == f'data: {raw_payload.decode("utf-8")}\n\n' + assert "b'data:" not in "".join(yielded_text) + assert yielded_text[-1] == "data: [DONE]\n\n" + + @pytest.mark.asyncio async def test_async_data_generator_cleanup_on_normal_completion(): """ @@ -5648,6 +5708,7 @@ async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss( fake_redis = AsyncMock() fake_redis.async_increment = AsyncMock(side_effect=record_increment) fake_redis.async_get_cache = AsyncMock(return_value=None) # counter missing + fake_redis.async_set_cache = AsyncMock(return_value=True) # SET NX wins counter_cache.redis_cache = fake_redis # Prisma returns spend=42.0 (authoritative) while the stale cached @@ -5684,16 +5745,131 @@ async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss( fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with( where={"team_id": "team-9"} ) - # Two increments keyed on the counter: seed ($42) then request ($1.50). + # Seed uses SET NX with db_spend (42) — cross-pod safe, no INCR of 42. + # Only the per-request delta (1.5) goes through INCRBYFLOAT. + fake_redis.async_set_cache.assert_awaited_once_with( + key="spend:team:team-9", value=42.0, nx=True + ) writes = [(c["key"], c["value"]) for c in recorded_increments] - assert ("spend:team:team-9", 42.0) in writes - assert ("spend:team:team-9", 1.5) in writes + assert writes == [("spend:team:team-9", 1.5)] finally: ps.user_api_key_cache = orig_user ps.spend_counter_cache = orig_counter ps.prisma_client = orig_prisma +@pytest.mark.asyncio +async def test_primary_spend_counter_redis_concurrent_seed_does_not_double_seed(): + """Two pods both observing a missing Redis counter must not both + INCRBYFLOAT the full DB spend. SpendCounterReseed.coalesced uses SET NX + so the loser reads the winner's value; final Redis = db_spend, not + 2 * db_spend. + + The per-counter asyncio.Lock is per-process, so it does NOT coordinate + across pods. We simulate two pods by patching _get_lock to return a + fresh lock per call (each "pod" has its own lock registry in real life). + """ + from litellm.caching.dual_cache import DualCache + from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed + + counter_key = "spend:team:team-concurrent-seed" + redis_store: dict = {} + db_read_count = 0 + set_results: list = [] + get_after_set_count = 0 + set_completed_count = 0 + + async def redis_set_cache(key, value, nx=False, **_): + # Yield BEFORE the membership check so two concurrent callers + # interleave the way real atomic Redis SET NX does: the first + # to resume runs check + write atomically and wins; the second + # resumes after the key exists and loses. Yielding *after* the + # check would let both callers pass the empty-store check before + # either writes, so neither would ever lose. + await asyncio.sleep(0) + if nx and key in redis_store: + set_results.append(False) + return False + redis_store[key] = float(value) + set_results.append(True) + nonlocal set_completed_count + set_completed_count += 1 + return True + + async def redis_get_cache(key): + # Track reads that happen after at least one SET NX has completed + # — those are the loser-path fallback reads we want to verify. + if set_completed_count > 0: + nonlocal get_after_set_count + get_after_set_count += 1 + return redis_store.get(key) + + fake_redis = AsyncMock() + fake_redis.async_get_cache = AsyncMock(side_effect=redis_get_cache) + fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache) + + async def slow_find_unique(**_): + nonlocal db_read_count + db_read_count += 1 + # Both pods read DB before either's SET NX lands. + await asyncio.sleep(0) + row = MagicMock() + row.spend = 506.0 + return row + + fake_prisma = MagicMock() + fake_prisma.db.litellm_teamtable.find_unique = AsyncMock( + side_effect=slow_find_unique + ) + + pod_a = DualCache() + pod_a.redis_cache = fake_redis + pod_b = DualCache() + pod_b.redis_cache = fake_redis + + # Each "pod" has its own per-process lock registry. Patch _get_lock to + # always return a fresh lock so the two coalesced calls do not serialize + # via one in-process lock (which is what would happen across pods). + async def fresh_lock(_counter_key): + return asyncio.Lock() + + with patch.object(SpendCounterReseed, "_get_lock", side_effect=fresh_lock): + results = await asyncio.gather( + SpendCounterReseed.coalesced( + prisma_client=fake_prisma, + spend_counter_cache=pod_a, + counter_key=counter_key, + ), + SpendCounterReseed.coalesced( + prisma_client=fake_prisma, + spend_counter_cache=pod_b, + counter_key=counter_key, + ), + ) + + assert all(r == 506.0 for r in results), results + assert redis_store[counter_key] == pytest.approx(506.0), redis_store + # Both pods read the DB and both attempted SET NX; exactly one wrote + # (winner) and one was rejected (loser). + assert db_read_count == 2 + assert fake_redis.async_set_cache.await_count == 2 + nx_writes = [ + call + for call in fake_redis.async_set_cache.await_args_list + if call.kwargs.get("nx") is True + ] + assert len(nx_writes) == 2 + assert sorted(set_results) == [False, True], ( + f"expected exactly one SET NX winner and one loser, got {set_results}" + ) + # Loser path executed: after the winner's SET NX returned True, the + # losing coalesced() call falls back to async_get_cache to read the + # winner's value rather than re-seeding. + assert get_after_set_count >= 1, ( + "loser branch (else: read back winner's value) was never exercised" + ) + + @pytest.mark.asyncio async def test_reseed_spend_from_db_user_and_org_prefixes(): """User and org counters reseed from their own DB tables. @@ -5817,9 +5993,16 @@ async def test_init_spend_counter_redis_clean_miss_skips_stale_in_memory(): redis_store[key] = (redis_store.get(key) or 0.0) + value return redis_store[key] + async def redis_set_cache(key, value, nx=False, **_): + if nx and key in redis_store: + return False + redis_store[key] = float(value) + return True + fake_redis = AsyncMock() fake_redis.async_get_cache = AsyncMock(return_value=None) fake_redis.async_increment = AsyncMock(side_effect=redis_increment) + fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache) counter_cache.redis_cache = fake_redis db_row = MagicMock() @@ -5847,6 +6030,7 @@ async def test_init_spend_counter_redis_clean_miss_skips_stale_in_memory(): fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with( where={"team_id": "team-stale-local"} ) + # Seed via SET NX (42) + delta via INCRBYFLOAT (1.5) = 43.5. assert redis_store[counter_key] == pytest.approx(43.5) assert counter_cache.in_memory_cache.get_cache( key=counter_key @@ -6237,14 +6421,14 @@ async def test_get_current_spend_reseeds_from_db_when_counter_missing(): from litellm.proxy.proxy_server import get_current_spend counter_cache = DualCache() - recorded_warms: list = [] + recorded_seeds: list = [] - async def record_increment(key, value, ttl=None, **kwargs): - recorded_warms.append({"key": key, "value": value}) - return value + async def record_set_cache(key, value, nx=False, **kwargs): + recorded_seeds.append({"key": key, "value": value, "nx": nx}) + return True fake_redis = AsyncMock() - fake_redis.async_increment = AsyncMock(side_effect=record_increment) + fake_redis.async_set_cache = AsyncMock(side_effect=record_set_cache) fake_redis.async_get_cache = AsyncMock(return_value=None) counter_cache.redis_cache = fake_redis @@ -6269,9 +6453,9 @@ async def test_get_current_spend_reseeds_from_db_when_counter_missing(): f"expected DB reseed to return 362.0, got {spend} " f"(fallback would have returned 30.0 and caused bypass)" ) - # Counter warmed so subsequent reads are fast - assert ("spend:team_member:user-1:team-1", 362.0) in [ - (w["key"], w["value"]) for w in recorded_warms + # Counter warmed via SET NX so subsequent reads are fast. + assert ("spend:team_member:user-1:team-1", 362.0, True) in [ + (s["key"], s["value"], s["nx"]) for s in recorded_seeds ] assert counter_cache.in_memory_cache.get_cache( key="spend:team_member:user-1:team-1" @@ -6348,8 +6532,15 @@ async def test_get_current_spend_coalesces_concurrent_reseeds(): redis_store[key] = (redis_store.get(key) or 0.0) + value return redis_store[key] + async def redis_set_cache(key, value, nx=False, **_): + if nx and key in redis_store: + return False + redis_store[key] = float(value) + return True + fake_redis.async_get_cache = AsyncMock(side_effect=redis_get) fake_redis.async_increment = AsyncMock(side_effect=redis_increment) + fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache) counter_cache.redis_cache = fake_redis fake_prisma = MagicMock() @@ -6456,9 +6647,16 @@ async def test_concurrent_read_and_write_paths_share_one_db_query(): redis_store[key] = (redis_store.get(key) or 0.0) + value return redis_store[key] + async def redis_set_cache(key, value, nx=False, **_): + if nx and key in redis_store: + return False + redis_store[key] = float(value) + return True + fake_redis = AsyncMock() fake_redis.async_get_cache = AsyncMock(side_effect=redis_get) fake_redis.async_increment = AsyncMock(side_effect=redis_increment) + fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache) counter_cache.redis_cache = fake_redis fake_prisma = MagicMock() @@ -6561,9 +6759,16 @@ async def test_reseed_warms_cache_even_on_zero_db_spend(): redis_store[key] = (redis_store.get(key) or 0.0) + value return redis_store[key] + async def redis_set_cache(key, value, nx=False, **_): + if nx and key in redis_store: + return False + redis_store[key] = float(value) + return True + fake_redis = AsyncMock() fake_redis.async_get_cache = AsyncMock(side_effect=redis_get) fake_redis.async_increment = AsyncMock(side_effect=redis_increment) + fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache) counter_cache.redis_cache = fake_redis db_call_count = 0 diff --git a/tests/test_litellm/responses/test_sse_output_recovery.py b/tests/test_litellm/responses/test_sse_output_recovery.py new file mode 100644 index 00000000000..c8f3325a624 --- /dev/null +++ b/tests/test_litellm/responses/test_sse_output_recovery.py @@ -0,0 +1,57 @@ +"""Tests for litellm.responses.sse_output_recovery helpers.""" + +from litellm.responses.sse_output_recovery import ( + _MAX_CONTENT_INDEX, + record_output_text_chunk, +) + + +def test_text_chunk_with_oversized_content_index_is_dropped(): + output_items: dict = {} + text_only_items: dict = {} + record_output_text_chunk( + parsed_chunk={ + "type": "response.output_text.done", + "output_index": 0, + "content_index": _MAX_CONTENT_INDEX + 1, + "text": "ignored", + }, + output_items=output_items, + text_only_items=text_only_items, + ) + item = text_only_items[0] + assert item["content"] == [] + + +def test_text_chunk_with_negative_content_index_is_dropped(): + output_items: dict = {} + text_only_items: dict = {} + record_output_text_chunk( + parsed_chunk={ + "type": "response.output_text.done", + "output_index": 0, + "content_index": -1, + "text": "ignored", + }, + output_items=output_items, + text_only_items=text_only_items, + ) + assert text_only_items[0]["content"] == [] + + +def test_text_chunk_at_max_content_index_is_recorded(): + output_items: dict = {} + text_only_items: dict = {} + record_output_text_chunk( + parsed_chunk={ + "type": "response.output_text.done", + "output_index": 0, + "content_index": _MAX_CONTENT_INDEX, + "text": "kept", + }, + output_items=output_items, + text_only_items=text_only_items, + ) + content = text_only_items[0]["content"] + assert len(content) == _MAX_CONTENT_INDEX + 1 + assert content[_MAX_CONTENT_INDEX]["text"] == "kept" diff --git a/tests/test_litellm/router_utils/test_router_interactions_endpoints.py b/tests/test_litellm/router_utils/test_router_interactions_endpoints.py index c5468d73810..91bea170458 100644 --- a/tests/test_litellm/router_utils/test_router_interactions_endpoints.py +++ b/tests/test_litellm/router_utils/test_router_interactions_endpoints.py @@ -140,3 +140,159 @@ class TestInitInteractionsApiEndpoints: custom_llm_provider="vertex_ai", ) assert result == {"result": "success"} + + @pytest.mark.asyncio + async def test_init_interactions_api_endpoints_clears_model_when_equals_agent( + self, + ): + """Managed agent interactions must not pass agent name as model to the SDK.""" + router = Router(model_list=[]) + + mock_function = AsyncMock(return_value={"result": "success"}) + + await router._init_interactions_api_endpoints( + original_function=mock_function, + agent="mqy-custom-slides-agent", + model="mqy-custom-slides-agent", + input="hello", + ) + + mock_function.assert_called_once_with( + custom_llm_provider="gemini", + agent="mqy-custom-slides-agent", + model=None, + input="hello", + ) + + +class TestRouterCreateInteractionRouting: + """acreate_interaction routing: agent-only vs model + fallbacks.""" + + @pytest.mark.asyncio + async def test_acreate_interaction_agent_only_uses_init_interactions(self): + """Agent-only create must not use model-group fallback lookup.""" + router = Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": {"model": "gpt-4"}, + } + ] + ) + + with ( + patch.object( + router, + "_init_interactions_api_endpoints", + new_callable=AsyncMock, + return_value={"id": "int-1"}, + ) as mock_init, + patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new_callable=AsyncMock, + ) as mock_generic, + ): + result = await router.acreate_interaction( + agent="mqy-custom-slides-agent", + input="hello", + custom_llm_provider="gemini", + ) + + mock_init.assert_called_once() + mock_generic.assert_not_called() + assert result == {"id": "int-1"} + + @pytest.mark.asyncio + async def test_init_interactions_model_uses_generic_fallbacks(self): + """Model-based create uses _ageneric_api_call_with_fallbacks inside _init_interactions.""" + router = Router(model_list=[]) + + with patch.object( + router, + "_ageneric_api_call_with_fallbacks", + new_callable=AsyncMock, + return_value={"id": "int-1"}, + ) as mock_generic: + result = await router._init_interactions_api_endpoints( + original_function=AsyncMock(), + model="gemini-2.5-flash", + input="hello", + custom_llm_provider="gemini", + ) + + mock_generic.assert_called_once() + assert result == {"id": "int-1"} + + +class TestInitializeManagedAgentsEndpoints: + """Tests for _initialize_managed_agents_endpoints.""" + + def test_initialize_managed_agents_endpoints_creates_methods(self): + router = Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4"}, + } + ] + ) + + for method_name in ( + "acreate_agent", + "alist_agents", + "aget_agent", + "adelete_agent", + "alist_agent_versions", + ): + assert hasattr(router, method_name), f"missing {method_name}" + assert callable(getattr(router, method_name)), f"{method_name} not callable" + + def test_initialize_managed_agents_endpoints_can_be_called_directly(self): + router = Router(model_list=[]) + router._initialize_managed_agents_endpoints() + assert callable(router.acreate_agent) + assert callable(router.alist_agents) + + +class TestInitManagedAgentsApiEndpoints: + """Tests for _init_managed_agents_api_endpoints.""" + + @pytest.mark.asyncio + async def test_init_managed_agents_api_endpoints_defaults_to_gemini(self): + router = Router(model_list=[]) + mock_fn = AsyncMock(return_value={"agents": []}) + + await router._init_managed_agents_api_endpoints( + original_function=mock_fn, + ) + + call_kwargs = mock_fn.call_args.kwargs + assert call_kwargs["custom_llm_provider"] == "gemini" + + @pytest.mark.asyncio + async def test_init_managed_agents_api_endpoints_passes_custom_provider(self): + router = Router(model_list=[]) + mock_fn = AsyncMock(return_value={"agents": []}) + + await router._init_managed_agents_api_endpoints( + original_function=mock_fn, + custom_llm_provider="vertex_ai", + ) + + call_kwargs = mock_fn.call_args.kwargs + assert call_kwargs["custom_llm_provider"] == "vertex_ai" + + @pytest.mark.asyncio + async def test_init_managed_agents_api_endpoints_does_not_override_existing_provider( + self, + ): + router = Router(model_list=[]) + mock_fn = AsyncMock(return_value={"agents": []}) + + await router._init_managed_agents_api_endpoints( + original_function=mock_fn, + custom_llm_provider="vertex_ai", + ) + + mock_fn.assert_called_once_with(custom_llm_provider="vertex_ai") diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 1be4abbec6e..00902890da3 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -2059,6 +2059,25 @@ def test_openrouter_gemini_3_1_flash_lite_preview_pricing(): assert model_info["max_output_tokens"] == 65536 +def test_gemini_3_1_flash_lite_pricing(): + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + for model_name in ( + "gemini-3.1-flash-lite", + "gemini/gemini-3.1-flash-lite", + "vertex_ai/gemini-3.1-flash-lite", + ): + model_info = litellm.model_cost.get(model_name) + assert model_info is not None, f"Missing model pricing entry: {model_name}" + assert model_info["input_cost_per_token"] == 4.5e-07 + assert model_info["input_cost_per_audio_token"] == 9e-07 + assert model_info["output_cost_per_token"] == 2.7e-06 + assert model_info["output_cost_per_reasoning_token"] == 2.7e-06 + assert model_info["cache_read_input_token_cost"] == 4.5e-08 + assert model_info["max_input_tokens"] == 1048576 + + def test_custom_pricing_applies_cache_read_input_cost(): """ Bug 1 reproduction: custom_cost_per_token with cache_read_input_token_cost @@ -2371,3 +2390,34 @@ def test_custom_pricing_without_cache_keys_preserves_legacy_behavior(): expected = 1000 * 0.0000025 + 100 * 0.000015 assert cost == pytest.approx(expected) + + +def test_openrouter_gemini_3_1_flash_lite_stable_pricing(): + """ + Test that openrouter/google/gemini-3.1-flash-lite (stable, no -preview suffix) + has a pricing entry. + + Google promoted gemini-3.1-flash-lite to GA on 2026-05-07. PR #27933 added the + stable pricing for the bare, gemini/, and vertex_ai/ prefixes but missed the + openrouter/google/ variant — every other Gemini family in the file has an + openrouter/google/ sibling (2.0-flash-001, 2.5-flash, 2.5-pro, 3-flash-preview, + 3-pro-preview, 3.1-flash-lite-preview, 3.1-pro-preview), so the gap is a + consistency issue, not a design choice. Same shape as the preview-variant gap + fixed in PR #25610. + + Pricing matches the existing -preview entry one-for-one (input $0.25/M, output + $1.50/M, cache-read $0.025/M) — Google did not change costs at the GA cutover. + """ + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_name = "openrouter/google/gemini-3.1-flash-lite" + model_info = litellm.model_cost.get(model_name) + + assert model_info is not None, f"Missing model pricing entry: {model_name}" + assert model_info["litellm_provider"] == "openrouter" + assert model_info["input_cost_per_token"] == 2.5e-07 + assert model_info["output_cost_per_token"] == 1.5e-06 + assert model_info["cache_read_input_token_cost"] == 2.5e-08 + assert model_info["max_input_tokens"] == 1048576 + assert model_info["max_output_tokens"] == 65536 diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d8be527689e..5e636b86ed6 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1741,6 +1741,362 @@ async def test_acompletion_streaming_iterator_pre_first_chunk_skips_continuation assert fallback_kwargs["messages"] == messages +# --------------------------------------------------------------------------- +# Shared helpers for the _aresponses_streaming_iterator test suite. +# --------------------------------------------------------------------------- +def _make_responses_iterator( + *, + chunks=(), + error=None, + bridge=False, + model="gpt-4", + hidden_params=None, + chat_chunks=None, +): + """Build a minimal mock Responses-API streaming iterator. + + Bypasses BaseResponsesAPIStreamingIterator.__init__ but mirrors every + attribute production code reads. Yields *chunks*, then raises *error* + (or StopAsyncIteration). Set bridge=True to inherit from + LiteLLMCompletionStreamingIterator so the wrapper's bridge-path + isinstance check (used by usage extraction) matches. + """ + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + base = ( + LiteLLMCompletionStreamingIterator + if bridge + else BaseResponsesAPIStreamingIterator + ) + + class _Iter(base): + def __init__(self): + self._chunks = list(chunks) + self._idx = 0 + self._hidden_params = hidden_params or {} + self.model = model + self.custom_llm_provider = "anthropic" + self.logging_obj = MagicMock() + self.litellm_metadata = None + self.responses_api_provider_config = None + self.finished = False + self.completed_response = None + self.response = None + self.start_time = None + self.request_data = {} + self.call_type = None + if chat_chunks is not None: + self.collected_chat_completion_chunks = chat_chunks + + def __aiter__(self): + return self + + async def __anext__(self): + if self._idx < len(self._chunks): + self._idx += 1 + return self._chunks[self._idx - 1] + if error is not None: + raise error + raise StopAsyncIteration + + return _Iter() + + +class _AsyncList: + """Generic async iterator over a list — used as the fallback response.""" + + def __init__(self, items=()): + self._items = list(items) + self._idx = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._idx >= len(self._items): + raise StopAsyncIteration + item = self._items[self._idx] + self._idx += 1 + return item + + +def _make_router_with_fallback(primary="gpt-4", secondary="gpt-3.5-turbo"): + return litellm.Router( + model_list=[ + { + "model_name": primary, + "litellm_params": {"model": primary, "api_key": "k1"}, + }, + { + "model_name": secondary, + "litellm_params": {"model": secondary, "api_key": "k2"}, + }, + ], + fallbacks=[{primary: [secondary]}], + ) + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_fallback(): + """Catches MidStreamFallbackError, re-enters the fallback chain via + async_function_with_fallbacks_common_utils with the per-attempt helper + and original_generic_function preserved. Mirrors + test_acompletion_streaming_iterator for the aresponses path.""" + from litellm.exceptions import MidStreamFallbackError + from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ) + + router = _make_router_with_fallback( + "anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6" + ) + src = _make_responses_iterator( + chunks=[MagicMock(type="response.created")], + error=MidStreamFallbackError( + message="anthropic socket timeout", + model="anthropic/claude-sonnet-4-6", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="", + ), + model="anthropic/claude-sonnet-4-6", + hidden_params={"model_id": "src-deployment-1"}, + ) + fallback_chunks = [ + MagicMock(type="response.output_text.delta"), + MagicMock(type="response.completed"), + ] + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(fallback_chunks), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "anthropic/claude-sonnet-4-6", + "stream": True, + "input": "Hi", + "original_generic_function": litellm.aresponses, + }, + ) + assert isinstance(wrapped, BaseResponsesAPIStreamingIterator) + assert wrapped._hidden_params.get("model_id") == "src-deployment-1" + collected = [c async for c in wrapped] + + assert len(collected) == 3 # 1 primary chunk + 2 fallback chunks + call_kwargs = mock_fallback_utils.call_args.kwargs + fbk = call_kwargs["kwargs"] + # Bound methods compare equal when they share the same instance + __func__. + assert fbk["original_function"] == router._ageneric_api_call_with_fallbacks_helper + assert fbk["original_generic_function"] is litellm.aresponses + assert call_kwargs["model_group"] == "anthropic/claude-sonnet-4-6" + assert call_kwargs["disable_fallbacks"] is False + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_writes_litellm_metadata_on_fallback(): + """Regression: model_group must land under "litellm_metadata" (the key + litellm.aresponses reads), not the default "metadata".""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + error=MidStreamFallbackError( + message="boom", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=True, + generated_content="", + ) + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "Hello", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + fbk = mock_fallback_utils.call_args.kwargs["kwargs"] + assert "litellm_metadata" in fbk, "wrong metadata_variable_name" + assert fbk["litellm_metadata"]["model_group"] == "gpt-4" + assert "model_group" not in fbk.get( + "metadata", {} + ), "model_group leaked into 'metadata' instead of 'litellm_metadata'" + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_pre_first_chunk_skips_continuation(): + """Pre-first-chunk error: original input is preserved unchanged.""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + error=MidStreamFallbackError( + message="socket timeout before first chunk", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=True, + generated_content="", + ) + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "Hello", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + fbk = mock_fallback_utils.call_args.kwargs["kwargs"] + assert fbk["input"] == "Hello" # original input, no continuation messages + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_partial_content_injects_continuation(): + """Mid-stream error: input is rewritten to include user prompt + + developer instruction + prior assistant message with partial output.""" + from litellm.exceptions import MidStreamFallbackError + + router = _make_router_with_fallback() + src = _make_responses_iterator( + chunks=[MagicMock(type="response.output_text.delta")], + error=MidStreamFallbackError( + message="socket reset mid-stream", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="The capital of France is", + ), + ) + + with patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList(), + ) as mock_fallback_utils: + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "What's the capital of France?", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + new_input = mock_fallback_utils.call_args.kwargs["kwargs"]["input"] + assert isinstance(new_input, list) + assert new_input[0]["role"] == "user" + assert new_input[0]["content"][0]["text"] == "What's the capital of France?" + assert new_input[1]["role"] == "developer" + assert "do not repeat" in new_input[1]["content"][0]["text"].lower() + assert new_input[2]["role"] == "assistant" + assert new_input[2]["content"][0]["type"] == "output_text" + assert new_input[2]["content"][0]["text"] == "The capital of France is" + + +@pytest.mark.asyncio +async def test_aresponses_streaming_iterator_combines_partial_usage(): + """Partial usage from the bridge path is normalized to ResponseAPIUsage + and summed onto the fallback's response.completed event — no token-name + split, clean ResponseAPIUsage on output.""" + from types import SimpleNamespace + + from litellm.exceptions import MidStreamFallbackError + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ResponsesAPIStreamEvents, + ) + + router = _make_router_with_fallback() + src = _make_responses_iterator( + bridge=True, + chat_chunks=[MagicMock()], + chunks=[MagicMock(type="response.output_text.delta")], + error=MidStreamFallbackError( + message="boom", + model="gpt-4", + llm_provider="anthropic", + is_pre_first_chunk=False, + generated_content="hello", + ), + ) + + fallback_response_object = ResponsesAPIResponse( + id="resp_test", created_at=0, model="gpt-4", object="response", output=[] + ) + fallback_response_object.usage = ResponseAPIUsage( + input_tokens=20, output_tokens=15, total_tokens=35 + ) + fallback_event = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=fallback_response_object, + ) + + with ( + patch( + "litellm.main.stream_chunk_builder", + return_value=SimpleNamespace( + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4) + ), + ), + patch.object( + router, + "async_function_with_fallbacks_common_utils", + return_value=_AsyncList([fallback_event]), + ), + ): + wrapped = await router._aresponses_streaming_iterator( + response=src, + initial_kwargs={ + "model": "gpt-4", + "stream": True, + "input": "hi", + "original_generic_function": litellm.aresponses, + }, + ) + async for _ in wrapped: + pass + + merged = fallback_response_object.usage + assert isinstance(merged, ResponseAPIUsage) + assert merged.input_tokens == 30 # 10 (translated from prompt_tokens) + 20 + assert merged.output_tokens == 19 # 4 (translated from completion_tokens) + 15 + assert merged.total_tokens == 49 + + @pytest.mark.asyncio async def test_async_function_with_fallbacks_common_utils(): """Test the async_function_with_fallbacks_common_utils method""" @@ -3863,7 +4219,15 @@ def test_is_deployment_blocked_static_helper_reflects_blocked_flag(): # No model_info on deployment object → treated as not blocked assert litellm.Router._is_deployment_blocked(object()) is False missing_blocked = types.SimpleNamespace() - assert litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=missing_blocked)) is False - assert litellm.Router._is_deployment_blocked( - types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) - ) is True + assert ( + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=missing_blocked) + ) + is False + ) + assert ( + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) + ) + is True + ) diff --git a/tests/test_litellm/test_router_model_cost_isolation.py b/tests/test_litellm/test_router_model_cost_isolation.py index c3f93078557..9454e03e918 100644 --- a/tests/test_litellm/test_router_model_cost_isolation.py +++ b/tests/test_litellm/test_router_model_cost_isolation.py @@ -7,8 +7,10 @@ and one has explicit zero-cost pricing in model_info, the other deployment should still use the built-in pricing. """ +import copy import os import sys +from unittest.mock import patch import pytest @@ -19,6 +21,16 @@ sys.path.insert( import litellm from litellm import Router from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo +from litellm.utils import _invalidate_model_cost_lowercase_map + + +def _restore_model_cost_entries(original_entries): + for key, value in original_entries.items(): + if value is None: + litellm.model_cost.pop(key, None) + else: + litellm.model_cost[key] = value + _invalidate_model_cost_lowercase_map() def test_should_not_pollute_shared_key_with_zero_cost_pricing(): @@ -323,3 +335,70 @@ def test_responses_prefix_stripped_alias_registered_for_add_deployment(): ) is True ) + + +def test_should_not_downgrade_chatgpt_shared_key_mode_with_alias_override(): + """ + ChatGPT aliases that share the same backend model should not be able to + downgrade the shared backend key from responses -> chat during router setup. + """ + from litellm.main import responses_api_bridge_check + + backend_model = "chatgpt/gpt-5.4" + model_keys = { + backend_model: copy.deepcopy(litellm.model_cost.get(backend_model)), + "chatgpt-shared-mode-base": copy.deepcopy( + litellm.model_cost.get("chatgpt-shared-mode-base") + ), + "chatgpt-shared-mode-alias": copy.deepcopy( + litellm.model_cost.get("chatgpt-shared-mode-alias") + ), + } + + try: + backend_entry = copy.deepcopy(model_keys[backend_model]) or {} + backend_entry["litellm_provider"] = "chatgpt" + backend_entry["mode"] = "responses" + litellm.model_cost[backend_model] = backend_entry + _invalidate_model_cost_lowercase_map() + + router = Router(model_list=[]) + with patch.object( + Router, "_add_deployment", lambda self, deployment: deployment + ): + router._create_deployment( + deployment_info={}, + _model_name="chatgpt/gpt-5.4", + _litellm_params={ + "model": "gpt-5.4", + "custom_llm_provider": "chatgpt", + }, + _model_info={ + "id": "chatgpt-shared-mode-base", + "mode": "responses", + }, + ) + router._create_deployment( + deployment_info={}, + _model_name="chatgpt/gpt-5.4-medium", + _litellm_params={ + "model": "gpt-5.4", + "custom_llm_provider": "chatgpt", + }, + _model_info={ + "id": "chatgpt-shared-mode-alias", + "mode": "chat", + }, + ) + + assert litellm.model_cost[backend_model]["mode"] == "responses" + assert "mode" in litellm.model_cost[backend_model] + + bridge_model_info, bridge_model = responses_api_bridge_check( + model="gpt-5.4", + custom_llm_provider="chatgpt", + ) + assert bridge_model == "gpt-5.4" + assert bridge_model_info["mode"] == "responses" + finally: + _restore_model_cost_entries(model_keys) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index bc60375f906..de286aede93 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -754,6 +754,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "input_dbu_cost_per_token": {"type": "number"}, "annotation_cost_per_page": {"type": "number"}, "ocr_cost_per_page": {"type": "number"}, + "ocr_cost_per_credit": {"type": "number"}, "code_interpreter_cost_per_session": {"type": "number"}, "inference_geo": {"type": "string"}, "litellm_provider": {"type": "string"}, @@ -855,6 +856,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_adaptive_thinking": {"type": "boolean"}, "supports_service_tier": {"type": "boolean"}, "supports_preset": {"type": "boolean"}, + "supports_output_config": {"type": "boolean"}, "tool_use_system_prompt_tokens": {"type": "number"}, "tpm": {"type": "number"}, "provider_specific_entry": {"type": "object"}, diff --git a/tests/test_spend_logs.py b/tests/test_spend_logs.py index 8aec1d5cc60..c575fa07551 100644 --- a/tests/test_spend_logs.py +++ b/tests/test_spend_logs.py @@ -100,6 +100,9 @@ async def get_spend_logs(session, request_id=None, api_key=None): return await response.json() +@pytest.mark.skip( + reason="Flaky in CI: /spend/logs?request_id=... returns 500 even after a 20s wait for the spend log to be written. Spend-log accuracy is covered by tests/test_litellm/proxy/spend_tracking/ and the proxy_spend_accuracy_tests CircleCI job." +) @pytest.mark.asyncio async def test_spend_logs(): """ @@ -155,6 +158,9 @@ async def generate_team(session: aiohttp.ClientSession, org_id: str) -> dict: return await response.json() +@pytest.mark.skip( + reason="Flaky in CI: /spend/logs?request_id=... returns 500 even after a 20s wait for the spend log to be written. Same write-then-read race against the spend logs DB as test_spend_logs. Spend-log accuracy is covered by tests/test_litellm/proxy/spend_tracking/ and the proxy_spend_accuracy_tests CircleCI job." +) @pytest.mark.asyncio async def test_spend_logs_with_org_id(): """ diff --git a/tests/test_team_members.py b/tests/test_team_members.py index a3d64eae803..4cf85af6410 100644 --- a/tests/test_team_members.py +++ b/tests/test_team_members.py @@ -136,6 +136,9 @@ def test_add_single_member(api_client, new_team): ), f"Team size did not increase by 1 (was {initial_size}, now {updated_size})" +@pytest.mark.skip( + reason="Flaky in CI: /team/info?team_id=... intermittently returns 404/400 mid-loop after add_team_member calls. Single-member coverage in test_add_single_member is sufficient; team-member CRUD is also covered by tests/test_litellm/proxy/management_endpoints/." +) def test_add_multiple_members(api_client, new_team): """Test adding multiple members to a new team""" # Get initial team size @@ -203,6 +206,9 @@ def test_error_handling(api_client): api_client.get_team_info("invalid-team-id") +@pytest.mark.skip( + reason="Flaky in CI: /team/info?team_id=... intermittently returns 404 after add_team_member calls, same race documented for test_add_multiple_members. Duplicate-prevention is covered by test_update_team_members_list_duplicate_prevention in tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py." +) def test_duplicate_user_addition(api_client, new_team): """Test that adding the same user twice is handled appropriately""" # Add user first time diff --git a/ui/litellm-dashboard/e2e_tests/playwright.config.ts b/ui/litellm-dashboard/e2e_tests/playwright.config.ts index ec4d3a6ddb0..6964fe52a14 100644 --- a/ui/litellm-dashboard/e2e_tests/playwright.config.ts +++ b/ui/litellm-dashboard/e2e_tests/playwright.config.ts @@ -28,6 +28,11 @@ export default defineConfig({ /* Action timeout for clicks, fills, waitForSelector, etc. */ actionTimeout: 15 * 1000, navigationTimeout: 30 * 1000, + + /* Slow down actions when SLOWMO= is set, useful for headed local debugging */ + launchOptions: { + slowMo: process.env.SLOWMO ? (parseInt(process.env.SLOWMO, 10) || 0) : 0, + }, }, /* Configure projects for major browsers */ diff --git a/ui/litellm-dashboard/e2e_tests/run_e2e.sh b/ui/litellm-dashboard/e2e_tests/run_e2e.sh index 4e3a47edfbd..f8f570cda89 100755 --- a/ui/litellm-dashboard/e2e_tests/run_e2e.sh +++ b/ui/litellm-dashboard/e2e_tests/run_e2e.sh @@ -15,7 +15,7 @@ set -euo pipefail # In CI (CI=true), expects: # - PostgreSQL already running on 127.0.0.1:5432 # - DATABASE_URL already set -# - Python/Poetry already installed +# - Python/uv already installed # - Node.js/npx already available # ================================================================ @@ -48,7 +48,7 @@ cleanup() { trap cleanup EXIT INT TERM # --- Pre-flight checks --- -for cmd in python3 npx poetry; do +for cmd in python3 npx uv; do command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found."; exit 1; } done @@ -117,19 +117,15 @@ echo "UI build copied and restructured" # --- Python environment --- echo "=== Setting up Python environment ===" cd "$REPO_ROOT" -if ! poetry run python3 -c "import prisma" 2>/dev/null; then - echo "Installing Python dependencies (first run)..." - poetry install --with dev,proxy-dev --extras "proxy" --quiet - poetry run pip install nodejs-wheel-binaries 2>/dev/null || true - poetry run prisma generate --schema litellm/proxy/schema.prisma -fi +uv sync --group dev --group proxy-dev --extra proxy --frozen --quiet +uv run --no-sync python -m prisma generate --schema litellm/proxy/schema.prisma echo "=== Pushing Prisma schema to database ===" -poetry run prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss +uv run --no-sync python -m prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss # --- Mock LLM server --- echo "=== Starting mock LLM server ===" -poetry run python3 "$SCRIPT_DIR/fixtures/mock_llm_server/server.py" & +uv run --no-sync python "$SCRIPT_DIR/fixtures/mock_llm_server/server.py" & MOCK_PID=$! for i in $(seq 1 15); do @@ -140,7 +136,7 @@ done # --- LiteLLM proxy --- echo "=== Starting LiteLLM proxy ===" cd "$REPO_ROOT" -poetry run python3 -m litellm.proxy.proxy_cli \ +uv run --no-sync python -m litellm.proxy.proxy_cli \ --config "$SCRIPT_DIR/fixtures/config.yml" \ --port 4000 & PROXY_PID=$! diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts index 14ceb1a4a6b..1e44d9a25a0 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts @@ -126,4 +126,84 @@ test.describe("Proxy Admin - Keys", () => { await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS)).toBeVisible({ timeout: 10_000 }); }); + + test("Create a key with All Proxy Models (no team)", async ({ page }) => { + await navigateToPage(page, Page.ApiKeys); + await dismissFeedbackPopup(page); + + await page.getByRole("button", { name: /Create New Key/i }).click(); + + await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); + + const keyName = `e2e-admin-allproxy-${Date.now()}`; + await page.getByTestId("base-input").fill(keyName); + + // No team selection — leave team dropdown empty so the key is owned by the admin user + + // Select models — open the multi-select and pick the all-models meta-option. + // The Create Key modal labels this "All Team Models" even when no team is selected + // (see src/components/organisms/create_key_button.tsx:944), unlike the team/user + // settings screens which use "All Proxy Models". + await page.locator(".ant-select-selection-overflow").click(); + await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Create Key", exact: true }).click(); + + await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 }); + await page.keyboard.press("Escape"); + + await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 }); + }); + + test("Create a key with a specific proxy model (no team)", async ({ page }) => { + await navigateToPage(page, Page.ApiKeys); + await dismissFeedbackPopup(page); + + await page.getByRole("button", { name: /Create New Key/i }).click(); + + await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); + + const keyName = `e2e-admin-specific-${Date.now()}`; + await page.getByTestId("base-input").fill(keyName); + + // Open the model multi-select and pick a single specific model. Use + // getByRole("option", ...) to avoid the strict-mode collision between + // the option container and its inner text node. + const modelName = "fake-openai-gpt-4"; + await page.locator(".ant-select-selection-overflow").click(); + const option = page.locator(".ant-select-dropdown:visible").getByRole("option", { name: modelName, exact: true }); + await option.waitFor({ state: "attached" }); + // Dispatch the click via the DOM — antd's dropdown can render the option + // off-viewport during the open animation, which trips Playwright's + // visibility/stability checks. The click handler fires regardless. + await option.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Create Key", exact: true }).click(); + + await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 }); + + // Grab the new key from the success modal (rendered inside a
) and
+    // verify it can call /chat/completions for the model it was scoped to.
+    // The mock LLM server (fixtures/mock_llm_server/server.py) replies with
+    // a fixed "This is a mock response." body.
+    const apiKey = (await page.locator(".ant-modal:visible pre").innerText()).trim();
+    expect(apiKey).toMatch(/^sk-/);
+
+    const response = await page.request.post("/chat/completions", {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      data: {
+        model: modelName,
+        messages: [{ role: "user", content: "ping" }],
+      },
+    });
+    expect(response.status()).toBe(200);
+    const body = await response.json();
+    expect(body.choices?.[0]?.message?.content).toBe("This is a mock response.");
+
+    await page.keyboard.press("Escape");
+
+    await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
+  });
 });
diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index b33b2a69bee..97bc797fd54 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -23,7 +23,7 @@
         "jwt-decode": "4.0.0",
         "lucide-react": "0.513.0",
         "moment": "2.30.1",
-        "next": "16.2.4",
+        "next": "16.2.6",
         "openai": "4.104.0",
         "papaparse": "5.5.3",
         "react": "18.3.1",
@@ -1883,9 +1883,9 @@
       }
     },
     "node_modules/@next/env": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
-      "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
+      "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
       "license": "MIT"
     },
     "node_modules/@next/eslint-plugin-next": {
@@ -1899,9 +1899,9 @@
       }
     },
     "node_modules/@next/swc-darwin-arm64": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
-      "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
+      "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
       "cpu": [
         "arm64"
       ],
@@ -1915,9 +1915,9 @@
       }
     },
     "node_modules/@next/swc-darwin-x64": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
-      "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
+      "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
       "cpu": [
         "x64"
       ],
@@ -1931,15 +1931,12 @@
       }
     },
     "node_modules/@next/swc-linux-arm64-gnu": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
-      "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
+      "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1950,15 +1947,12 @@
       }
     },
     "node_modules/@next/swc-linux-arm64-musl": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
-      "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
+      "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1969,15 +1963,12 @@
       }
     },
     "node_modules/@next/swc-linux-x64-gnu": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
-      "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
+      "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1988,15 +1979,12 @@
       }
     },
     "node_modules/@next/swc-linux-x64-musl": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
-      "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
+      "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2007,9 +1995,9 @@
       }
     },
     "node_modules/@next/swc-win32-arm64-msvc": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
-      "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
+      "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
       "cpu": [
         "arm64"
       ],
@@ -2023,9 +2011,9 @@
       }
     },
     "node_modules/@next/swc-win32-x64-msvc": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
-      "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
+      "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
       "cpu": [
         "x64"
       ],
@@ -9316,12 +9304,12 @@
       "license": "MIT"
     },
     "node_modules/next": {
-      "version": "16.2.4",
-      "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
-      "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
+      "version": "16.2.6",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
+      "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
       "license": "MIT",
       "dependencies": {
-        "@next/env": "16.2.4",
+        "@next/env": "16.2.6",
         "@swc/helpers": "0.5.15",
         "baseline-browser-mapping": "^2.9.19",
         "caniuse-lite": "^1.0.30001579",
@@ -9335,14 +9323,14 @@
         "node": ">=20.9.0"
       },
       "optionalDependencies": {
-        "@next/swc-darwin-arm64": "16.2.4",
-        "@next/swc-darwin-x64": "16.2.4",
-        "@next/swc-linux-arm64-gnu": "16.2.4",
-        "@next/swc-linux-arm64-musl": "16.2.4",
-        "@next/swc-linux-x64-gnu": "16.2.4",
-        "@next/swc-linux-x64-musl": "16.2.4",
-        "@next/swc-win32-arm64-msvc": "16.2.4",
-        "@next/swc-win32-x64-msvc": "16.2.4",
+        "@next/swc-darwin-arm64": "16.2.6",
+        "@next/swc-darwin-x64": "16.2.6",
+        "@next/swc-linux-arm64-gnu": "16.2.6",
+        "@next/swc-linux-arm64-musl": "16.2.6",
+        "@next/swc-linux-x64-gnu": "16.2.6",
+        "@next/swc-linux-x64-musl": "16.2.6",
+        "@next/swc-win32-arm64-msvc": "16.2.6",
+        "@next/swc-win32-x64-msvc": "16.2.6",
         "sharp": "^0.34.5"
       },
       "peerDependencies": {
@@ -13345,16 +13333,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/zod": {
-      "version": "3.25.76",
-      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
-      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
-      "extraneous": true,
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/colinhacks"
-      }
-    },
     "node_modules/zwitch": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
@@ -13364,21 +13342,6 @@
         "type": "github",
         "url": "https://github.com/sponsors/wooorm"
       }
-    },
-    "node_modules/@next/swc-win32-ia32-msvc": {
-      "version": "14.2.33",
-      "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
-      "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 10"
-      }
     }
   }
 }
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 32c00ac62a8..72b9bc2a159 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -35,7 +35,7 @@
     "jwt-decode": "4.0.0",
     "lucide-react": "0.513.0",
     "moment": "2.30.1",
-    "next": "16.2.4",
+    "next": "16.2.6",
     "openai": "4.104.0",
     "papaparse": "5.5.3",
     "react": "18.3.1",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index 5431c196883..2626ace86d5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -7,7 +7,7 @@ import { columns } from "@/components/molecules/models/columns";
 import { getDisplayModelName } from "@/components/view_model/model_name_display";
 import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
 import NotificationsManager from "@/components/molecules/notifications_manager";
-import { modelDeleteCall } from "@/components/networking";
+import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
 import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
 import { PaginationState, SortingState } from "@tanstack/react-table";
 import { useQueryClient } from "@tanstack/react-query";
@@ -220,6 +220,25 @@ const AllModelsTab = ({
     }
   };
 
+  const [pausingModelId, setPausingModelId] = useState(null);
+
+  const handleTogglePause = async (modelId: string, blocked: boolean) => {
+    if (!accessToken) return;
+    try {
+      setPausingModelId(modelId);
+      await modelPatchUpdateCall(accessToken, { blocked }, modelId);
+      NotificationsManager.success(blocked ? "Model paused" : "Model resumed");
+      // invalidateQueries already schedules a refetch for active observers
+      // on this key — no need to also call refetchModels() (would double-fetch).
+      queryClient.invalidateQueries({ queryKey: ["models", "list"] });
+    } catch (error) {
+      console.error("Error toggling model pause state:", error);
+      NotificationsManager.fromBackend(error);
+    } finally {
+      setPausingModelId(null);
+    }
+  };
+
   return (
     
       
@@ -536,6 +555,8 @@ const AllModelsTab = ({
                 expandedRows,
                 setExpandedRows,
                 setDeleteModalModelId,
+                handleTogglePause,
+                pausingModelId,
               )}
               data={filteredData}
               isLoading={isLoadingModelsInfo}
diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx
index b9305e4723a..da00ad911b0 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.tsx
@@ -45,6 +45,7 @@ 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 AccessGroupSelector from "./common_components/AccessGroupSelector";
+import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
 import AgentSelector from "./agent_management/AgentSelector";
 import ModelAliasManager from "./common_components/ModelAliasManager";
 import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings";
@@ -1446,6 +1447,30 @@ const Teams: React.FC = ({
                           placeholder="Select vector stores (optional)"
                         />
                       
+                      
+                        
+                           form.setFieldValue("allowed_passthrough_routes", values)}
+                            value={form.getFieldValue("allowed_passthrough_routes")}
+                            accessToken={accessToken || ""}
+                            placeholder="Select pass through routes (optional)"
+                            disabled={!premiumUser || !isProxyAdminRole(userRole || "")}
+                          />
+                        
+                      
                     
                   
 
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx
index 7a592785a44..e98226b86bc 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx
@@ -182,16 +182,18 @@ export function ToolTestPanel({
 
     Object.entries(values).forEach(([key, value]) => {
       const prop = schemaToUse.properties?.[key];
-      if (prop && value !== null && value !== undefined && value !== "") {
+      // Strip leading/trailing whitespace from string inputs before submitting
+      const normalizedValue = typeof value === "string" ? value.trim() : value;
+      if (prop && normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") {
         switch (prop.type) {
           case "boolean":
-            convertedValues[key] = value === "true" || value === true;
+            convertedValues[key] = normalizedValue === "true" || normalizedValue === true;
             break;
           case "number":
           case "integer": {
-            const numericValue = Number(value);
+            const numericValue = Number(normalizedValue);
             convertedValues[key] = Number.isNaN(numericValue)
-              ? value
+              ? normalizedValue
               : prop.type === "integer"
                 ? Math.trunc(numericValue)
                 : numericValue;
@@ -200,28 +202,28 @@ export function ToolTestPanel({
           case "object":
           case "array": {
             try {
-              const parsed = typeof value === "string" ? JSON.parse(value) : value;
+              const parsed = typeof normalizedValue === "string" ? JSON.parse(normalizedValue) : normalizedValue;
               const isValidObject =
                 prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
               const isValidArray = prop.type === "array" && Array.isArray(parsed);
               if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) {
                 convertedValues[key] = parsed;
               } else {
-                convertedValues[key] = value;
+                convertedValues[key] = normalizedValue;
               }
             } catch (err) {
-              convertedValues[key] = value;
+              convertedValues[key] = normalizedValue;
             }
             break;
           }
           case "string":
-            convertedValues[key] = String(value);
+            convertedValues[key] = String(normalizedValue);
             break;
           default:
-            convertedValues[key] = value;
+            convertedValues[key] = normalizedValue;
         }
-      } else if (value !== null && value !== undefined && value !== "") {
-        convertedValues[key] = value;
+      } else if (normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") {
+        convertedValues[key] = normalizedValue;
       }
     });
 
diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts
index b1447a0634b..77a03d2c039 100644
--- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts
+++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts
@@ -6,6 +6,7 @@ export interface ModelInfo {
   team_id: string;
   db_model: boolean;
   access_groups: string[] | null;
+  blocked?: boolean;
 }
 
 export interface LiteLLMParams {
diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
index a3dbbb2783f..c08dca1b8ce 100644
--- a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx
@@ -944,4 +944,108 @@ describe("columns", () => {
     expect(screen.getByText("Out: $0.03")).toBeInTheDocument();
     expect(screen.queryByText(/In:/)).not.toBeInTheDocument();
   });
+
+  describe("pause/resume toggle", () => {
+    const renderWithToggle = (
+      overrides: Partial["model_info"]> = {},
+      togglePauseHandler?: ReturnType,
+      userRole: string = "Admin",
+    ) => {
+      const handler = togglePauseHandler ?? vi.fn();
+      const cols = columns(
+        userRole,
+        defaultProps.userID,
+        defaultProps.premiumUser,
+        defaultProps.setSelectedModelId,
+        defaultProps.setSelectedTeamId,
+        defaultProps.getDisplayModelName,
+        defaultProps.handleEditClick,
+        defaultProps.handleRefreshClick,
+        defaultProps.expandedRows,
+        defaultProps.setExpandedRows,
+        vi.fn(),
+        handler,
+      );
+      const model = createMockModel({
+        model_info: { ...createMockModel().model_info, ...overrides },
+      });
+      render();
+      return { handler };
+    };
+
+    it("renders the toggle ON for a db_model that is not blocked", () => {
+      renderWithToggle({ db_model: true, blocked: false });
+      const toggle = screen.getByRole("switch", { name: /pause model/i });
+      expect(toggle).toBeEnabled();
+      expect(toggle).toHaveAttribute("aria-checked", "true");
+    });
+
+    it("renders the toggle OFF for a db_model that is blocked", () => {
+      renderWithToggle({ db_model: true, blocked: true });
+      const toggle = screen.getByRole("switch", { name: /resume model/i });
+      expect(toggle).toBeEnabled();
+      expect(toggle).toHaveAttribute("aria-checked", "false");
+    });
+
+    it("calls the handler with blocked=true when an admin flips an active toggle off", async () => {
+      const handler = vi.fn();
+      renderWithToggle({ db_model: true, blocked: false }, handler);
+      await userEvent.click(screen.getByRole("switch", { name: /pause model/i }));
+      expect(handler).toHaveBeenCalledWith("test-model-id", true);
+    });
+
+    it("calls the handler with blocked=false when an admin flips a paused toggle on", async () => {
+      const handler = vi.fn();
+      renderWithToggle({ db_model: true, blocked: true }, handler);
+      await userEvent.click(screen.getByRole("switch", { name: /resume model/i }));
+      expect(handler).toHaveBeenCalledWith("test-model-id", false);
+    });
+
+    it("disables the toggle for non-admin users", () => {
+      const handler = vi.fn();
+      renderWithToggle({ db_model: true, blocked: false }, handler, "User");
+      const toggle = screen.getByRole("switch", { name: /pause model/i });
+      expect(toggle).toBeDisabled();
+    });
+
+    it("disables the toggle for config models", () => {
+      const handler = vi.fn();
+      renderWithToggle({ db_model: false, blocked: false }, handler, "Admin");
+      const toggle = screen.getByRole("switch", { name: /pause model/i });
+      expect(toggle).toBeDisabled();
+    });
+
+    it("disables the toggle while a PATCH for the same row is in-flight", () => {
+      // Regression for Greptile P1 on PR #28151 — antd's `loading` prop is
+      // visual only and does not prevent click events, so the row needs to
+      // be explicitly disabled while its PATCH is pending to avoid
+      // racing/conflicting PATCH calls on double-click.
+      const handler = vi.fn();
+      const model = createMockModel({
+        model_info: {
+          ...createMockModel().model_info,
+          db_model: true,
+          blocked: false,
+        },
+      });
+      const cols = columns(
+        "Admin",
+        defaultProps.userID,
+        defaultProps.premiumUser,
+        defaultProps.setSelectedModelId,
+        defaultProps.setSelectedTeamId,
+        defaultProps.getDisplayModelName,
+        defaultProps.handleEditClick,
+        defaultProps.handleRefreshClick,
+        defaultProps.expandedRows,
+        defaultProps.setExpandedRows,
+        vi.fn(),
+        handler,
+        model.model_info.id, // pausingModelId matches this row
+      );
+      render();
+      const toggle = screen.getByRole("switch", { name: /pause model/i });
+      expect(toggle).toBeDisabled();
+    });
+  });
 });
diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
index a303e1b1a44..4563e9c80dd 100644
--- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx
@@ -2,7 +2,7 @@ import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icon
 import { TrashIcon } from "@heroicons/react/outline";
 import { ColumnDef } from "@tanstack/react-table";
 import { Badge, Button, Icon } from "@tremor/react";
-import { Divider, Flex, Popover, Space, Tooltip, Typography } from "antd";
+import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
 import { ModelData } from "../../model_dashboard/types";
 import { ProviderLogo } from "./ProviderLogo";
 
@@ -53,6 +53,8 @@ export const columns = (
   expandedRows: Set,
   setExpandedRows: (expandedRows: Set) => void,
   onDeleteClick?: (modelId: string) => void,
+  onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise,
+  pausingModelId?: string | null,
 ): ColumnDef[] => [
     {
       header: () => Model ID,
@@ -398,15 +400,48 @@ export const columns = (
     {
       id: "actions",
       header: () => Actions,
-      size: 60,
-      minSize: 40,
+      size: 100,
+      minSize: 80,
       enableResizing: false,
       cell: ({ row }) => {
         const model = row.original;
         const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
         const isConfigModel = !model.model_info?.db_model;
+        const isAdmin = userRole === "Admin";
+        const isBlocked = model.model_info?.blocked === true;
+        const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
+        const pauseTooltip = isConfigModel
+          ? "Config models cannot be paused from the dashboard. Pause is DB-backed."
+          : !isAdmin
+            ? "Only proxy admins can pause or resume a model."
+            : isBlocked
+              ? "Resume model — restore normal routing."
+              : "Pause model — stop routing requests until resumed.";
+        // antd's `loading` prop on Switch is purely cosmetic — it does not block
+        // clicks. Pair `loading` with `disabled` derived from the same condition
+        // so a double-click during a pending PATCH cannot send a second,
+        // conflicting `blocked` value.
+        const isPausing = pausingModelId === model.model_info?.id;
         return (
           
+ + { + e.stopPropagation(); + }} + onChange={(nextChecked) => { + const modelId = model.model_info?.id; + if (isPauseToggleable && onTogglePauseClick && modelId) { + void onTogglePauseClick(modelId, !nextChecked); + } + }} + /> + {isConfigModel ? ( diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index 06a09ca2c37..a80cf935c4b 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -49,6 +49,7 @@ import { fetchAvailableModels, ModelGroup } from "../llm_calls/fetch_models"; import { makeOpenAIImageEditsRequest } from "../llm_calls/image_edits"; import { makeOpenAIImageGenerationRequest } from "../llm_calls/image_generation"; import { makeOpenAIResponsesRequest } from "../llm_calls/responses_api"; +import { makeInteractionsRequest } from "../llm_calls/interactions_api"; import A2AMetrics from "./A2AMetrics"; import AdditionalModelSettings from "./AdditionalModelSettings"; import AudioRenderer from "./AudioRenderer"; @@ -649,6 +650,7 @@ const ChatUI: React.FC = ({ EndpointType.ANTHROPIC_MESSAGES, EndpointType.EMBEDDINGS, EndpointType.TRANSCRIPTION, + EndpointType.INTERACTIONS, ]; if (modelRequiredEndpoints.includes(endpointType as EndpointType) && !selectedModel) { @@ -914,6 +916,16 @@ const ChatUI: React.FC = ({ customProxyBaseUrl || undefined, ); } + } else if (endpointType === EndpointType.INTERACTIONS) { + await makeInteractionsRequest( + inputMessage, + (text, model) => updateTextUI("assistant", text, model), + selectedModel, + effectiveApiKey, + selectedTags, + signal, + customProxyBaseUrl || undefined, + ); } } @@ -1241,10 +1253,11 @@ const ChatUI: React.FC = ({ return true; } const optionEndpoint = getEndpointType(option.mode); - // Show chat models for responses/anthropic_messages endpoints as they are compatible + // Show chat models for responses/anthropic_messages/interactions endpoints as they are compatible if ( endpointType === EndpointType.RESPONSES || - endpointType === EndpointType.ANTHROPIC_MESSAGES + endpointType === EndpointType.ANTHROPIC_MESSAGES || + endpointType === EndpointType.INTERACTIONS ) { return optionEndpoint === endpointType || optionEndpoint === EndpointType.CHAT; } @@ -2089,7 +2102,8 @@ const ChatUI: React.FC = ({ endpointType === EndpointType.CHAT || endpointType === EndpointType.EMBEDDINGS || endpointType === EndpointType.RESPONSES || - endpointType === EndpointType.ANTHROPIC_MESSAGES + endpointType === EndpointType.ANTHROPIC_MESSAGES || + endpointType === EndpointType.INTERACTIONS ? "Type your message... (Shift+Enter for new line)" : endpointType === EndpointType.A2A_AGENTS ? "Send a message to the A2A agent..." diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/chatConstants.ts b/ui/litellm-dashboard/src/components/playground/chat_ui/chatConstants.ts index 919e3bc1c65..9592a521250 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/chatConstants.ts +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/chatConstants.ts @@ -45,4 +45,5 @@ export const ENDPOINT_OPTIONS = [ { value: EndpointType.A2A_AGENTS, label: "/v1/a2a/message/send" }, { value: EndpointType.MCP, label: "/mcp-rest/tools/call" }, { value: EndpointType.REALTIME, label: "/v1/realtime" }, + { value: EndpointType.INTERACTIONS, label: "/v1beta/interactions" }, ]; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/mode_endpoint_mapping.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/mode_endpoint_mapping.tsx index f354efe641e..3b2a5e0fd60 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/mode_endpoint_mapping.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/mode_endpoint_mapping.tsx @@ -28,6 +28,7 @@ export enum EndpointType { A2A_AGENTS = "a2a_agents", MCP = "mcp", REALTIME = "realtime", + INTERACTIONS = "interactions", } // Create a mapping between the model mode and the corresponding endpoint type diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/interactions_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/interactions_api.tsx new file mode 100644 index 00000000000..6a6e4a2cfa0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/interactions_api.tsx @@ -0,0 +1,124 @@ +import NotificationManager from "@/components/molecules/notifications_manager"; +import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking"; + +export async function makeInteractionsRequest( + input: string, + updateUI: (text: string, model?: string) => void, + selectedModel: string, + accessToken: string, + tags?: string[], + signal?: AbortSignal, + customBaseUrl?: string, + previousInteractionId?: string, +): Promise { + if (!accessToken) { + throw new Error("Virtual Key is required"); + } + + const isLocal = process.env.NODE_ENV === "development"; + if (isLocal !== true) { + console.log = function () {}; + } + + const proxyBaseUrl = customBaseUrl || getProxyBaseUrl(); + const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl; + const requestUrl = `${normalizedBaseUrl}/v1beta/interactions`; + + const headers: Record = { + "Content-Type": "application/json", + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + }; + if (tags && tags.length > 0) { + headers["x-litellm-tags"] = tags.join(","); + } + + const body: Record = { + model: selectedModel, + input, + stream: true, + }; + if (previousInteractionId) { + body.previous_interaction_id = previousInteractionId; + } + + try { + const response = await fetch(requestUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Request failed with status ${response.status}`); + } + + if (!response.body) { + throw new Error("No response body received"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let responseModel: string | undefined; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // SSE lines are separated by double newlines; split on single newlines and + // look for "data: " prefixed lines. + const lines = buffer.split("\n"); + // Keep the last (potentially incomplete) line in the buffer + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const jsonStr = trimmed.slice("data:".length).trim(); + if (!jsonStr || jsonStr === "[DONE]") continue; + + let event: Record; + try { + event = JSON.parse(jsonStr); + } catch { + continue; + } + + const eventType = event.event_type as string | undefined; + + if (eventType === "interaction.start" || eventType === "interaction.complete") { + // Capture model from either the native Gemini shape (nested under + // `interaction`) or the bridge shape (top-level `model` field). + const interaction = event.interaction as Record | undefined; + if (typeof interaction?.model === "string" && interaction.model) { + responseModel = interaction.model; + } else if (typeof event.model === "string" && event.model) { + responseModel = event.model; + } + } else if (eventType === "content.delta" || eventType === "content.start") { + const delta = event.delta as Record | undefined; + // Accept both native Gemini format {"type":"text","text":"..."} and bridge + // format {"text":"..."} (no type discriminator) + if (typeof delta?.text === "string" && delta.text) { + updateUI(delta.text, responseModel ?? selectedModel); + } + } + // content.start, content.stop, interaction.status_update — no UI action needed + } + } + } catch (error: unknown) { + if (signal?.aborted) { + console.log("Interactions request was cancelled"); + throw error; + } + NotificationManager.fromBackend( + `Error occurred while making Interactions API request. Error: ${error}`, + ); + throw error; + } +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 8f3553a8395..ce9d15e1e19 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -502,6 +502,14 @@ const TeamInfoView: React.FC = ({ (n) => !(values.guardrails || []).includes(n), ); + // Non-proxy-admins can't set allowed_passthrough_routes; preserve the + // stored value so an unrelated save can't wipe it. + const passthroughRoutesMetadata = is_proxy_admin + ? { allowed_passthrough_routes: values.allowed_passthrough_routes || [] } + : info.metadata?.allowed_passthrough_routes + ? { allowed_passthrough_routes: info.metadata.allowed_passthrough_routes } + : {}; + const updateData: any = { team_id: teamId, team_alias: values.team_alias, @@ -515,6 +523,7 @@ const TeamInfoView: React.FC = ({ budget_duration: values.budget_duration, metadata: { ...parsedMetadata, + ...passthroughRoutesMetadata, guardrails: (values.guardrails || []).filter((n: string) => !globalGuardrailNames.has(n)), opted_out_global_guardrails: optedOutGlobalGuardrails, ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), @@ -961,7 +970,7 @@ const TeamInfoView: React.FC = ({ : "", metadata: info.metadata ? JSON.stringify( - (({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata), + (({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, allowed_passthrough_routes, ...rest }) => rest)(info.metadata), null, 2, ) @@ -986,6 +995,7 @@ const TeamInfoView: React.FC = ({ }, access_group_ids: info.access_group_ids || [], default_team_member_models: info.default_team_member_models || [], + allowed_passthrough_routes: info.metadata?.allowed_passthrough_routes || [], }} layout="vertical" > @@ -1338,12 +1348,24 @@ const TeamInfoView: React.FC = ({ - form.setFieldValue("allowed_passthrough_routes", values)} - value={form.getFieldValue("allowed_passthrough_routes")} - accessToken={accessToken || ""} - placeholder="Select pass through routes" - /> + + form.setFieldValue("allowed_passthrough_routes", values)} + value={form.getFieldValue("allowed_passthrough_routes")} + accessToken={accessToken || ""} + placeholder="Select pass through routes" + disabled={!premiumUser || !is_proxy_admin} + /> + diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx index a2da5136755..85a38e26977 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -172,12 +172,7 @@ describe("LogDetailContent", () => { }); it("should display loading state when isLoadingDetails is true", () => { - render( - , - ); + render(); expect(screen.getByText("Loading request & response data...")).toBeInTheDocument(); }); @@ -298,6 +293,37 @@ describe("LogDetailContent", () => { expect(screen.getByText("42.50 ms")).toBeInTheDocument(); }); + it("should not display LiteLLM Overhead when litellm_overhead_time_ms is absent from metadata", () => { + render(); + + expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument(); + }); + + const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement; + + it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => { + render( + , + ); + + expect(within(retriesItem()).getByText("2 / 3")).toBeInTheDocument(); + }); + + it("should display a green 'None' tag for Retries when attempted_retries is 0", () => { + render(); + + const noneTag = within(retriesItem()).getByText("None"); + expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green"); + }); + + it("should display '-' for Retries when attempted_retries is absent from metadata", () => { + render(); + + expect(within(retriesItem()).getByText("-")).toBeInTheDocument(); + }); + it("should display start and end time in ISO format", () => { render( void; + startTime: string; + onStartTimeChange: (value: string) => void; + endTime: string; + onEndTimeChange: (value: string) => void; + isCustomDate: boolean; + onIsCustomDateChange: (value: boolean) => void; + selectedTimeInterval: { value: number; unit: string }; + onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void; + isLiveTail: boolean; + onIsLiveTailChange: (value: boolean) => void; + currentPage: number; + onCurrentPageChange: (updater: number | ((prev: number) => number)) => void; + pageSize: number; + isLoading: boolean; + isButtonLoading: boolean; + onRefetch: () => void; + filteredLogs: PaginatedResponse; +} + +export function LogsTableToolbar({ + searchTerm, + onSearchChange, + startTime, + onStartTimeChange, + endTime, + onEndTimeChange, + isCustomDate, + onIsCustomDateChange, + selectedTimeInterval, + onSelectedTimeIntervalChange, + isLiveTail, + onIsLiveTailChange, + currentPage, + onCurrentPageChange, + pageSize, + isLoading, + isButtonLoading, + onRefetch, + filteredLogs, +}: LogsTableToolbarProps) { + const [quickSelectOpen, setQuickSelectOpen] = useState(false); + const quickSelectRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) { + setQuickSelectOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const selectedOption = QUICK_SELECT_OPTIONS.find( + (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, + ); + const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; + + return ( + <> +
+
+
+
+ onSearchChange(e.target.value)} + /> + + + +
+ +
+
+ + + {quickSelectOpen && ( +
+
+ {QUICK_SELECT_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ )} +
+ +
+ Live Tail + +
+ + +
+ + {isCustomDate && ( +
+
+ { + onStartTimeChange(e.target.value); + onCurrentPageChange(1); + }} + className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ to +
+ { + onEndTimeChange(e.target.value); + onCurrentPageChange(1); + }} + className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ )} +
+ +
+ + Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "} + {isLoading + ? "..." + : filteredLogs + ? Math.min(currentPage * pageSize, filteredLogs.total) + : 0}{" "} + of {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results + +
+ + Page {isLoading ? "..." : currentPage} of{" "} + {isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} + + + +
+
+
+
+ {isLiveTail && currentPage === 1 && ( +
+
+ Auto-refreshing every 15 seconds +
+ +
+ )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts new file mode 100644 index 00000000000..59ac58b6745 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts @@ -0,0 +1,77 @@ +import FilterTeamDropdown from "../common_components/FilterTeamDropdown"; +import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; +import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect"; +import { FilterOption } from "../molecules/filter"; +import { allEndUsersCall } from "../networking"; +import { ERROR_CODE_OPTIONS } from "./constants"; +import { FILTER_KEYS } from "./log_filter_logic"; + +export function getLogFilterOptions(accessToken: string): FilterOption[] { + return [ + { + name: "Team ID", + label: "Team ID", + customComponent: FilterTeamDropdown, + }, + { + name: "Status", + label: "Status", + isSearchable: false, + options: [ + { label: "Success", value: "success" }, + { label: "Failure", value: "failure" }, + ], + }, + { + name: "Model", + label: "Model", + customComponent: PaginatedModelSelect, + }, + { + name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, + label: "Public model / search tool", + isSearchable: false, + }, + { + name: "Key Alias", + label: "Key Alias", + customComponent: PaginatedKeyAliasSelect, + }, + { + name: "End User", + label: "End User", + isSearchable: true, + searchFn: async (searchText: string) => { + const data = await allEndUsersCall(accessToken); + const users = data?.map((u: any) => u.user_id) || []; + const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase())); + return filtered.map((u: string) => ({ label: u, value: u })); + }, + }, + { + name: "Error Code", + label: "Error Code", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!searchText) return ERROR_CODE_OPTIONS; + const lower = searchText.toLowerCase(); + const filtered = ERROR_CODE_OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower)); + const isExactValue = ERROR_CODE_OPTIONS.some((opt) => opt.value === searchText.trim()); + if (!isExactValue && searchText.trim()) { + filtered.push({ label: `Use custom code: ${searchText.trim()}`, value: searchText.trim() }); + } + return filtered; + }, + }, + { + name: "Key Hash", + label: "Key Hash", + isSearchable: false, + }, + { + name: "Error Message", + label: "Error Message", + isSearchable: false, + }, + ]; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx index 7a9a541d3e0..aed194a2972 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -1,12 +1,8 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import moment from "moment"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import SpendLogsTable, { RequestViewer } from "./index"; -import type { LogEntry } from "./columns"; -import type { Row } from "@tanstack/react-table"; +import SpendLogsTable from "./index"; import { renderWithProviders } from "../../../tests/test-utils"; -import { uiSpendLogsCall } from "../networking"; const mockHandleFilterResetFromHook = vi.fn(); vi.mock("./log_filter_logic", async (importOriginal) => { @@ -14,14 +10,8 @@ vi.mock("./log_filter_logic", async (importOriginal) => { return { ...actual, useLogFilterLogic: vi.fn(() => ({ - filters: {}, - filteredLogs: { - data: [], - total: 0, - page: 1, - page_size: 50, - total_pages: 1, - }, + logsQuery: { isLoading: false, isFetching: false, refetch: vi.fn() }, + filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 }, allTeams: [], handleFilterChange: vi.fn(), handleFilterReset: mockHandleFilterResetFromHook, @@ -50,139 +40,6 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({ fetchAllTeams: vi.fn().mockResolvedValue([]), })); -const baseLogEntry: LogEntry = { - request_id: "chatcmpl-test-id", - api_key: "api-key", - team_id: "team-id", - model: "gpt-4", - model_id: "gpt-4", - call_type: "chat", - spend: 0, - total_tokens: 0, - prompt_tokens: 0, - completion_tokens: 0, - startTime: "2025-11-14T00:00:00Z", - endTime: "2025-11-14T00:00:00Z", - cache_hit: "miss", - request_duration_ms: 1000, - messages: [{ role: "user", content: "hello" }], - response: { status: "ok" }, - metadata: { - status: "success", - additional_usage_values: { - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - request_tags: {}, - custom_llm_provider: "openai", - api_base: "https://api.example.com", -}; - -const createRow = (overrides: Partial = {}): Row => - ({ - original: { - ...baseLogEntry, - ...overrides, - }, - }) as unknown as Row; - -describe("Request Viewer", () => { - it("renders the request details heading", () => { - render(); - expect(screen.getByText("Request Details")).toBeInTheDocument(); - }); - - it("should truncate the request id if it is longer than 64 characters", () => { - const LONG_REQUEST_ID = "a".repeat(128); - const TRUNCATED_REQUEST_ID = `${"a".repeat(64)}...`; - render( - , - ); - - expect(screen.getByText(TRUNCATED_REQUEST_ID)).toBeInTheDocument(); - }); - - it("should display LiteLLM Overhead when litellm_overhead_time_ms is present in metadata", () => { - render( - , - ); - - expect(screen.getByText("LiteLLM Overhead:")).toBeInTheDocument(); - expect(screen.getByText("150 ms")).toBeInTheDocument(); - }); - - it("should not display LiteLLM Overhead when litellm_overhead_time_ms is not present in metadata", () => { - render(); - - expect(screen.queryByText("LiteLLM Overhead:")).not.toBeInTheDocument(); - }); - - it("should display retry count when attempted_retries > 0 in metadata", () => { - render( - , - ); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("2 / 3")).toBeInTheDocument(); - }); - - it("should display green 'None' tag when attempted_retries is 0", () => { - render( - , - ); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("None")).toBeInTheDocument(); - }); - - it("should display '-' for Retries when attempted_retries is not present in metadata", () => { - render(); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("-")).toBeInTheDocument(); - }); -}); - describe("SpendLogsTable", () => { const defaultProps = { accessToken: "test-token", @@ -215,7 +72,9 @@ describe("SpendLogsTable", () => { renderWithProviders(); // Open the time range quick select dropdown (button shows current range like "Last 24 Hours") - const quickSelectButton = screen.getByRole("button", { name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i }); + const quickSelectButton = screen.getByRole("button", { + name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i, + }); await user.click(quickSelectButton); // Click "Custom Range" to enable custom date selection @@ -241,51 +100,19 @@ describe("SpendLogsTable", () => { }); }); - describe("Quick Select time range", () => { - const waitForWindowSeconds = async (minMinutes: number) => { - let diff = -1; - await waitFor(() => { - const lastCall = vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; - if (!lastCall) throw new Error("uiSpendLogsCall was not called"); - diff = moment - .utc(lastCall.end_date, "YYYY-MM-DD HH:mm:ss") - .diff(moment.utc(lastCall.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds"); - // start_date is rounded down to the minute boundary; end_date is current time - expect(diff).toBeGreaterThanOrEqual(minMinutes * 60); - expect(diff).toBeLessThan((minMinutes + 1) * 60); - }); - return diff; - }; + describe("auth-not-ready guard", () => { + it("shows a loading spinner when credentials are not yet resolved", () => { + renderWithProviders(); - it("should pass a ~1-minute window to uiSpendLogsCall when 'Last Minute' is selected", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last Minute" })); - - await waitForWindowSeconds(1); + expect(document.querySelector(".ant-spin")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument(); }); - it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => { - const user = userEvent.setup(); + it("renders the table (no spinner) once all credentials are present", () => { renderWithProviders(); - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" })); - - await waitForWindowSeconds(15); - }); - - it("should update the time-range button label to 'Last Minute' after selecting it", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last Minute" })); - - expect(screen.getByRole("button", { name: "Last Minute" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Last 24 Hours/i })).not.toBeInTheDocument(); + expect(document.querySelector(".ant-spin")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 2f9e8fe8780..6c5fd03f0a0 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,35 +1,22 @@ -import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import moment from "moment"; -import { useCallback, useDeferredValue, useEffect, useRef, useState } from "react"; -import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { truncateString } from "@/utils/textUtils"; -import { SyncOutlined } from "@ant-design/icons"; -import { Row } from "@tanstack/react-table"; -import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import { Button, Tag, Tooltip } from "antd"; +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import { internalUserRoles } from "../../utils/roles"; import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; -import FilterTeamDropdown from "../common_components/FilterTeamDropdown"; import { KeyResponse } from "../key_team_helpers/key_list"; -import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; -import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect"; -import FilterComponent, { FilterOption } from "../molecules/filter"; -import { allEndUsersCall, keyInfoV1Call, uiSpendLogsCall } from "../networking"; +import FilterComponent from "../molecules/filter"; +import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import AuditLogs from "./audit_logs"; import { createColumns, LogEntry, type LogsSortField } from "./columns"; -import { ConfigInfoMessage } from "./ConfigInfoMessage"; -import { AGENT_CALL_TYPES, ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants"; -import { CostBreakdownViewer } from "./CostBreakdownViewer"; -import { ErrorViewer } from "./ErrorViewer"; -import { FILTER_KEYS, useLogFilterLogic } from "./log_filter_logic"; +import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; +import { getLogFilterOptions } from "./filter_options"; +import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; -import { getTimeRangeDisplay } from "./logs_utils"; -import { RequestResponsePanel } from "./RequestResponsePanel"; +import { LogsTableToolbar } from "./LogsTableToolbar"; import { DataTable } from "./table"; -import { VectorStoreViewer } from "./VectorStoreViewer"; +import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner"; interface SpendLogsTableProps { accessToken: string | null; @@ -39,45 +26,19 @@ interface SpendLogsTableProps { premiumUser: boolean; } -export interface PaginatedResponse { - data: LogEntry[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -export default function SpendLogsTable({ - accessToken, - token, - userRole, - userID, - premiumUser, -}: SpendLogsTableProps) { +export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { const [searchTerm, setSearchTerm] = useState(""); - const [showFilters, setShowFilters] = useState(false); - const [showColumnDropdown, setShowColumnDropdown] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pageSize] = useState(50); - const dropdownRef = useRef(null); - const filtersRef = useRef(null); - const quickSelectRef = useRef(null); // New state variables for Start and End Time const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm")); const [isCustomDate, setIsCustomDate] = useState(false); - const [quickSelectOpen, setQuickSelectOpen] = useState(false); - const [tempTeamId, setTempTeamId] = useState(""); - const [tempKeyHash, setTempKeyHash] = useState(""); - const [selectedTeamId, setSelectedTeamId] = useState(""); - const [selectedKeyHash, setSelectedKeyHash] = useState(""); - const [selectedModelId, setSelectedModelId] = useState(""); + const [filters, setFilters] = useState(defaultFilters); const [selectedKeyInfo, setSelectedKeyInfo] = useState(null); const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedEndUser, setSelectedEndUser] = useState(""); const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); @@ -88,12 +49,10 @@ export default function SpendLogsTable({ const [sortBy, setSortBy] = useState("startTime"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - // Tracks whether any filter that uses performSearch (backend) is active. - // Used to disable the main query so it doesn't fire redundant unfiltered requests - // when time range / sort / page changes while a backend filter is in effect. - const [isMainQueryEnabled, setIsMainQueryEnabled] = useState(true); - - const queryClient = useQueryClient(); + const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ + value: 24, + unit: "hours", + }); const [isLiveTail, setIsLiveTail] = useState(() => { const storedValue = sessionStorage.getItem("isLiveTail"); @@ -105,11 +64,6 @@ export default function SpendLogsTable({ sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail)); }, [isLiveTail]); - const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ - value: 24, - unit: "hours", - }); - useEffect(() => { const fetchKeyInfo = async () => { if (selectedKeyIdInfoView && accessToken) { @@ -126,132 +80,33 @@ export default function SpendLogsTable({ fetchKeyInfo(); }, [selectedKeyIdInfoView, accessToken]); - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowColumnDropdown(false); - } - if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) { - setShowFilters(false); - } - if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) { - setQuickSelectOpen(false); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - useEffect(() => { if (userRole && internalUserRoles.includes(userRole)) { setFilterByCurrentUser(true); } }, [userRole]); - const LiveTailControls = () => { - return ( -
- Live Tail - -
- ); - }; - - const logs = useQuery({ - queryKey: [ - "logs", - "table", - currentPage, - pageSize, - startTime, - endTime, - selectedTeamId, - selectedKeyHash, - filterByCurrentUser ? userID : null, - selectedStatus, - selectedModelId, - sortBy, - sortOrder, - ], - queryFn: async () => { - if (!accessToken || !token || !userRole || !userID) { - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); - - // Get base response from API - // NOTE: We only fetch the list of logs here (lightweight). - // Log details (messages/response) are fetched on-demand when user clicks a row. - const response = await uiSpendLogsCall({ - accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, - page: currentPage, - page_size: pageSize, - params: { - api_key: selectedKeyHash || undefined, - team_id: selectedTeamId || undefined, - user_id: filterByCurrentUser ? userID ?? undefined : undefined, - end_user: selectedEndUser || undefined, - status_filter: selectedStatus || undefined, - model_id: selectedModelId || undefined, - sort_by: sortBy, - sort_order: sortOrder, - }, - }); - - return response; - }, - enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs" && isMainQueryEnabled, - refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, - placeholderData: keepPreviousData, - refetchIntervalInBackground: true, - }); - - // Defer the transition from "Fetching" to "Fetch" so the button stays loading until - // the table has rendered with the new data (avoids the visual gap where the button - // exits loading state before the table updates) - const isFetchingDeferred = useDeferredValue(logs.isFetching); - const isButtonLoading = logs.isFetching || isFetchingDeferred; - - const logsData = logs.data || { - data: [], - total: 0, - page: 1, - page_size: pageSize || 10, - total_pages: 1, - }; - const { - filters, + logsQuery, filteredLogs, - hasBackendFilters, allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, - refetchWithFilters, } = useLogFilterLogic({ - logs: logsData, accessToken, + token, + userRole, + userID, + filters, + setFilters, + filterByCurrentUser: !!filterByCurrentUser, + activeTab, + isLiveTail, startTime, endTime, pageSize, isCustomDate, setCurrentPage, - userID, - userRole, sortBy, sortOrder, currentPage, @@ -259,7 +114,6 @@ export default function SpendLogsTable({ const handleFilterReset = useCallback(() => { handleFilterResetFromHook(); - // Reset custom time range to default (last 24 hours) setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); setEndTime(moment().format("YYYY-MM-DDTHH:mm")); setIsCustomDate(false); @@ -267,111 +121,104 @@ export default function SpendLogsTable({ setCurrentPage(1); }, [handleFilterResetFromHook]); - // Disable the main query whenever backend filters are active so it doesn't fire - // redundant unfiltered requests when time range / sort / page changes. - useEffect(() => { - setIsMainQueryEnabled(!hasBackendFilters); - }, [hasBackendFilters]); + const handleSortChange = useCallback((newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + setCurrentPage(1); + }, []); - // Sync filter state into the individual selectedX state variables used by the main query - useEffect(() => { - if (!accessToken) return; + const columns = useMemo( + () => createColumns({ sortBy, sortOrder, onSortChange: handleSortChange }), + [sortBy, sortOrder, handleSortChange], + ); - if (filters["Team ID"]) { - setSelectedTeamId(filters["Team ID"]); - } else { - setSelectedTeamId(""); + const filteredData = useMemo(() => { + const searchedLogs = filteredLogs.data.filter((log) => { + const matchesSearch = + !searchTerm || + log.request_id.includes(searchTerm) || + log.model.includes(searchTerm) || + (log.user && log.user.includes(searchTerm)); + + // No need for additional filtering since we're now handling this in the API call + return matchesSearch; + }); + + const sessionCompositionById = searchedLogs.reduce>( + (acc, log) => { + if (!log.session_id) return acc; + if (!acc[log.session_id]) { + acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 }; + } + if (MCP_CALL_TYPES.includes(log.call_type)) { + acc[log.session_id].mcp += 1; + } else if (AGENT_CALL_TYPES.includes(log.call_type)) { + acc[log.session_id].agent += 1; + } else { + acc[log.session_id].llm += 1; + } + return acc; + }, + {}, + ); + + // Build a single-pass map of session_id → representative request_id. + // Prefers an LLM row over an MCP row as the representative. + const sessionRepresentativeMap = new Map(); + for (const log of searchedLogs) { + if (!log.session_id || (log.session_total_count || 1) <= 1) continue; + const isMcp = MCP_CALL_TYPES.includes(log.call_type); + const existing = sessionRepresentativeMap.get(log.session_id); + if (!existing || (existing.isMcp && !isMcp)) { + sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp }); + } } - setSelectedStatus(filters["Status"] || ""); - setSelectedModelId(filters["Model"] || ""); - setSelectedEndUser(filters["End User"] || ""); - // Key Alias filtering is handled server-side by performSearch via the key_alias param. - // We intentionally do not translate the alias to a hash here to avoid firing a - // redundant main-query request (api_key=hash) alongside performSearch's key_alias request. - setSelectedKeyHash(filters["Key Hash"] || ""); - }, [filters, accessToken]); + return ( + searchedLogs + .map((log) => { + const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined; + return { + ...log, + request_duration_ms: log.request_duration_ms, + session_llm_count: sessionComposition?.llm ?? undefined, + session_mcp_count: sessionComposition?.mcp ?? undefined, + session_agent_count: sessionComposition?.agent ?? undefined, + onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), + onSessionClick: (sessionId: string) => { + if (sessionId) { + setSelectedSessionId(sessionId); + setSelectedLog(log); + setIsDrawerOpen(true); + } + }, + }; + }) + // Deduplicate multi-call sessions using the pre-built map (O(1) per row). + .filter((log) => { + if (!log.session_id || (log.session_total_count || 1) <= 1) return true; + return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; + }) + ); + }, [filteredLogs.data, searchTerm]); + + // Keep the Fetch button busy until the table has actually committed the new + // rows. `keepPreviousData` leaves logsQuery.isLoading false on refetch, so + // without this the button clears while stale rows are still on screen. + const deferredData = useDeferredValue(filteredData); + const isStale = deferredData !== filteredData; + const isButtonLoading = logsQuery.isFetching || isStale; + const isRefiltering = logsQuery.isPlaceholderData; + const isLogsLoading = logsQuery.isLoading || isRefiltering; if (!accessToken || !token || !userRole || !userID) { - return null; + return ( +
+ +
+ ); } - const searchedLogs = filteredLogs.data.filter((log) => { - const matchesSearch = - !searchTerm || - log.request_id.includes(searchTerm) || - log.model.includes(searchTerm) || - (log.user && log.user.includes(searchTerm)); - - // No need for additional filtering since we're now handling this in the API call - return matchesSearch; - }); - - const sessionCompositionById = searchedLogs.reduce>((acc, log) => { - if (!log.session_id) return acc; - if (!acc[log.session_id]) { - acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 }; - } - if (MCP_CALL_TYPES.includes(log.call_type)) { - acc[log.session_id].mcp += 1; - } else if (AGENT_CALL_TYPES.includes(log.call_type)) { - acc[log.session_id].agent += 1; - } else { - acc[log.session_id].llm += 1; - } - return acc; - }, {}); - - // Build a single-pass map of session_id → representative request_id. - // Prefers an LLM row over an MCP row as the representative. - const sessionRepresentativeMap = new Map(); - for (const log of searchedLogs) { - if (!log.session_id || (log.session_total_count || 1) <= 1) continue; - const isMcp = MCP_CALL_TYPES.includes(log.call_type); - const existing = sessionRepresentativeMap.get(log.session_id); - if (!existing || (existing.isMcp && !isMcp)) { - sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp }); - } - } - - const filteredData = - searchedLogs - .map((log) => { - const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined; - return { - ...log, - request_duration_ms: log.request_duration_ms, - session_llm_count: sessionComposition?.llm ?? undefined, - session_mcp_count: sessionComposition?.mcp ?? undefined, - session_agent_count: sessionComposition?.agent ?? undefined, - onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), - onSessionClick: (sessionId: string) => { - if (sessionId) { - setSelectedSessionId(sessionId); - setSelectedLog(log); - setIsDrawerOpen(true); - } - }, - }; - }) - // Deduplicate multi-call sessions using the pre-built map (O(1) per row). - .filter((log) => { - if (!log.session_id || (log.session_total_count || 1) <= 1) return true; - return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; - }) || []; - - // Add this function to handle manual refresh - const handleRefresh = () => { - if (hasBackendFilters) { - // When backend filters (e.g. Key Alias) are active the main TanStack Query - // is disabled and its params do not include filter values like key_alias. - // Route through the filter-aware refetch so all active filters are preserved. - refetchWithFilters(); - } else { - logs.refetch(); - } - }; - const handleRowClick = (log: LogEntry) => { // Multi-call session row: open in the same right-side drawer (session mode) if (log.session_id && (log.session_total_count || 1) > 1) { @@ -386,100 +233,6 @@ export default function SpendLogsTable({ setIsDrawerOpen(true); }; - const handleCloseDrawer = () => { - setIsDrawerOpen(false); - setSelectedSessionId(null); - }; - - const handleSelectLog = (log: LogEntry) => { - setSelectedLog(log); - }; - - const logFilterOptions: FilterOption[] = [ - { - name: "Team ID", - label: "Team ID", - customComponent: FilterTeamDropdown, - }, - { - name: "Status", - label: "Status", - isSearchable: false, - options: [ - { label: "Success", value: "success" }, - { label: "Failure", value: "failure" }, - ], - }, - { - name: "Model", - label: "Model", - customComponent: PaginatedModelSelect, - }, - { - name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, - label: "Public model / search tool", - isSearchable: false, - }, - { - name: "Key Alias", - label: "Key Alias", - customComponent: PaginatedKeyAliasSelect, - }, - { - name: "End User", - label: "End User", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!accessToken) return []; - const data = await allEndUsersCall(accessToken); - // data if set, is a list of objects, with key = user_id - const users = data?.map((u: any) => u.user_id) || []; - const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase())); - return filtered.map((u: string) => ({ label: u, value: u })); - }, - }, - { - name: "Error Code", - label: "Error Code", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!searchText) return ERROR_CODE_OPTIONS; - const lower = searchText.toLowerCase(); - const filtered = ERROR_CODE_OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower)); - const isExactValue = ERROR_CODE_OPTIONS.some((opt) => opt.value === searchText.trim()); - if (!isExactValue && searchText.trim()) { - filtered.push({ label: `Use custom code: ${searchText.trim()}`, value: searchText.trim() }); - } - return filtered; - }, - }, - { - name: "Key Hash", - label: "Key Hash", - isSearchable: false, - }, - { - name: "Error Message", - label: "Error Message", - isSearchable: false, - }, - ]; - - const formatTimeUnit = (value: number, unit: string) => { - if (value === 1) { - if (unit === "minutes") return "minute"; - if (unit === "hours") return "hour"; - if (unit === "days") return "day"; - } - return unit; - }; - - const selectedOption = QUICK_SELECT_OPTIONS.find( - (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, - ); - - const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; - return (
setActiveTab(index === 0 ? "request logs" : "audit logs")}> @@ -505,192 +258,37 @@ export default function SpendLogsTable({ ) : ( <>
-
-
-
-
- setSearchTerm(e.target.value)} - /> - - - -
- -
-
- - - {quickSelectOpen && ( -
-
- {QUICK_SELECT_OPTIONS.map((option) => ( - - ))} -
- -
-
- )} -
- - - - -
- - {isCustomDate && ( -
-
- { - setStartTime(e.target.value); - setCurrentPage(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- to -
- { - setEndTime(e.target.value); - setCurrentPage(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
-
- )} -
- -
- - Showing {logs.isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "} - {logs.isLoading - ? "..." - : filteredLogs - ? Math.min(currentPage * pageSize, filteredLogs.total) - : 0}{" "} - of {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results - -
- - Page {logs.isLoading ? "..." : currentPage} of{" "} - {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} - - - -
-
-
-
- {isLiveTail && currentPage === 1 && isMainQueryEnabled && ( -
-
- Auto-refreshing every 15 seconds -
- -
- )} + logsQuery.refetch()} + filteredLogs={filteredLogs} + /> { - setSortBy(newSortBy); - setSortOrder(newSortOrder); - setCurrentPage(1); - }, - })} - data={filteredData} + columns={columns} + data={deferredData} onRowClick={handleRowClick} - isLoading={logs.isLoading} + isLoading={isLogsLoading} />
@@ -706,330 +304,29 @@ export default function SpendLogsTable({ premiumUser={premiumUser} /> - - + + + + + + {/* Log Details Drawer */} { + setIsDrawerOpen(false); + setSelectedSessionId(null); + }} logEntry={selectedLog} sessionId={selectedSessionId} accessToken={accessToken} allLogs={filteredData} - onSelectLog={handleSelectLog} + onSelectLog={setSelectedLog} startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")} />
); } - -export function RequestViewer({ row }: { row: Row }) { - // Helper function to clean metadata by removing specific fields - const formatData = (input: any) => { - if (typeof input === "string") { - try { - return JSON.parse(input); - } catch { - return input; - } - } - return input; - }; - - // New helper function to get raw request - const getRawRequest = () => { - // First check if proxy_server_request exists in metadata - if (row.original?.proxy_server_request) { - return formatData(row.original.proxy_server_request); - } - // Fall back to messages if proxy_server_request is empty - return formatData(row.original.messages); - }; - - // Extract error information from metadata if available - const metadata = row.original.metadata || {}; - const hasError = metadata.status === "failure"; - const errorInfo = hasError ? metadata.error_information : null; - - // Check if request/response data is missing - const hasMessages = - row.original.messages && - (Array.isArray(row.original.messages) - ? row.original.messages.length > 0 - : Object.keys(row.original.messages).length > 0); - const hasResponse = row.original.response && Object.keys(formatData(row.original.response)).length > 0; - const missingData = !hasMessages && !hasResponse && !hasError; - - // Format the response with error details if present - const formattedResponse = () => { - if (hasError && errorInfo) { - return { - error: { - message: errorInfo.error_message || "An error occurred", - type: errorInfo.error_class || "error", - code: errorInfo.error_code || "unknown", - param: null, - }, - }; - } - return formatData(row.original.response); - }; - - // Extract vector store request metadata if available - const hasVectorStoreData = - metadata.vector_store_request_metadata && - Array.isArray(metadata.vector_store_request_metadata) && - metadata.vector_store_request_metadata.length > 0; - - // Extract guardrail information from metadata if available - const guardrailInfo = row.original.metadata?.guardrail_information; - const guardrailEntries = Array.isArray(guardrailInfo) ? guardrailInfo : guardrailInfo ? [guardrailInfo] : []; - const hasGuardrailData = guardrailEntries.length > 0; - - // Calculate total masked entities if guardrail data exists - const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => { - const maskedCounts = entry?.masked_entity_count; - if (!maskedCounts) { - return sum; - } - return ( - sum + - Object.values(maskedCounts).reduce((acc, count) => (typeof count === "number" ? acc + count : acc), 0) - ); - }, 0); - - const primaryGuardrailLabel = - guardrailEntries.length === 1 - ? guardrailEntries[0]?.guardrail_name ?? "-" - : guardrailEntries.length > 1 - ? `${guardrailEntries.length} guardrails` - : "-"; - - const truncatedRequestId = truncateString(row.original.request_id, 64); - - return ( -
- {/* Combined Info Card */} -
-
-

Request Details

-
-
-
-
- Request ID: - {row.original.request_id.length > 64 ? ( - - {truncatedRequestId} - - ) : ( - {row.original.request_id} - )} -
-
- Model: - {row.original.model} -
-
- Model ID: - {row.original.model_id} -
-
- Call Type: - {row.original.call_type} -
-
- Provider: - {row.original.custom_llm_provider || "-"} -
-
- API Base: - - {row.original.api_base || "-"} - -
- {row?.original?.requester_ip_address && ( -
- IP Address: - {row?.original?.requester_ip_address} -
- )} - {hasGuardrailData && ( -
- Guardrail: -
- {primaryGuardrailLabel} - {totalMaskedEntities > 0 && ( - - {totalMaskedEntities} masked - - )} -
-
- )} -
-
-
- Tokens: - - {row.original.total_tokens} ({row.original.prompt_tokens} prompt tokens +{" "} - {row.original.completion_tokens} completion tokens) - -
-
- Cache Read Tokens: - - {formatNumberWithCommas(row.original.metadata?.additional_usage_values?.cache_read_input_tokens || 0)} - -
-
- Cache Creation Tokens: - - {formatNumberWithCommas(row.original.metadata?.additional_usage_values.cache_creation_input_tokens)} - -
-
- Cost: - ${formatNumberWithCommas(row.original.spend || 0, 6)} -
-
- Cache Hit: - {row.original.cache_hit} -
- -
- Status: - - {(row.original.metadata?.status || "Success").toLowerCase() !== "failure" ? "Success" : "Failure"} - -
-
- Start Time: - {row.original.startTime} -
-
- End Time: - {row.original.endTime} -
-
- Duration: - {row.original.request_duration_ms != null ? (row.original.request_duration_ms / 1000).toFixed(3) : "-"} s. -
- {row.original.metadata?.litellm_overhead_time_ms !== undefined && ( -
- LiteLLM Overhead: - {row.original.metadata.litellm_overhead_time_ms} ms -
- )} -
- Retries: - - {row.original.metadata?.attempted_retries !== undefined && row.original.metadata?.attempted_retries !== null - ? row.original.metadata.attempted_retries > 0 - ? `${row.original.metadata.attempted_retries}${row.original.metadata.max_retries !== undefined && row.original.metadata.max_retries !== null ? ` / ${row.original.metadata.max_retries}` : ''}` - : None - : '-'} - -
-
-
-
- - {/* Cost Breakdown - Show if cost breakdown data is available */} - - - {/* Configuration Info Message - Show when data is missing */} - - - {/* Request/Response Panel */} -
- -
- - {/* Guardrail Data - Show only if present */} - {hasGuardrailData && } - - {/* Vector Store Request Data - Show only if present */} - {hasVectorStoreData && } - - {/* Error Card - Only show for failures */} - {hasError && errorInfo && } - - {/* Tags Card - Only show if there are tags */} - {row.original.request_tags && Object.keys(row.original.request_tags).length > 0 && ( -
-
-

Request Tags

-
-
-
- {Object.entries(row.original.request_tags).map(([key, value]) => ( - - {key}: {String(value)} - - ))} -
-
-
- )} - - {/* Metadata Card - Only show if there's metadata */} - {row.original.metadata && Object.keys(row.original.metadata).length > 0 && ( -
-
-

Metadata

- -
-
-
-              {JSON.stringify(row.original.metadata, null, 2)}
-            
-
-
- )} -
- ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 17c50771526..cbe37e0b70f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -1,10 +1,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; -import React, { ReactNode } from "react"; +import React, { ReactNode, useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PaginatedResponse } from "."; -import type { LogEntry, LogsSortField } from "./columns"; -import { useLogFilterLogic } from "./log_filter_logic"; +import type { LogsSortField } from "./columns"; +import { + defaultFilters, + getLiveTailRefetchInterval, + LIVE_TAIL_INTERVAL_MS, + useLogFilterLogic, + type LogFilterState, + type PaginatedResponse, +} from "./log_filter_logic"; vi.mock("../networking", () => ({ uiSpendLogsCall: vi.fn(), @@ -16,767 +22,642 @@ vi.mock("@/components/key_team_helpers/filter_helpers", () => ({ import { uiSpendLogsCall } from "../networking"; -const createLogEntry = (overrides: Partial = {}): LogEntry => -({ - request_id: "req-1", - api_key: "key-1", - team_id: "team-1", - model: "gpt-4", - model_id: "gpt-4", - call_type: "chat", - spend: 0, - total_tokens: 0, - prompt_tokens: 0, - completion_tokens: 0, - startTime: "2025-01-01T00:00:00Z", - endTime: "2025-01-01T00:01:00Z", - cache_hit: "miss", - messages: [], - response: {}, - metadata: {}, - request_tags: {}, - ...overrides, -} as LogEntry); - -const createPaginatedResponse = (data: LogEntry[]): PaginatedResponse => ({ - data, - total: data.length, +const emptyResponse: PaginatedResponse = { + data: [], + total: 0, page: 1, page_size: 50, - total_pages: 1, -}); + total_pages: 0, +}; const defaultProps = { - logs: createPaginatedResponse([]), - accessToken: "test-token", + accessToken: "test-token" as string | null, + token: "test-token" as string | null, + userRole: "Admin" as string | null, + userID: "user-1" as string | null, + filterByCurrentUser: false, + activeTab: "request logs", + isLiveTail: false, startTime: "2025-01-01T00:00:00", endTime: "2025-01-01T23:59:59", isCustomDate: true, - setCurrentPage: vi.fn(), - userID: "user-1", - userRole: "Admin", + sortBy: "startTime" as LogsSortField, + sortOrder: "desc" as "asc" | "desc", + currentPage: 1, }; +type HookOverrides = Partial[0], "filters" | "setFilters">>; + describe("useLogFilterLogic", () => { let queryClient: QueryClient; beforeEach(() => { queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, + defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); - vi.mocked(uiSpendLogsCall).mockResolvedValue({ - data: [], - total: 0, - page: 1, - page_size: 50, - total_pages: 0, - }); + vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); }); const wrapper = ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); - it("should return filters, filteredLogs, allTeams, handleFilterChange, and handleFilterReset", () => { - const { result } = renderHook( - () => - useLogFilterLogic({ + function renderFilterHook(overrides: HookOverrides = {}) { + const setCurrentPage = overrides.setCurrentPage ?? vi.fn(); + const rendered = renderHook( + () => { + const [filters, setFilters] = useState(defaultFilters); + const hook = useLogFilterLogic({ ...defaultProps, - logs: createPaginatedResponse([createLogEntry()]), - }), + ...overrides, + filters, + setFilters, + setCurrentPage, + }); + return { ...hook, filters, setFilters }; + }, { wrapper }, ); + return { ...rendered, setCurrentPage }; + } - expect(result.current.filters).toBeDefined(); - expect(result.current.filteredLogs).toBeDefined(); - expect(result.current).toHaveProperty("allTeams"); - expect(result.current.handleFilterChange).toBeDefined(); - expect(result.current.handleFilterReset).toBeDefined(); - }); + describe("return shape", () => { + it("exposes filteredLogs, allTeams, handleFilterChange, handleFilterReset", () => { + const { result } = renderFilterHook(); - it("should initialize filters with all keys empty", () => { - const { result } = renderHook(() => useLogFilterLogic(defaultProps), { wrapper }); - - const filters = result.current.filters; - expect(filters["Team ID"]).toBe(""); - expect(filters["Key Hash"]).toBe(""); - expect(filters["Request ID"]).toBe(""); - expect(filters["Model"]).toBe(""); - expect(filters["User ID"]).toBe(""); - expect(filters["End User"]).toBe(""); - expect(filters["Status"]).toBe(""); - expect(filters["Key Alias"]).toBe(""); - expect(filters["Error Code"]).toBe(""); - expect(filters["Error Message"]).toBe(""); - expect(filters["Public model / search tool"]).toBe(""); - }); - - it("should return all logs when no filters are applied", () => { - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1" }), - createLogEntry({ request_id: "req-2" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - expect(result.current.filteredLogs.data).toHaveLength(2); - expect(result.current.filteredLogs.data).toEqual(logs.data); - }); - - it("should filter logs by team_id when Team ID filter is set", () => { - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", team_id: "team-a" }), - createLogEntry({ request_id: "req-2", team_id: "team-b" }), - createLogEntry({ request_id: "req-3", team_id: "team-a" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-a" }); - }); - - expect(result.current.filteredLogs.data).toHaveLength(2); - expect(result.current.filteredLogs.data.every((log) => log.team_id === "team-a")).toBe(true); - }); - - it("should filter logs by status when Status filter is set to success", () => { - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", status: "success" }), - createLogEntry({ request_id: "req-2" }), - createLogEntry({ request_id: "req-3", status: "error" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ Status: "success" }); - }); - - expect(result.current.filteredLogs.data).toHaveLength(2); - expect(result.current.filteredLogs.data.every((log) => !log.status || log.status === "success")).toBe(true); - }); - - it("should filter logs by status when Status filter is set to error", () => { - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", status: "success" }), - createLogEntry({ request_id: "req-2", status: "error" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ Status: "error" }); - }); - - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].status).toBe("error"); - }); - - it("should filter logs by model_id when Model filter is set", async () => { - const filteredLogs = [ - createLogEntry({ request_id: "req-1", model_id: "gpt-4" }), - createLogEntry({ request_id: "req-3", model_id: "gpt-4" }), - ]; - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse(filteredLogs), - ); - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", model_id: "gpt-4" }), - createLogEntry({ request_id: "req-2", model_id: "gpt-3.5" }), - createLogEntry({ request_id: "req-3", model_id: "gpt-4" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ Model: "gpt-4" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(2); - expect(result.current.filteredLogs.data.every((log) => log.model_id === "gpt-4")).toBe(true); - }, - { timeout: 500 }, - ); - }); - - it("should pass model param and filter search-tool rows by spend log model column", async () => { - const searchRows = [ - createLogEntry({ - request_id: "s1", - call_type: "asearch", - model: "tavily-marketing", - model_id: "", - team_id: "team-x", - }), - ]; - vi.mocked(uiSpendLogsCall).mockResolvedValue(createPaginatedResponse(searchRows)); - const logs = createPaginatedResponse([ - ...searchRows, - createLogEntry({ - request_id: "c1", - call_type: "chat", - model: "gpt-4o", - model_id: "mid-1", - team_id: "team-x", - }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].model).toBe("tavily-marketing"); - expect(result.current.filteredLogs.data[0].call_type).toBe("asearch"); - }, - { timeout: 500 }, - ); - - expect(vi.mocked(uiSpendLogsCall)).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - model: "tavily-marketing", - }), - }), - ); - }); - - it("should filter logs by api_key when Key Hash filter is set", async () => { - const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" }); - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([filteredLog]), - ); - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", api_key: "key-x" }), - createLogEntry({ request_id: "req-2", api_key: "key-y" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Key Hash": "key-x" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].api_key).toBe("key-x"); - }, - { timeout: 500 }, - ); - }); - - it("should filter logs by end_user when End User filter is set", async () => { - const filteredLog = createLogEntry({ request_id: "req-1", end_user: "user-a" }); - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([filteredLog]), - ); - const logs = createPaginatedResponse([ - createLogEntry({ request_id: "req-1", end_user: "user-a" }), - createLogEntry({ request_id: "req-2", end_user: "user-b" }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "End User": "user-a" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].end_user).toBe("user-a"); - }, - { timeout: 500 }, - ); - }); - - it("should filter logs by error_code when Error Code filter is set", async () => { - const filteredLog = createLogEntry({ - request_id: "req-1", - metadata: { error_information: { error_code: "429" } }, - }); - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([filteredLog]), - ); - const logs = createPaginatedResponse([ - createLogEntry({ - request_id: "req-1", - metadata: { error_information: { error_code: "429" } }, - }), - createLogEntry({ - request_id: "req-2", - metadata: { error_information: { error_code: "500" } }, - }), - ]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Error Code": "429" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].metadata?.error_information?.error_code).toBe("429"); - }, - { timeout: 500 }, - ); - }); - - it("should return empty data when logs is null or has no data", () => { - const { result } = renderHook( - () => - useLogFilterLogic({ - ...defaultProps, - logs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }, - }), - { wrapper }, - ); - - expect(result.current.filteredLogs.data).toEqual([]); - expect(result.current.filteredLogs.total).toBe(0); - }); - - it("should reset filters when handleFilterReset is called", () => { - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" }); - }); - - expect(result.current.filters["Team ID"]).toBe("team-1"); - expect(result.current.filters["Status"]).toBe("success"); - - act(() => { - result.current.handleFilterReset(); - }); - - expect(result.current.filters["Team ID"]).toBe(""); - expect(result.current.filters["Status"]).toBe(""); - }); - - it("should call setCurrentPage with 1 when handleFilterChange is invoked", () => { - const setCurrentPage = vi.fn(); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook( - () => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }), - { wrapper }, - ); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1" }); - }); - - expect(setCurrentPage).toHaveBeenCalledWith(1); - }); - - it("should call uiSpendLogsCall when backend filter is set and debounce elapses", async () => { - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalled(); - }, - { timeout: 500 }, - ); - }); - - it("should not call uiSpendLogsCall when accessToken is null", async () => { - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook( - () => useLogFilterLogic({ ...defaultProps, logs, accessToken: null }), - { wrapper }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await new Promise((resolve) => setTimeout(resolve, 350)); - - expect(uiSpendLogsCall).not.toHaveBeenCalled(); - }); - - it("should use backend filtered logs when backend filters are active and API returns data", async () => { - const backendLog = createLogEntry({ request_id: "backend-req" }); - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([backendLog]), - ); - const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req"); - }, - { timeout: 500 }, - ); - }); - - it("should call uiSpendLogsCall with request_id when Request ID filter is set", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry({ request_id: "req-xyz" })]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Request ID": "req-xyz" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ request_id: "req-xyz" }), - }), - ); - }, - { timeout: 500 }, - ); - }); - - it("should call uiSpendLogsCall with user_id when User ID filter is set", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "User ID": "user-123" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ user_id: "user-123" }), - }), - ); - }, - { timeout: 500 }, - ); - }); - - it("should call uiSpendLogsCall with error_message when Error Message filter is set", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Error Message": "rate limit exceeded" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ error_message: "rate limit exceeded" }), - }), - ); - }, - { timeout: 500 }, - ); - }); - - it("should return empty results when backend filters are active but API returns empty", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue({ - data: [], - total: 0, - page: 1, - page_size: 50, - total_pages: 0, - }); - const clientLog = createLogEntry({ request_id: "client-req" }); - const logs = createPaginatedResponse([clientLog]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalled(); - }, - { timeout: 500 }, - ); - - expect(result.current.filteredLogs.data).toHaveLength(0); - }); - - it("should refetch when sortBy changes and backend filters are active", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result, rerender } = renderHook( - (props: { sortBy?: LogsSortField }) => - useLogFilterLogic({ ...defaultProps, logs, ...props }), - { wrapper, initialProps: { sortBy: "startTime" as LogsSortField } }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { - timeout: 500, - }); - - rerender({ sortBy: "spend" as LogsSortField }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { - timeout: 500, - }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ sort_by: "spend" }), - }), - ); - }); - - it("should refetch when sortOrder changes and backend filters are active", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result, rerender } = renderHook( - (props: { sortOrder?: "asc" | "desc" }) => - useLogFilterLogic({ ...defaultProps, logs, ...props }), - { wrapper, initialProps: { sortOrder: "desc" } }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { - timeout: 500, - }); - - rerender({ sortOrder: "asc" }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { - timeout: 500, - }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ sort_order: "asc" }), - }), - ); - }); - - it("should refetch when currentPage changes and backend filters are active", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result, rerender } = renderHook( - (props) => useLogFilterLogic({ ...defaultProps, logs, ...props }), - { wrapper, initialProps: { currentPage: 1 } }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { - timeout: 500, - }); - - rerender({ currentPage: 2 }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { - timeout: 500, - }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ page: 2 }), - ); - }); - - it("should refetch when startTime changes and backend filters are active", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result, rerender } = renderHook( - (props: { startTime?: string }) => - useLogFilterLogic({ ...defaultProps, logs, ...props }), - { wrapper, initialProps: { startTime: "2025-01-01T00:00:00Z" } }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { - timeout: 500, - }); - - rerender({ startTime: "2025-01-02T00:00:00Z" }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { - timeout: 500, - }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - start_date: "2025-01-02 00:00:00", - }), - ); - }); - - it("should refetch when isCustomDate changes and backend filters are active", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result, rerender } = renderHook( - (props: { isCustomDate?: boolean }) => - useLogFilterLogic({ ...defaultProps, logs, ...props }), - { wrapper, initialProps: { isCustomDate: false } }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { - timeout: 500, - }); - - rerender({ isCustomDate: true }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { - timeout: 500, + expect(result.current.filteredLogs).toBeDefined(); + expect(result.current).toHaveProperty("allTeams"); + expect(result.current.handleFilterChange).toBeInstanceOf(Function); + expect(result.current.handleFilterReset).toBeInstanceOf(Function); }); }); - it("should not call setCurrentPage when handleFilterChange receives identical filters", async () => { - const setCurrentPage = vi.fn(); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook( - () => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }), - { wrapper }, - ); + describe("handleFilterReset", () => { + it("restores filters to defaults after changes", () => { + const { result } = renderFilterHook(); - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1" }); + act(() => { + result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" }); + }); + + expect(result.current.filters["Team ID"]).toBe("team-1"); + expect(result.current.filters["Status"]).toBe("success"); + + act(() => { + result.current.handleFilterReset(); + }); + + expect(result.current.filters["Team ID"]).toBe(""); + expect(result.current.filters["Status"]).toBe(""); }); - await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), { - timeout: 500, + it("calls setCurrentPage(1)", () => { + const setCurrentPage = vi.fn(); + const { result } = renderFilterHook({ setCurrentPage }); + + act(() => { + result.current.handleFilterReset(); + }); + + expect(setCurrentPage).toHaveBeenCalledWith(1); }); - setCurrentPage.mockClear(); + it("triggers a fetch with all filter params undefined", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); + const { result } = renderFilterHook(); - await act(async () => { - result.current.handleFilterChange({ "Team ID": "team-1" }); - await new Promise((resolve) => setTimeout(resolve, 350)); - }); + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); - expect(setCurrentPage).not.toHaveBeenCalled(); - }); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - it("should not crash when uiSpendLogsCall throws", async () => { - vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error")); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + act(() => { + result.current.handleFilterReset(); + }); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { - timeout: 500, - }); - - expect(result.current.filteredLogs).toBeDefined(); - expect(result.current.filters).toBeDefined(); - }); - - it("should clear backendFilteredLogs when handleFilterReset is called", async () => { - const backendLog = createLogEntry({ request_id: "backend-req" }); - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([backendLog]), - ); - const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]); - const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req"); - }, - { timeout: 500 }, - ); - - act(() => { - result.current.handleFilterReset(); - }); - - expect(result.current.filteredLogs.data).toEqual(logs.data); - expect(result.current.filteredLogs.data[0].request_id).toBe("client-req"); - }); - - it("should pass correct start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue( - createPaginatedResponse([createLogEntry()]), - ); - const logs = createPaginatedResponse([createLogEntry()]); - const { result } = renderHook( - () => - useLogFilterLogic({ - ...defaultProps, - logs, - startTime: "2025-01-15T00:00:00Z", - endTime: "2025-01-15T23:59:59Z", - isCustomDate: true, - sortBy: "spend", - sortOrder: "asc", - }), - { wrapper }, - ); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - start_date: "2025-01-15 00:00:00", - end_date: "2025-01-15 23:59:59", - params: expect.objectContaining({ - sort_by: "spend", - sort_order: "asc", + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + team_id: undefined, + api_key: undefined, + request_id: undefined, + user_id: undefined, + end_user: undefined, + status_filter: undefined, + model_id: undefined, + key_alias: undefined, + error_code: undefined, + error_message: undefined, + }), }), - }), + ); + }, + { timeout: 500 }, + ); + }); + }); + + describe("handleFilterChange", () => { + it("calls setCurrentPage(1) when filters change", () => { + const setCurrentPage = vi.fn(); + const { result } = renderFilterHook({ setCurrentPage }); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-1" }); + }); + + expect(setCurrentPage).toHaveBeenCalledWith(1); + }); + + it("merges partial updates without clobbering other filter keys", () => { + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-a" }); + }); + expect(result.current.filters["Team ID"]).toBe("team-a"); + + act(() => { + result.current.handleFilterChange({ Model: "gpt-4" }); + }); + + expect(result.current.filters["Team ID"]).toBe("team-a"); + expect(result.current.filters["Model"]).toBe("gpt-4"); + }); + + it("does not call setCurrentPage when filters are identical", async () => { + const setCurrentPage = vi.fn(); + const { result } = renderFilterHook({ setCurrentPage }); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-1" }); + }); + + await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), { timeout: 500 }); + + setCurrentPage.mockClear(); + + await act(async () => { + result.current.handleFilterChange({ "Team ID": "team-1" }); + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + expect(setCurrentPage).not.toHaveBeenCalled(); + }); + }); + + describe("query params — filter keys", () => { + const filterCases: Array<{ + filterKey: keyof LogFilterState; + paramName: string; + value: string; + }> = [ + { filterKey: "Team ID", paramName: "team_id", value: "team-a" }, + { filterKey: "Key Hash", paramName: "api_key", value: "key-x" }, + { filterKey: "Request ID", paramName: "request_id", value: "req-xyz" }, + { filterKey: "User ID", paramName: "user_id", value: "user-123" }, + { filterKey: "End User", paramName: "end_user", value: "user-a" }, + { filterKey: "Status", paramName: "status_filter", value: "error" }, + { filterKey: "Model", paramName: "model_id", value: "gpt-4" }, + { filterKey: "Public model / search tool", paramName: "model", value: "tavily-marketing" }, + { filterKey: "Error Code", paramName: "error_code", value: "429" }, + { filterKey: "Error Message", paramName: "error_message", value: "rate limit exceeded" }, + ]; + + it.each(filterCases)( + "forwards $filterKey as params.$paramName to uiSpendLogsCall", + async ({ filterKey, paramName, value }) => { + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ [filterKey]: value } as Partial); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ [paramName]: value }), + }), + ); + }, + { timeout: 500 }, ); }, - { timeout: 500 }, ); }); + + describe("query params — date & sort", () => { + it("passes start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => { + const { result } = renderFilterHook({ + startTime: "2025-01-15T00:00:00Z", + endTime: "2025-01-15T23:59:59Z", + isCustomDate: true, + sortBy: "spend" as LogsSortField, + sortOrder: "asc", + }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + start_date: "2025-01-15 00:00:00", + end_date: "2025-01-15 23:59:59", + params: expect.objectContaining({ + sort_by: "spend", + sort_order: "asc", + }), + }), + ); + }, + { timeout: 500 }, + ); + }); + }); + + describe("debounce", () => { + it("calls uiSpendLogsCall after the debounce elapses for text filters", async () => { + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ "Key Hash": "hash-1" }); + }); + + await waitFor( + () => + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ api_key: "hash-1" }), + }), + ), + { timeout: 500 }, + ); + }); + + it("does not call uiSpendLogsCall with a text filter before the debounce elapses", async () => { + const { result } = renderFilterHook(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + vi.mocked(uiSpendLogsCall).mockClear(); + + act(() => { + result.current.handleFilterChange({ "Key Hash": "hash-1" }); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(uiSpendLogsCall).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ api_key: "hash-1" }), + }), + ); + + await waitFor( + () => + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ api_key: "hash-1" }), + }), + ), + { timeout: 500 }, + ); + }); + + it("applies dropdown filter changes without waiting for the debounce", async () => { + const { result } = renderFilterHook(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + vi.mocked(uiSpendLogsCall).mockClear(); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-instant" }); + }); + + await waitFor( + () => + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ team_id: "team-instant" }), + }), + ), + { timeout: 100 }, + ); + }); + + // Guards the TEXT_FILTER_KEYS fix: this free-text filter must debounce, not fire per keystroke. + it("debounces the 'Public model / search tool' text filter", async () => { + const { result } = renderFilterHook(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + vi.mocked(uiSpendLogsCall).mockClear(); + + act(() => { + result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" }); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(uiSpendLogsCall).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ model: "tavily-marketing" }), + }), + ); + + await waitFor( + () => + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ model: "tavily-marketing" }), + }), + ), + { timeout: 500 }, + ); + }); + }); + + describe("handleFilterReset", () => { + it("flushes the text-filter debounce so a pending typed value is not sent", async () => { + const { result } = renderFilterHook(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + vi.mocked(uiSpendLogsCall).mockClear(); + + act(() => { + result.current.handleFilterChange({ "Key Hash": "pending-hash" }); + }); + + act(() => { + result.current.handleFilterReset(); + }); + + await new Promise((resolve) => setTimeout(resolve, 400)); + + for (const call of vi.mocked(uiSpendLogsCall).mock.calls) { + expect(call[0].params?.api_key).toBeUndefined(); + } + }); + }); + + describe("backend filtered logs", () => { + it("returns the query payload as filteredLogs when backend filters are active", async () => { + const backendLog = { request_id: "backend-req" }; + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [backendLog], + total: 1, + page: 1, + page_size: 50, + total_pages: 1, + } as PaginatedResponse); + + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req"); + }, + { timeout: 500 }, + ); + }); + + it("returns empty data when the API returns an empty payload", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + + expect(result.current.filteredLogs.data).toHaveLength(0); + }); + }); + + describe("refetch triggers", () => { + it("refetches when sortBy changes", async () => { + const { rerender } = renderHook( + (props: { sortBy: LogsSortField }) => { + const [filters, setFilters] = useState(defaultFilters); + return useLogFilterLogic({ + ...defaultProps, + filters, + setFilters, + setCurrentPage: vi.fn(), + sortBy: props.sortBy, + }); + }, + { wrapper, initialProps: { sortBy: "startTime" } }, + ); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); + + rerender({ sortBy: "spend" }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ sort_by: "spend" }), + }), + ); + }); + + it("refetches when sortOrder changes", async () => { + const { rerender } = renderHook( + (props: { sortOrder: "asc" | "desc" }) => { + const [filters, setFilters] = useState(defaultFilters); + return useLogFilterLogic({ + ...defaultProps, + filters, + setFilters, + setCurrentPage: vi.fn(), + sortOrder: props.sortOrder, + }); + }, + { wrapper, initialProps: { sortOrder: "desc" } }, + ); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); + + rerender({ sortOrder: "asc" }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ sort_order: "asc" }), + }), + ); + }); + + it("refetches when currentPage changes", async () => { + const { rerender } = renderHook( + (props: { currentPage: number }) => { + const [filters, setFilters] = useState(defaultFilters); + return useLogFilterLogic({ + ...defaultProps, + filters, + setFilters, + setCurrentPage: vi.fn(), + currentPage: props.currentPage, + }); + }, + { wrapper, initialProps: { currentPage: 1 } }, + ); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); + + rerender({ currentPage: 2 }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })); + }); + + it("refetches when startTime changes", async () => { + const { rerender } = renderHook( + (props: { startTime: string }) => { + const [filters, setFilters] = useState(defaultFilters); + return useLogFilterLogic({ + ...defaultProps, + filters, + setFilters, + setCurrentPage: vi.fn(), + startTime: props.startTime, + }); + }, + { wrapper, initialProps: { startTime: "2025-01-01T00:00:00Z" } }, + ); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); + + rerender({ startTime: "2025-01-02T00:00:00Z" }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ start_date: "2025-01-02 00:00:00" })); + }); + + it("refetches with a different end_date when isCustomDate toggles", async () => { + const customEndTime = "2025-01-15T23:59:59Z"; + const customEndFormatted = "2025-01-15 23:59:59"; + + const { rerender } = renderHook( + (props: { isCustomDate: boolean }) => { + const [filters, setFilters] = useState(defaultFilters); + return useLogFilterLogic({ + ...defaultProps, + endTime: customEndTime, + filters, + setFilters, + setCurrentPage: vi.fn(), + isCustomDate: props.isCustomDate, + }); + }, + { wrapper, initialProps: { isCustomDate: false } }, + ); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); + const firstEndDate = vi.mocked(uiSpendLogsCall).mock.calls[0][0].end_date; + expect(firstEndDate).not.toBe(customEndFormatted); + + rerender({ isCustomDate: true }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); + expect(vi.mocked(uiSpendLogsCall).mock.calls[1][0].end_date).toBe(customEndFormatted); + }); + }); + + describe("query enablement", () => { + const nullCredentialCases: Array<{ name: string; override: HookOverrides }> = [ + { name: "accessToken", override: { accessToken: null } }, + { name: "token", override: { token: null } }, + { name: "userRole", override: { userRole: null } }, + { name: "userID", override: { userID: null } }, + ]; + + it.each(nullCredentialCases)("does not call uiSpendLogsCall when $name is null", async ({ override }) => { + const { result } = renderFilterHook(override); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(uiSpendLogsCall).not.toHaveBeenCalled(); + }); + + it("does not call uiSpendLogsCall when activeTab is not 'request logs'", async () => { + const { result } = renderFilterHook({ activeTab: "audit logs" }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(uiSpendLogsCall).not.toHaveBeenCalled(); + }); + }); + + describe("filterByCurrentUser", () => { + it("sends user_id: userID when the User ID filter is blank", async () => { + const { result } = renderFilterHook({ + filterByCurrentUser: true, + userID: "me-123", + }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ user_id: "me-123" }), + }), + ); + }, + { timeout: 500 }, + ); + }); + }); + + describe("error handling", () => { + it("does not crash when uiSpendLogsCall throws", async () => { + vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error")); + const { result } = renderFilterHook(); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + + expect(result.current.filteredLogs).toBeDefined(); + expect(result.current.filteredLogs.data).toEqual([]); + }); + }); +}); + +describe("getLiveTailRefetchInterval", () => { + it("polls every 15s when live tail is on and on page 1", () => { + expect(getLiveTailRefetchInterval(true, 1)).toBe(LIVE_TAIL_INTERVAL_MS); + }); + + it("does not poll when live tail is off", () => { + expect(getLiveTailRefetchInterval(false, 1)).toBe(false); + }); + + it("does not poll when not on page 1, even with live tail on", () => { + expect(getLiveTailRefetchInterval(true, 2)).toBe(false); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8f916999c16..d9830a83f6e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -1,13 +1,28 @@ import moment from "moment"; -import { useCallback, useEffect, useState, useRef, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { uiSpendLogsCall } from "../networking"; import { Team } from "../key_team_helpers/key_list"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers"; -import { debounce } from "lodash"; import { defaultPageSize } from "../constants"; -import { PaginatedResponse } from "."; -import type { LogsSortField } from "./columns"; +import type { LogEntry, LogsSortField } from "./columns"; + +export interface PaginatedResponse { + data: LogEntry[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +function useDebouncedValue(value: T, delayMs: number): [T, React.Dispatch>] { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + return [debounced, setDebounced]; +} /** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */ export const FILTER_KEYS = { @@ -28,324 +43,188 @@ export const FILTER_KEYS = { export type FilterKey = keyof typeof FILTER_KEYS; export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>; +// Keys whose UI is a free-form text input; only these need debouncing. +const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [ + FILTER_KEYS.KEY_HASH, + FILTER_KEYS.ERROR_MESSAGE, + FILTER_KEYS.REQUEST_ID, + FILTER_KEYS.USER_ID, + FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, +]; + +// Live-tail polls every 15s, but only on page 1 (newest) while live tail is on. +export const LIVE_TAIL_INTERVAL_MS = 15000; +export const getLiveTailRefetchInterval = (isLiveTail: boolean, currentPage: number): number | false => + isLiveTail && currentPage === 1 ? LIVE_TAIL_INTERVAL_MS : false; + +export const defaultFilters: LogFilterState = { + [FILTER_KEYS.TEAM_ID]: "", + [FILTER_KEYS.KEY_HASH]: "", + [FILTER_KEYS.REQUEST_ID]: "", + [FILTER_KEYS.MODEL]: "", + [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", + [FILTER_KEYS.USER_ID]: "", + [FILTER_KEYS.END_USER]: "", + [FILTER_KEYS.STATUS]: "", + [FILTER_KEYS.KEY_ALIAS]: "", + [FILTER_KEYS.ERROR_CODE]: "", + [FILTER_KEYS.ERROR_MESSAGE]: "", +}; + export function useLogFilterLogic({ - logs, accessToken, - startTime, // Receive from SpendLogsTable - endTime, // Receive from SpendLogsTable + token, + userRole, + userID, + filters, + setFilters, + filterByCurrentUser, + activeTab, + isLiveTail, + startTime, + endTime, pageSize = defaultPageSize, isCustomDate, setCurrentPage, - userID, - userRole, sortBy = "startTime", sortOrder = "desc", currentPage = 1, }: { - logs: PaginatedResponse; accessToken: string | null; + token: string | null; + userRole: string | null; + userID: string | null; + filters: LogFilterState; + setFilters: React.Dispatch>; + filterByCurrentUser: boolean | null; + activeTab: string; + isLiveTail: boolean; startTime: string; endTime: string; pageSize?: number; isCustomDate: boolean; setCurrentPage: (page: number) => void; - userID: string | null; - userRole: string | null; sortBy?: LogsSortField; sortOrder?: "asc" | "desc"; currentPage?: number; }) { - const defaultFilters = useMemo( - () => ({ - [FILTER_KEYS.TEAM_ID]: "", - [FILTER_KEYS.KEY_HASH]: "", - [FILTER_KEYS.REQUEST_ID]: "", - [FILTER_KEYS.MODEL]: "", - [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", - [FILTER_KEYS.USER_ID]: "", - [FILTER_KEYS.END_USER]: "", - [FILTER_KEYS.STATUS]: "", - [FILTER_KEYS.KEY_ALIAS]: "", - [FILTER_KEYS.ERROR_CODE]: "", - [FILTER_KEYS.ERROR_MESSAGE]: "", - }), - [], - ); + const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300); - const [filters, setFilters] = useState(defaultFilters); - const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); - const lastSearchTimestamp = useRef(0); + // Live values for dropdown keys, debounced for text keys. + const effectiveFilters = useMemo(() => { + const merged = { ...filters }; + for (const k of TEXT_FILTER_KEYS) { + merged[k] = debouncedFilters[k]; + } + return merged; + }, [filters, debouncedFilters]); - // Refs that always hold the latest filters and hasBackendFilters values. - // The sort/page/time effect below intentionally omits these from its dep array - // to avoid double-fetches when a filter changes; reading from refs instead of - // the closure prevents stale-closure bugs (e.g. the effect using a snapshot of - // filters taken before the user selected Key Alias). - const filtersRef = useRef(filters); - const hasBackendFiltersRef = useRef(false); - const performSearch = useCallback( - async (filters: LogFilterState, page = 1) => { - if (!accessToken) return; - - console.log("Filters being sent to API:", filters); - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; + const logsQuery = useQuery({ + queryKey: [ + "logs", + "table", + currentPage, + pageSize, + startTime, + endTime, + isCustomDate, + effectiveFilters, + filterByCurrentUser ? userID : null, + sortBy, + sortOrder, + ], + queryFn: async () => { + if (!accessToken || !token || !userRole || !userID) { + return { + data: [], + total: 0, + page: 1, + page_size: pageSize, + total_pages: 0, + }; + } const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); const formattedEndTime = isCustomDate ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") : moment().utc().format("YYYY-MM-DD HH:mm:ss"); - try { - const response = await uiSpendLogsCall({ - accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, - page, - page_size: pageSize, - params: { - api_key: filters[FILTER_KEYS.KEY_HASH] || undefined, - team_id: filters[FILTER_KEYS.TEAM_ID] || undefined, - request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined, - user_id: filters[FILTER_KEYS.USER_ID] || undefined, - end_user: filters[FILTER_KEYS.END_USER] || undefined, - status_filter: filters[FILTER_KEYS.STATUS] || undefined, - model_id: filters[FILTER_KEYS.MODEL] || undefined, - model: filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined, - key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined, - error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined, - error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined, - sort_by: sortBy, - sort_order: sortOrder, - }, - }); + const response = await uiSpendLogsCall({ + accessToken, + start_date: formattedStartTime, + end_date: formattedEndTime, + page: currentPage, + page_size: pageSize, + params: { + api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined, + team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined, + request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined, + user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), + end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined, + status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined, + model_id: effectiveFilters[FILTER_KEYS.MODEL] || undefined, + model: effectiveFilters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined, + key_alias: effectiveFilters[FILTER_KEYS.KEY_ALIAS] || undefined, + error_code: effectiveFilters[FILTER_KEYS.ERROR_CODE] || undefined, + error_message: effectiveFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined, + sort_by: sortBy, + sort_order: sortOrder, + }, + }); - if (currentTimestamp === lastSearchTimestamp.current) { - setBackendFilteredLogs({ - ...response, - data: response.data ?? [], - }); - } - } catch (error) { - console.error("Error searching users:", error); - setBackendFilteredLogs({ - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }); - } + return response; }, - [accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder], - ); + enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", + refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage), + placeholderData: keepPreviousData, + // Only live-tail-poll while the tab is visible. + refetchIntervalInBackground: false, + }); - const debouncedSearch = useMemo( - () => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300), - [performSearch], - ); + const filteredLogs: PaginatedResponse = logsQuery.data ?? { + data: [], + total: 0, + page: 1, + page_size: pageSize, + total_pages: 0, + }; - useEffect(() => { - return () => debouncedSearch.cancel(); - }, [debouncedSearch]); - - // Determine when backend filters are active (server-side filtering) - const hasBackendFilters = useMemo( - () => - !!( - filters[FILTER_KEYS.KEY_ALIAS] || - filters[FILTER_KEYS.KEY_HASH] || - filters[FILTER_KEYS.REQUEST_ID] || - filters[FILTER_KEYS.USER_ID] || - filters[FILTER_KEYS.END_USER] || - filters[FILTER_KEYS.ERROR_CODE] || - filters[FILTER_KEYS.ERROR_MESSAGE] || - filters[FILTER_KEYS.MODEL] || - filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] - ), - [filters], - ); - - // Keep refs in sync on every render so the sort/page/time effect always reads - // the latest values without those values being in its dep array. - useEffect(() => { - filtersRef.current = filters; - hasBackendFiltersRef.current = hasBackendFilters; - }, [filters, hasBackendFilters]); - - // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) - useEffect(() => { - if (hasBackendFiltersRef.current && accessToken) { - // Cancel any pending debounced search to prevent it from overwriting this page's results - debouncedSearch.cancel(); - performSearch(filtersRef.current, currentPage); - } - // filters / hasBackendFilters are read via refs — avoids stale-closure bugs - // when sort/page/time changes after a filter (e.g. Key Alias) was set. - // debouncedSearch / performSearch: filter changes go through handleFilterChange - // → debouncedSearch; adding them here would cause double-fetches on filter apply. - // accessToken: stable across sort/page/time changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); - - // Compute client-side filtered logs directly from incoming logs and filters - const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => { - if (!logs || !logs.data) { - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - - // If backend filters are on, don't perform client-side filtering here - if (hasBackendFilters) { - return logs; - } - - let filteredData = [...logs.data]; - - if (filters[FILTER_KEYS.TEAM_ID]) { - filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]); - } - - if (filters[FILTER_KEYS.STATUS]) { - filteredData = filteredData.filter((log) => { - if (filters[FILTER_KEYS.STATUS] === "success") { - return !log.status || log.status === "success"; - } - return log.status === filters[FILTER_KEYS.STATUS]; - }); - } - - if (filters[FILTER_KEYS.MODEL]) { - filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]); - } - - if (filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]) { - const m = filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]; - filteredData = filteredData.filter((log) => log.model === m); - } - - if (filters[FILTER_KEYS.KEY_HASH]) { - filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]); - } - - if (filters[FILTER_KEYS.END_USER]) { - filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]); - } - - if (filters[FILTER_KEYS.ERROR_CODE]) { - filteredData = filteredData.filter((log) => { - const metadata = log.metadata || {}; - const errorInfo = metadata.error_information; - return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE]; - }); - } - - return { - data: filteredData, - total: logs.total, - page: logs.page, - page_size: logs.page_size, - total_pages: logs.total_pages, - }; - }, [logs, filters, hasBackendFilters]); - - // Choose which filtered logs to expose: backend result when active, otherwise client-derived - const filteredLogs: PaginatedResponse = useMemo(() => { - if (hasBackendFilters) { - // When backend filters are active, only show backend results. - // If search hasn't completed yet (null), show empty state rather than - // falling back to unfiltered logs — that caused filtered views to - // display mismatched data when the filter matched zero rows. - if (backendFilteredLogs !== null) { - return backendFilteredLogs; - } - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - return clientDerivedFilteredLogs; - }, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs]); - - // Fetch all teams and users for potential filter dropdowns (optional, can be adapted) const { data: allTeams } = useQuery({ queryKey: ["allTeamsForLogFilters", accessToken], queryFn: async () => { if (!accessToken) return []; - // Use fetchAllTeams helper function for consistency and abstraction - // Assuming fetchAllTeams returns Team[] directly const teamsData = await fetchAllTeams(accessToken); - return teamsData || []; // Ensure it returns an array + return teamsData || []; }, enabled: !!accessToken, }); - // Update filters state const handleFilterChange = (newFilters: Partial) => { setFilters((prev) => { const updatedFilters = { ...prev, ...newFilters }; - - // Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters for (const key of Object.keys(defaultFilters) as Array) { if (!(key in updatedFilters)) { updatedFilters[key] = defaultFilters[key]; } } - - // Only call debouncedSearch if filters have actually changed if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) { setCurrentPage(1); - setBackendFilteredLogs(null); - debouncedSearch(updatedFilters, 1); } - return updatedFilters as LogFilterState; }); }; const handleFilterReset = () => { - // Reset filters state setFilters(defaultFilters); - - // Clear backend filtered logs to ensure fresh render - setBackendFilteredLogs(null); - - // Cancel any in-flight debounced search - debouncedSearch.cancel(); - - // Reset to first page so the unfiltered view starts at page 1 + setDebouncedFilters(defaultFilters); setCurrentPage(1); }; - // Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can - // refresh results while keeping all active backend filters intact. The plain - // `logs.refetch()` in the parent only re-runs the main TanStack Query, which - // does not carry key_alias or other backend-only filter params. - const refetchWithFilters = useCallback( - (page = currentPage) => { - if (hasBackendFilters && accessToken) { - debouncedSearch.cancel(); - performSearch(filters, page); - } - }, - [hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch], - ); - return { - filters, + logsQuery, filteredLogs, - hasBackendFilters, allTeams, handleFilterChange, handleFilterReset, - refetchWithFilters, }; } diff --git a/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx b/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx new file mode 100644 index 00000000000..b0b74df4c24 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx @@ -0,0 +1,45 @@ +import moment from "moment"; +import { describe, expect, it } from "vitest"; +import { getTimeRangeDisplay } from "./logs_utils"; + +// startTime built relative to "now"; getTimeRangeDisplay computes now() internally. +const ago = (amount: number, unit: moment.unitOfTime.DurationConstructor) => + moment().subtract(amount, unit).toISOString(); + +describe("getTimeRangeDisplay", () => { + it("labels a ~1-minute window as 'Last 1 Minute'", () => { + expect(getTimeRangeDisplay(false, ago(1, "minutes"), "")).toBe("Last 1 Minute"); + }); + + it("labels a ~10-minute window as 'Last 15 Minutes'", () => { + expect(getTimeRangeDisplay(false, ago(10, "minutes"), "")).toBe("Last 15 Minutes"); + }); + + it("labels a ~30-minute window as 'Last Hour'", () => { + expect(getTimeRangeDisplay(false, ago(30, "minutes"), "")).toBe("Last Hour"); + }); + + it("labels a ~2-hour window as 'Last 4 Hours'", () => { + expect(getTimeRangeDisplay(false, ago(2, "hours"), "")).toBe("Last 4 Hours"); + }); + + it("labels a ~10-hour window as 'Last 24 Hours'", () => { + expect(getTimeRangeDisplay(false, ago(10, "hours"), "")).toBe("Last 24 Hours"); + }); + + it("labels a ~3-day window as 'Last 7 Days'", () => { + expect(getTimeRangeDisplay(false, ago(3, "days"), "")).toBe("Last 7 Days"); + }); + + it("falls back to a 'MMM D - MMM D' range beyond 7 days", () => { + const label = getTimeRangeDisplay(false, ago(30, "days"), ""); + expect(label).toMatch(/^[A-Z][a-z]{2} \d{1,2} - [A-Z][a-z]{2} \d{1,2}$/); + }); + + it("renders an explicit start - end range when isCustomDate is true", () => { + const start = "2025-01-02T03:04:00Z"; + const end = "2025-01-05T06:07:00Z"; + const expected = `${moment(start).format("MMM D, h:mm A")} - ${moment(end).format("MMM D, h:mm A")}`; + expect(getTimeRangeDisplay(true, start, end)).toBe(expected); + }); +}); diff --git a/ui/litellm-dashboard/tests/view_logs/useLogFilterLogic.min.test.tsx b/ui/litellm-dashboard/tests/view_logs/useLogFilterLogic.min.test.tsx deleted file mode 100644 index faede84f4c2..00000000000 --- a/ui/litellm-dashboard/tests/view_logs/useLogFilterLogic.min.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useLogFilterLogic } from "../../src/components/view_logs/log_filter_logic"; - -// Minimal mocks to avoid real network during hook init -vi.mock("../../src/components/key_team_helpers/filter_helpers", () => ({ - fetchAllKeyAliases: vi.fn().mockResolvedValue([]), - fetchAllTeams: vi.fn().mockResolvedValue([]), -})); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); - -function Harness({ logs }: { logs: any }) { - const { filteredLogs } = useLogFilterLogic({ - logs, - accessToken: "token", - startTime: "2025-01-01 00:00:00", - endTime: "2025-01-02 00:00:00", - pageSize: 50, - isCustomDate: true, - setCurrentPage: () => {}, - userID: "user-1", - userRole: "admin", - }); - - return
{filteredLogs.data.length}
; -} - -describe("useLogFilterLogic (minimal)", () => { - it("useLogFilterLogic minimal: updates filteredLogs when logs change", async () => { - const qc = createQueryClient(); - const logsA = { data: [{ request_id: "a" }], total: 1, page: 1, page_size: 50, total_pages: 1 }; - const logsB = { - data: [{ request_id: "a" }, { request_id: "b" }], - total: 2, - page: 1, - page_size: 50, - total_pages: 1, - }; - - const { rerender } = render( - - - , - ); - - expect(await screen.findByTestId("count")).toHaveTextContent("1"); - - rerender( - - - , - ); - - expect(await screen.findByTestId("count")).toHaveTextContent("2"); - }); -}); diff --git a/uv.lock b/uv.lock index f3eaf6ca88c..e99d8d49da6 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-19T00:08:46.706629Z" exclude-newer-span = "P3D" [manifest] @@ -539,7 +539,7 @@ wheels = [ [[package]] name = "black" -version = "24.10.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -547,28 +547,33 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/f3/465c0eb5cddf7dbbfe1fecd9b875d1dcf51b88923cd2c1d7e9ab95c6336b/black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", size = 1623211, upload-time = "2024-10-07T19:26:12.43Z" }, - { url = "https://files.pythonhosted.org/packages/df/57/b6d2da7d200773fdfcc224ffb87052cf283cec4d7102fab450b4a05996d8/black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", size = 1457139, upload-time = "2024-10-07T19:25:06.453Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c5/9023b7673904a5188f9be81f5e129fff69f51f5515655fbd1d5a4e80a47b/black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", size = 1753774, upload-time = "2024-10-07T19:23:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/e1/32/df7f18bd0e724e0d9748829765455d6643ec847b3f87e77456fc99d0edab/black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e", size = 1414209, upload-time = "2024-10-07T19:24:42.54Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cc/7496bb63a9b06a954d3d0ac9fe7a73f3bf1cd92d7a58877c27f4ad1e9d41/black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", size = 1607468, upload-time = "2024-10-07T19:26:14.966Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e3/69a738fb5ba18b5422f50b4f143544c664d7da40f09c13969b2fd52900e0/black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", size = 1437270, upload-time = "2024-10-07T19:25:24.291Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9b/2db8045b45844665c720dcfe292fdaf2e49825810c0103e1191515fc101a/black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", size = 1737061, upload-time = "2024-10-07T19:23:52.18Z" }, - { url = "https://files.pythonhosted.org/packages/a3/95/17d4a09a5be5f8c65aa4a361444d95edc45def0de887810f508d3f65db7a/black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", size = 1423293, upload-time = "2024-10-07T19:24:41.7Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256, upload-time = "2024-10-07T19:27:53.355Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534, upload-time = "2024-10-07T19:26:44.953Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892, upload-time = "2024-10-07T19:24:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796, upload-time = "2024-10-07T19:25:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986, upload-time = "2024-10-07T19:28:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085, upload-time = "2024-10-07T19:28:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928, upload-time = "2024-10-07T19:24:15.233Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875, upload-time = "2024-10-07T19:24:42.762Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -3189,7 +3194,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.86.0" +version = "1.87.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -3484,7 +3489,7 @@ ci = [ { name = "traceloop-sdk", specifier = "==0.33.12" }, ] dev = [ - { name = "black", specifier = "==24.10.0" }, + { name = "black", specifier = "==26.3.1" }, { name = "diff-cover", specifier = "==9.7.2" }, { name = "fakeredis", specifier = "==2.34.1" }, { name = "fastapi-offline", specifier = "==1.7.6" }, @@ -3539,7 +3544,7 @@ source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.72" +version = "0.4.73" source = { editable = "litellm-proxy-extras" } [[package]] @@ -6162,6 +6167,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pytz" version = "2026.2"