diff --git a/.github/actions/detect-backend-changes/action.yml b/.github/actions/detect-backend-changes/action.yml new file mode 100644 index 00000000000..af01038f294 --- /dev/null +++ b/.github/actions/detect-backend-changes/action.yml @@ -0,0 +1,48 @@ +name: "Detect backend-relevant changes" +description: >- + Classify the pull request's changed files with .circleci/scripts/classify_changes.sh + and expose decision=run|skip. decision=skip means only ui/**, **.md or **.mdx files + changed, so callers can short-circuit expensive steps while the job still completes + successfully and satisfies its required status check. The decision defaults to run for + any non pull_request event or whenever the changed set cannot be resolved, so tests are + never skipped when the classification is uncertain. + +outputs: + decision: + description: "run when backend-relevant files changed, otherwise skip" + value: ${{ steps.classify.outputs.decision }} + +runs: + using: composite + steps: + - id: classify + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -uo pipefail + if [ -z "${BASE_SHA:-}" ]; then + echo "detect-backend-changes: not a pull_request event; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if ! git fetch --no-tags --depth=1 origin "${BASE_SHA}" >/dev/null 2>&1; then + echo "detect-backend-changes: could not fetch base ${BASE_SHA}; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + fi + changed="$(git diff --name-only "${BASE_SHA}" HEAD 2>/dev/null)" || { + echo "detect-backend-changes: git diff failed; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + } + if [ -z "${changed}" ]; then + echo "detect-backend-changes: no changed files vs ${BASE_SHA}; skipping job" + echo "decision=skip" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "detect-backend-changes: changed files vs ${BASE_SHA}:" + printf '%s\n' "${changed}" | sed 's/^/ /' + decision="$(printf '%s\n' "${changed}" | bash .circleci/scripts/classify_changes.sh backend)" || decision="run" + echo "detect-backend-changes: decision=${decision}" + echo "decision=${decision}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index 25c6d4a7019..9fd81b27f3b 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -45,12 +45,18 @@ jobs: name: Run tests runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + decision: ${{ steps.changes.outputs.decision }} steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -72,16 +78,19 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma - name: Run tests + if: steps.changes.outputs.decision != 'skip' env: TEST_PATH: ${{ inputs.test-path }} MAX_FAILURES: ${{ inputs.max-failures }} @@ -114,7 +123,7 @@ jobs: fi - name: Save coverage report - if: always() + if: always() && steps.changes.outputs.decision != 'skip' uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -124,7 +133,7 @@ jobs: upload-coverage: name: Upload coverage to Codecov needs: run - if: always() + if: always() && needs.run.outputs.decision != 'skip' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test-unit-documentation.yml b/.github/workflows/test-unit-documentation.yml index 4cef791a9b3..03f9f0a510b 100644 --- a/.github/workflows/test-unit-documentation.yml +++ b/.github/workflows/test-unit-documentation.yml @@ -32,6 +32,10 @@ jobs: path: docs/my-website persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -53,10 +57,12 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | @@ -64,6 +70,7 @@ jobs: # Run the same documentation tests that CircleCI ran (as direct Python scripts) - name: Run documentation validation tests + if: steps.changes.outputs.decision != 'skip' run: | uv run --no-sync python ./tests/documentation_tests/test_env_keys.py uv run --no-sync python ./tests/documentation_tests/test_router_settings.py diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml index 8db218cd1fc..0068e80e584 100644 --- a/.github/workflows/test-unit-proxy-legacy.yml +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -49,6 +49,10 @@ jobs: with: persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -70,16 +74,19 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma - name: Run tests - ${{ matrix.test-group.name }} + if: steps.changes.outputs.decision != 'skip' env: TEST_PATH: ${{ matrix.test-group.path }} run: | diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql new file mode 100644 index 00000000000..2cfabb9c02e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "dcr_bridge" BOOLEAN; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/constants.py b/litellm/constants.py index 7423d9b2211..715d57e594d 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -715,6 +715,7 @@ openai_compatible_endpoints: List = [ "https://api.clarifai.com/v2/ext/openai/v1", "https://api.libertai.io/v1", "https://pinstripes.io/v1", + "https://api.meta.ai/v1", ] @@ -781,6 +782,7 @@ openai_compatible_providers: List = [ "ragflow", "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", + "meta", # Meta Model API (Muse Spark) - JSON-configured provider ] openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 59d37639098..ae82e7992ed 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -757,6 +757,12 @@ class CustomGuardrail(CustomLogger): # raw provider JSON so redaction is not duplicated upstream). clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response) + from litellm.litellm_core_utils.sensitive_data_masker import ( + mask_credentials_in_payload, + ) + + clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response) + slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name, guardrail_provider=guardrail_provider, diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 61a73201c43..fa8ffe1ba1d 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -346,6 +346,9 @@ def get_llm_provider( elif endpoint == "https://pinstripes.io/v1": custom_llm_provider = "pinstripes" dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY") + elif endpoint == "https://api.meta.ai/v1": + custom_llm_provider = "meta" + dynamic_api_key = get_secret_str("META_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception("api base needs to be a string. api_base={}".format(api_base)) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 1f3a6961f39..7861e13bae5 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,6 +1,8 @@ from collections.abc import Mapping from typing import Any, Dict, List, Optional, Set +from pydantic import BaseModel + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER @@ -153,6 +155,39 @@ def mask_sensitive_structure(data: object) -> object: return _error_masker.mask(data) +def mask_credentials_in_payload(data: object) -> object: + """Return a copy of ``data`` where string values under sensitive-named keys + are masked but every other value (``None``, ``int``, ``float``, ``bool``, + ``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by + identity, and dicts/lists are rebuilt structurally. + + Use this for logging payloads that carry response data through to + SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s + config-dump semantics (``None`` -> ``"None"``, tuples stringified, + objects flattened via ``__dict__``) would silently distort the record. + + Sensitive-key detection is delegated to the shared + :class:`SensitiveDataMasker` so pattern updates stay in one place. + """ + return _walk_payload(data, key_is_sensitive=False, depth=0) + + +def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object: + if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + return node + if isinstance(node, Mapping): + return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()} + if isinstance(node, list): + return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node] + if isinstance(node, tuple): + return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node) + if isinstance(node, BaseModel): + return _walk_payload(node.model_dump(), key_is_sensitive, depth) + if key_is_sensitive and isinstance(node, str) and node: + return _default_masker._mask_value(node) + return node + + def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]: """Return a new dict with values masked for keys listed in ``sensitive_fields``. diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 3c763ed9b9b..31c913d5d4e 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -91,7 +91,7 @@ def create_config_class(provider: SimpleProviderConfig): def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" - from litellm.utils import supports_function_calling + from litellm.utils import supports_function_calling, supports_reasoning supported_params = super().get_supported_openai_params(model=model) @@ -113,6 +113,10 @@ def create_config_class(provider: SimpleProviderConfig): f"function calling — removed tool-related params from supported params." ) + _supports_reasoning = supports_reasoning(model=model, custom_llm_provider=provider.slug) + if _supports_reasoning and "reasoning_effort" not in supported_params: + supported_params.append("reasoning_effort") + return supported_params def map_openai_params( diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 0df8c6e830b..4963bcca9ac 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -1,8 +1,11 @@ from typing import Any, Optional +import litellm from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, ) +from litellm.llms.openai_like.json_loader import SimpleProviderConfig +from litellm.secret_managers.main import get_secret_str DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" @@ -67,3 +70,65 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): if base.endswith("/v1"): base = base[: -len("/v1")] return f"{base}/v1/messages" + + +class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig): + """ + Provider-level native Anthropic Messages passthrough for JSON-configured + OpenAI-compatible providers whose ``supported_endpoints`` in providers.json + includes ``"/v1/messages"``. Resolves the api key and api base from the + provider's configured env vars, then forwards the Anthropic payload + untranslated like ``OpenAILikeAnthropicMessagesConfig``. + """ + + def __init__(self, provider: SimpleProviderConfig): + super().__init__() + self._provider = provider + + def should_strip_billing_metadata(self) -> bool: + return True + + def _resolve_api_key(self, api_key: Optional[str]) -> Optional[str]: + return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key + + def _resolve_api_base(self, api_base: Optional[str]) -> str: + env_api_base = get_secret_str(self._provider.api_base_env) if self._provider.api_base_env else None + return api_base or env_api_base or self._provider.base_url + + def validate_anthropic_messages_environment( + self, + headers: dict[str, str], + model: str, + messages: list[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> tuple[dict[str, str], Optional[str]]: + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self._resolve_api_key(api_key), + api_base=api_base, + ) + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + return super().get_complete_url( + api_base=self._resolve_api_base(api_base), + api_key=api_key, + model=model, + optional_params=optional_params, + litellm_params=litellm_params, + stream=stream, + ) diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index d87346fea70..164100d4194 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -168,6 +168,13 @@ }, "supported_endpoints": ["/v1/chat/completions", "/v1/responses"] }, + "meta": { + "base_url": "https://api.meta.ai/v1", + "api_key_env": "META_API_KEY", + "api_base_env": "META_API_BASE", + "base_class": "openai_gpt", + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + }, "pinstripes": { "base_url": "https://pinstripes.io/v1", "api_key_env": "PINSTRIPES_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a111f301d11..2a269d693ec 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -6012,6 +6012,522 @@ "supports_vision": true, "supports_web_search": true }, + "azure/gpt-5.6": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-sol": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_priority": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "input_cost_per_token_priority": 5e-06, + "input_cost_per_token_above_272k_tokens_priority": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "output_cost_per_token_priority": 3e-05, + "output_cost_per_token_above_272k_tokens_priority": 4.5e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-luna": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "cache_read_input_token_cost_priority": 2e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "input_cost_per_token_priority": 2e-06, + "input_cost_per_token_above_272k_tokens_priority": 4e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "output_cost_per_token_priority": 1.2e-05, + "output_cost_per_token_above_272k_tokens_priority": 1.8e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, "azure/gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -25501,6 +26017,42 @@ "supports_function_calling": true, "supports_tool_choice": false }, + "meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, diff --git a/litellm/models/mcp_server.py b/litellm/models/mcp_server.py index d9757cf80f4..af2efa822b0 100644 --- a/litellm/models/mcp_server.py +++ b/litellm/models/mcp_server.py @@ -96,6 +96,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 10081ce19de..c7b32614c61 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -3,7 +3,7 @@ import binascii import hashlib import json from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Union, cast from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -52,6 +52,7 @@ _AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset( "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", @@ -558,7 +559,11 @@ async def delete_mcp_server_from_virtualkey(): pass -async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]: +async def delete_mcp_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> Optional[LiteLLM_MCPServerTable]: """ Delete the mcp server from the db by server_id @@ -569,6 +574,12 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti caller-visible error. Each table is cleaned independently so a failure on one still attempts the other. + Each enumerated credential row's user also gets their cached per-user token + invalidated (legacy cache + v2 store, via invalidate_token_cache, defaulting + to the manager's shared invalidation): the caches are keyed by + (user_id, server_id), so without this a re-created server reusing the same + server_id would serve tokens minted for the deleted server until TTL. + Returns the deleted mcp server record if it exists, otherwise None """ deleted_server = await MCPServerRepository(prisma_client).table.delete( @@ -577,6 +588,18 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti }, ) if deleted_server is not None: + credential_user_ids: List[str] = [] + try: + credential_rows = await prisma_client.db.litellm_mcpusercredentials.find_many( + where={"server_id": server_id} + ) + credential_user_ids = [row.user_id for row in credential_rows] + except Exception as e: # noqa: BLE001 - enumeration is best-effort; cached tokens expire by TTL + verbose_proxy_logger.warning( + "MCP server %s deleted but per-user credential enumeration failed; cached tokens expire by TTL: %s", + server_id, + e, + ) for model, label in ( (prisma_client.db.litellm_mcpusercredentials, "credential"), (prisma_client.db.litellm_mcpuserenvvars, "env var"), @@ -591,6 +614,15 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti label, e, ) + if credential_user_ids: + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + for user_id in credential_user_ids: + await invalidate_token_cache(user_id, server_id) return deleted_server @@ -1070,6 +1102,103 @@ async def list_user_oauth_credentials( return results +def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object: + """Return one credential field decrypted with the global salt key; non-string and legacy + plaintext values come back unchanged (decrypt_value_helper returns the original on failure).""" + value = creds.get(field) + if not isinstance(value, str): + return value + return decrypt_value_helper( + value=value, + key=field, + exception_type="debug", + return_original_value=True, + ) + + +def mcp_oauth_token_identity(server: object) -> tuple[object, ...]: + """The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url, or + spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth2_flow), the + authorization-server endpoints, and the OAuth client + scopes. Mirrors the dashboard's + getOAuthAuthorizationIdentity. When any of these change on a server update, previously stored + per-user tokens were minted for the old identity and are stale. Excludes transport and + delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693). + + client_id/client_secret are compared decrypted: stored values are NaCl-encrypted with a fresh + nonce on every write, so comparing ciphertext would flag every routine save as an identity + change and purge tokens that are still valid.""" + creds = getattr(server, "credentials", None) + if isinstance(creds, str): + try: + parsed: object = json.loads(creds) + except ValueError: + parsed = None + else: + parsed = creds + creds_dict: Dict[str, object] = parsed if isinstance(parsed, dict) else {} + return ( + getattr(server, "url", None), + getattr(server, "spec_path", None), + getattr(server, "auth_type", None), + getattr(server, "oauth2_flow", None), + getattr(server, "authorization_url", None), + getattr(server, "token_url", None), + getattr(server, "registration_url", None), + _decrypted_credential_field(creds_dict, "client_id"), + _decrypted_credential_field(creds_dict, "client_secret"), + creds_dict.get("scopes"), + ) + + +async def purge_user_oauth_credentials_for_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> int: + """Delete every stored per-user OAuth token for a server and invalidate each user's cached + token everywhere it can be served from (the legacy per-user token cache and the v2 per-user OAuth + token store), so no user keeps a token minted for a superseded configuration. Called when a server + update changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows + removed. + + LiteLLM_MCPUserCredentials also stores BYOK API keys in the same column; only rows whose payload + decodes as an OAuth2 credential (see _decode_oauth_payload) are deleted, because a config change + only invalidates minted tokens, never a user's own stored key. Rows are therefore deleted per + (user_id, server_id) pair rather than by a blanket server_id filter. An OAuth row inserted while + the purge runs for a user not yet enumerated survives; a re-auth completing in the window for an + already-enumerated user is deleted along with the stale row (the pair delete cannot tell them + apart), which costs that user one extra re-auth and nothing else. + + invalidate_token_cache is injectable for tests; it defaults to the manager's shared + invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens.""" + repo = MCPUserCredentialsRepository(prisma_client) + rows = await repo.table.find_many(where={"server_id": server_id}) + oauth_rows = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None] + if not oauth_rows: + return 0 + deleted_count = await repo.table.delete_many( + where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}} + ) + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + + for row in oauth_rows: + await invalidate_token_cache(row.user_id, server_id) + if deleted_count != len(oauth_rows): + verbose_proxy_logger.warning( + "MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; " + "row(s) were deleted concurrently during the purge", + server_id, + deleted_count, + len(oauth_rows), + ) + return deleted_count + + async def refresh_user_oauth_token( prisma_client: PrismaClient, user_id: str, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 4fd47a97066..f746a42080a 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -471,7 +471,8 @@ def _raise_if_not_oauth2(mcp_server: MCPServer) -> None: through: the caller owns the upstream token, and this relayed flow is how a browser obtains one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted token is upstream-audienced and held by the caller; the gateway persists nothing for these - modes (DCR persistence is opt-in and never enabled on this path). + modes (``_persist_dcr_client_registration`` skips them unconditionally, so even the admin + Authorize path with ``persist_credentials`` enabled writes nothing to the server row). """ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, @@ -807,7 +808,7 @@ async def _reuse_persisted_dcr_client_if_available(mcp_server: MCPServer) -> boo return bool(mcp_server.client_id) -DcrRegistrationPersistenceResult = Literal["persisted", "reused", "failed"] +DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"] async def _persist_dcr_client_registration( @@ -821,7 +822,16 @@ async def _persist_dcr_client_registration( full re-authorization instead of a silent refresh. Mirrors the ``encrypt_credentials`` write that ``client_credentials`` and token exchange already use. Failures are logged, never raised: registration still returns to the caller even when persistence fails. + + The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped + unconditionally: the caller holds the upstream token and the gateway must hold no OAuth + client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a + ``client_id`` onto a server whose mode promises the gateway stores nothing, making a + fresh pass-through server read as gateway-authorized. """ + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + return "skipped" + try: registration = _DcrClientRegistration.model_validate(registration_response) except ValidationError as exc: diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 356ed7a2729..b1a2b68fe34 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -57,7 +57,11 @@ from litellm.proxy._experimental.mcp_server.elicitation_handler import ( from litellm.proxy._experimental.mcp_server.sampling_handler import ( MCP_SAMPLING_AVAILABLE, ) -from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + MCPPerUserTokenCache, + mcp_per_user_token_cache, + resolve_mcp_auth, +) from litellm.proxy._experimental.mcp_server.outbound_credentials import ( Error, Ok, @@ -799,10 +803,12 @@ class MCPServerManager: self, cred_provider: Optional[UpstreamCredentialProvider] = None, per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None, + per_user_token_cache: Optional[MCPPerUserTokenCache] = None, ): self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore( self.get_mcp_server_by_id ) + self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache self._cred_provider = cred_provider or UpstreamCredentialProvider( oauth_token_store=self._per_user_oauth_token_store, token_exchanger=build_token_exchanger(), @@ -1038,6 +1044,24 @@ class MCPServerManager: "browser sign-in, including delegate_auth_to_upstream)." ) + config_dcr_bridge = server_config.get("dcr_bridge", None) + if config_dcr_bridge is not None and not isinstance(config_dcr_bridge, bool): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge " + f"must be a boolean (got {config_dcr_bridge!r})." + ) + if config_dcr_bridge and auth_type not in ( + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, + ): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge is only " + f"supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). " + "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded " + "token modes; interactive oauth2 servers already run the gateway " + "authorization-code flow." + ) + new_server = MCPServer( server_id=server_id, name=name_for_prefix, @@ -1073,6 +1097,7 @@ class MCPServerManager: available_on_public_internet=bool(server_config.get("available_on_public_internet", True)), delegate_auth_to_upstream=bool(server_config.get("delegate_auth_to_upstream", False)), oauth_passthrough=bool(server_config.get("oauth_passthrough", False)), + dcr_bridge=config_dcr_bridge, # AWS SigV4 fields aws_access_key_id=server_config.get("aws_access_key_id", None), aws_secret_access_key=server_config.get("aws_secret_access_key", None), @@ -1448,6 +1473,7 @@ class MCPServerManager: available_on_public_internet=bool(getattr(mcp_server, "available_on_public_internet", True)), delegate_auth_to_upstream=bool(getattr(mcp_server, "delegate_auth_to_upstream", False)), oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)), + dcr_bridge=getattr(mcp_server, "dcr_bridge", None), created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), tool_name_to_display_name=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_display_name", None)), @@ -4053,10 +4079,13 @@ class MCPServerManager: return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec) async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None: - """Drop the v2 chain's cached token for ``(user_id, server_id)`` after the credential row - changes (re-auth, revoke), so the next resolve reads the new row instead of serving the - replaced token until its cache TTL. Best-effort: a cache-drop failure is logged, never - raised, because the DB write already succeeded and the TTL remains the backstop. + """Drop every cached token for ``(user_id, server_id)`` after the credential row changes + (re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token + cache, so the next resolve reads the new row instead of serving the replaced token until its + cache TTL, whichever path resolves it. This is the single invalidation point for per-user + OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop + failure is logged, never raised, because the DB write already succeeded and the TTL remains + the backstop. """ try: await self._per_user_oauth_token_store.invalidate(user_id, server_id) @@ -4064,6 +4093,12 @@ class MCPServerManager: verbose_logger.warning( "Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc ) + try: + await self._per_user_token_cache.delete(user_id, server_id) + except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop + verbose_logger.warning( + "Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc + ) async def _resolve_oauth2_headers_for_tool_call( self, @@ -4785,6 +4820,7 @@ class MCPServerManager: token_url=server.token_url, registration_url=server.registration_url, oauth2_flow=server.oauth2_flow, + dcr_bridge=server.dcr_bridge, token_exchange_endpoint=server.token_exchange_endpoint, audience=server.audience, subject_token_type=server.subject_token_type, @@ -4901,6 +4937,7 @@ class MCPServerManager: available_on_public_internet=server.available_on_public_internet, delegate_auth_to_upstream=server.delegate_auth_to_upstream, oauth_passthrough=getattr(server, "oauth_passthrough", False), + dcr_bridge=server.dcr_bridge, is_byok=server.is_byok, byok_description=server.byok_description, byok_api_key_help_url=server.byok_api_key_help_url, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b6bef568637..43ddf302692 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -26,6 +26,7 @@ from litellm.types.llms.openai import ( ResponsesAPIResponse, ) from litellm.types.mcp import ( + MCPAuth, MCPAuthType, MCPCredentials, MCPTransport, @@ -1229,6 +1230,14 @@ from litellm.models.mcp_server import ( # noqa: E402 # MCP Proxy Request Types +def _dcr_bridge_auth_type_error(auth_type: object) -> ValueError: + return ValueError( + f"dcr_bridge is only supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). " + "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded token modes; " + "interactive oauth2 servers already run the gateway authorization-code flow." + ) + + class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: Optional[str] = None server_name: Optional[str] = None @@ -1268,6 +1277,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1322,6 +1332,16 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): """ return values + @model_validator(mode="before") + @classmethod + def validate_dcr_bridge_auth_type(cls, values): + if not isinstance(values, dict) or not values.get("dcr_bridge"): + return values + auth_type = values.get("auth_type") + if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + return values + raise _dcr_bridge_auth_type_error(auth_type) + class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): server_id: str @@ -1362,6 +1382,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1391,6 +1412,21 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): raise ValueError("url or spec_path is required for HTTP/SSE transport") return values + @model_validator(mode="before") + @classmethod + def validate_dcr_bridge_auth_type(cls, values): + """Partial updates omit auth_type; that case is validated against the stored row by the + update endpoint, which can read the database. This validator covers payloads that carry + both fields.""" + if not isinstance(values, dict) or not values.get("dcr_bridge"): + return values + if "auth_type" not in values: + return values + auth_type = values.get("auth_type") + if auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + return values + raise _dcr_bridge_auth_type_error(auth_type) + from litellm.models.mcp_server import ( # noqa: E402 LiteLLM_MCPServerTable as LiteLLM_MCPServerTable, diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index e7fee8d6eb2..cd8103abf5e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -98,7 +98,10 @@ from litellm.repositories.user_repository import UserRepository from litellm.router import Router from litellm.utils import get_utc_datetime -from .auth_checks_organization import organization_role_based_access_check +from .auth_checks_organization import ( + add_team_org_context_to_request_body, + organization_role_based_access_check, +) from .auth_utils import get_model_from_request if TYPE_CHECKING: @@ -707,10 +710,28 @@ async def common_checks( # 10 [OPTIONAL] Organization RBAC checks organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body) + async def _fetch_team_org_id(team_id: str) -> Optional[str]: + try: + team = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + return None + return team.organization_id + + request_body_for_route_check = await add_team_org_context_to_request_body( + route=route, + request_body=request_body, + fetch_team_org_id=_fetch_team_org_id, + ) + _is_route_allowed = _is_api_route_allowed( route=route, request=request, - request_data=request_body, + request_data=request_body_for_route_check, valid_token=valid_token, user_obj=user_object, ) diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index 44c1d158cbe..b4caff9b8ee 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -2,7 +2,7 @@ Auth Checks for Organizations """ -from typing import Dict, List, Optional, Tuple +from typing import Awaitable, Callable, Dict, List, Optional, Tuple from fastapi import status @@ -170,3 +170,33 @@ def _user_is_org_admin( # User must be admin of ALL requested orgs, not just any one return all(org_id in admin_org_ids for org_id in candidate_org_ids) + + +TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"}) + + +async def add_team_org_context_to_request_body( + route: str, + request_body: dict, + fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]], +) -> dict: + """ + Return a copy of request_body with organization_id resolved from the target + team when the route identifies the team by team_id and the caller did not + pass organization_id. This lets an org admin of the team's own org reach the + org-scoped branch of the route gate (which keys off organization_id) without + the client having to send it. Returns request_body unchanged when it does + not apply, so callers that already pass organization_id and non-team routes + are untouched. + """ + if route not in TEAM_ORG_CONTEXT_ROUTES: + return request_body + if request_body.get("organization_id"): + return request_body + team_id = request_body.get("team_id") + if not isinstance(team_id, str) or not team_id: + return request_body + org_id = await fetch_team_org_id(team_id) + if not org_id: + return request_body + return {**request_body, "organization_id": org_id} diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index c9952b245c7..cab2a51a8ca 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -125,7 +125,9 @@ if MCP_AVAILABLE: get_user_env_vars_bulk, get_user_oauth_credential, list_user_oauth_credentials, + mcp_oauth_token_identity, merge_user_env_vars, + purge_user_oauth_credentials_for_server, reject_mcp_server, store_user_credential, store_user_oauth_credential, @@ -2318,6 +2320,41 @@ if MCP_AVAILABLE: }, ) + # Snapshot the pre-update identity so we can detect a mint-relevant change below. The read is + # advisory (it only feeds the stale-token purge decision), so a failure skips the purge with a + # warning instead of failing the edit, whose primary job is the update itself. + try: + old_server_record = await get_mcp_server(prisma_client, payload.server_id) + old_server_record_read_failed = False + except Exception as exc: # noqa: BLE001 - advisory read; invalidation is best-effort end-to-end + verbose_logger.warning( + "MCP server %s: could not snapshot the pre-update record; skipping the stale-token check: %s", + payload.server_id, + exc, + ) + old_server_record = None + old_server_record_read_failed = True + + if ( + payload.dcr_bridge + and payload.auth_type is None + and (old_server_record is not None or old_server_record_read_failed) + ): + stored_auth_type = old_server_record.auth_type if old_server_record else None + stored_auth_type_name = getattr(stored_auth_type, "value", stored_auth_type) + if stored_auth_type not in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + "dcr_bridge is only supported for auth_type true_passthrough or " + f"oauth_delegate (stored auth_type: {stored_auth_type_name!r}). Include " + "the server's auth_type in the update payload or configure one of the " + "client-forwarded token modes first." + ) + }, + ) + # try to update the mcp server mcp_server_record_updated = await update_mcp_server( prisma_client, @@ -2336,6 +2373,30 @@ if MCP_AVAILABLE: # Ensure registry is up to date by reloading from database await global_mcp_server_manager.reload_servers_from_database() + # If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth + # mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user + # token was minted for the old configuration and is stale. Purge them (DB + cache) so the next + # tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer + # matches. Best-effort: a purge failure must not fail the update, whose primary job already + # succeeded. + if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity( + mcp_server_record_updated + ): + try: + purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id) + if purged: + verbose_logger.info( + "MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change", + payload.server_id, + purged, + ) + except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded + verbose_logger.warning( + "MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s", + payload.server_id, + exc, + ) + # TODO: Enterprise: Finish audit log trail if litellm.store_audit_logs: pass diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index bce2e3581b5..5a0b94d1524 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3392,7 +3392,7 @@ async def _build_ui_spend_logs_response( ) count_map = {r["session_id"]: r["_count"]["session_id"] for r in counts if r.get("session_id")} - mcp_spend_map: dict[str, dict[str, Union[int, float]]] = {} + session_spend_map: dict[str, dict[str, Union[int, float]]] = {} if enrich_session_counts and session_ids: from prisma.errors import PrismaError @@ -3410,19 +3410,24 @@ async def _build_ui_spend_logs_response( rows = await prisma_client.db.query_raw( """ SELECT session_id, - COUNT(*)::int AS mcp_tool_call_count, - COALESCE(SUM(spend), 0)::double precision AS mcp_tool_call_spend + COALESCE(SUM(spend), 0)::double precision AS session_total_spend, + COUNT(*) FILTER ( + WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools') + )::int AS mcp_tool_call_count, + COALESCE(SUM(spend) FILTER ( + WHERE call_type IN ('call_mcp_tool', 'list_mcp_tools') + ), 0)::double precision AS mcp_tool_call_spend FROM "LiteLLM_SpendLogs" WHERE session_id = ANY($1::text[]) AND api_key = ANY($2::text[]) - AND call_type IN ('call_mcp_tool', 'list_mcp_tools') GROUP BY session_id """, session_ids, authorized_api_keys, ) - mcp_spend_map = { + session_spend_map = { row["session_id"]: { + "session_total_spend": float(row.get("session_total_spend") or 0.0), "mcp_tool_call_count": int(row.get("mcp_tool_call_count") or 0), "mcp_tool_call_spend": float(row.get("mcp_tool_call_spend") or 0.0), } @@ -3431,7 +3436,7 @@ async def _build_ui_spend_logs_response( } except PrismaError: verbose_proxy_logger.debug( - "Failed to enrich MCP session spend aggregates for spend logs UI", + "Failed to enrich session spend aggregates for spend logs UI", exc_info=True, ) @@ -3441,10 +3446,12 @@ async def _build_ui_spend_logs_response( row_dict = dict(row) if isinstance(row, dict) else row.model_dump() sid = row_dict.get("session_id") row_dict["session_total_count"] = count_map.get(sid, 1) if sid else 1 - mcp_stats = mcp_spend_map.get(sid) if sid else None - if mcp_stats: - row_dict["mcp_tool_call_count"] = mcp_stats["mcp_tool_call_count"] - row_dict["mcp_tool_call_spend"] = mcp_stats["mcp_tool_call_spend"] + session_stats = session_spend_map.get(sid) if sid else None + if session_stats: + row_dict["session_total_spend"] = session_stats["session_total_spend"] + if session_stats["mcp_tool_call_count"]: + row_dict["mcp_tool_call_count"] = session_stats["mcp_tool_call_count"] + row_dict["mcp_tool_call_spend"] = session_stats["mcp_tool_call_spend"] enriched.append(row_dict) response_data: list = enriched else: diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index d3642cb12a4..b38d5e39800 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -136,7 +136,7 @@ def _get_spend_logs_metadata( clean_metadata["vector_store_request_metadata"] = _get_vector_store_request_for_spend_logs_payload( vector_store_request_metadata ) - clean_metadata["guardrail_information"] = guardrail_information + clean_metadata["guardrail_information"] = _sanitize_guardrail_information_for_spend_logs(guardrail_information) clean_metadata["usage_object"] = usage_object clean_metadata["model_map_information"] = model_map_information clean_metadata["cold_storage_object_key"] = cold_storage_object_key @@ -868,6 +868,51 @@ def _redact_prompt_leaks_in_error_string(text: str) -> str: return "".join(out) +def _sanitize_guardrail_information_for_spend_logs( + guardrail_information: Optional[List[StandardLoggingGuardrailInformation]], +) -> Optional[List[StandardLoggingGuardrailInformation]]: + """ + When ``store_prompts_in_spend_logs`` is False, redact prompt-carrying fields + (``guardrail_request``, ``guardrail_response``, ``match_details``, + ``classification``) before they land in ``LiteLLM_SpendLogs.metadata``. + + Guardrail hooks may echo the LLM request payload back into + ``guardrail_response``, and two first-party hooks + (``block_code_execution``, ``litellm_content_filter``) inline user-prompt + substrings into ``match_details`` / ``classification`` too, so the flag + must cover all four fields. Every other typed field on the entry (name, + provider, mode, status, timings, action, violation_categories, risk_score, + masked_entity_count, ...) is preserved so guardrail dashboards keep + working. + + ``guardrail_information`` is typed ``Optional[List[...]]`` but at least + one writer (``xecguard``) assigns a bare dict, so normalize to a list + here to match OTEL's defensive read pattern; otherwise iteration would + yield the dict's keys and crash the whole spend-log write. + """ + if guardrail_information is None or _should_store_prompts_and_responses_in_spend_logs(): + return guardrail_information + entries = [guardrail_information] if isinstance(guardrail_information, dict) else guardrail_information + return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)] + + +_PROMPT_CARRYING_GUARDRAIL_FIELDS = ( + "guardrail_request", + "guardrail_response", + "match_details", + "classification", +) + + +def _redact_prompt_fields_in_guardrail_entry( + entry: StandardLoggingGuardrailInformation, +) -> StandardLoggingGuardrailInformation: + return { + **entry, + **{key: REDACTED_BY_LITELM_STRING for key in _PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry}, + } + + def _sanitize_error_information_for_spend_logs( error_information: Optional[StandardLoggingPayloadErrorInformation], ) -> Optional[StandardLoggingPayloadErrorInformation]: diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index f102ab5b7b9..bb0baba6cf4 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -103,6 +103,7 @@ class MCPServer(BaseModel): # ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must # be set explicitly to avoid regressing servers that did not opt in. oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = [] byok_api_key_help_url: Optional[str] = None @@ -164,6 +165,15 @@ class MCPServer(BaseModel): JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing.""" return self.auth_type == MCPAuth.oauth_delegate + @property + def is_dcr_bridge(self) -> bool: + """True when this client-forwarded-token server serves the gateway-hosted DCR front door + (gateway-self protected-resource and authorization-server metadata plus the register, + authorize, and token relays) instead of relaying the upstream's own OAuth discovery + verbatim. ``dcr_bridge`` is rejected on every other auth type at create, update, and + config load, so the mode gate here only defends rows edited outside those paths.""" + return bool(self.dcr_bridge) and (self.is_true_passthrough or self.is_oauth_delegate) + @property def requires_per_user_auth(self) -> bool: """ diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6b99cfa3314..8a5acc24d19 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2533,9 +2533,10 @@ class StandardLoggingMCPToolCall(TypedDict, total=False): mcp_server_resource: Optional[str] """ - The upstream MCP server resource identifier (scheme + host + path) the tool call was - forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an - upstream URL carrying an embedded token or secret query parameter never reaches log metadata. + The origin (scheme + host + port) of the upstream MCP server the tool call was forwarded + to. Redacted for logging: userinfo, the path, the query string, and the fragment are all + stripped, because hosted MCP servers routinely embed the credential in the URL path and + this value is readable by callers via request logs. Records which upstream received a relayed request; never a credential. """ @@ -2697,7 +2698,7 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False): guardrail_name: Optional[str] guardrail_provider: Optional[str] guardrail_mode: Optional[Union[GuardrailEventHooks, List[GuardrailEventHooks], GuardrailMode]] - guardrail_request: Optional[dict] + guardrail_request: Optional[Union[str, dict]] guardrail_response: Optional[Union[dict, str, List[dict]]] guardrail_status: GuardrailStatus start_time: Optional[float] @@ -2728,10 +2729,10 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False): confidence_score: Optional[float] """For LLM-judge guardrails: confidence score 0.0-1.0""" - classification: Optional[dict] + classification: Optional[Union[str, dict]] """For LLM-judge guardrails: structured classification output""" - match_details: Optional[List[dict]] + match_details: Optional[Union[str, List[dict]]] """Detailed match information for each detected pattern""" patterns_checked: Optional[int] @@ -3397,6 +3398,7 @@ class LlmProviders(str, Enum): LIBERTAI = "libertai" PINSTRIPES = "pinstripes" DARKBLOOM = "darkbloom" + META = "meta" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" BEDROCK_MANTLE = "bedrock_mantle" diff --git a/litellm/utils.py b/litellm/utils.py index 19c2fe16085..5af7b62b332 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8028,6 +8028,16 @@ class ProviderConfigManager: ) return GithubCopilotAnthropicMessagesConfig() + + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + json_provider = JSONProviderRegistry.get(provider.value) + if json_provider is not None and "/v1/messages" in json_provider.supported_endpoints: + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + return JSONProviderAnthropicMessagesConfig(json_provider) return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d6e4a265da0..e79ddbe35d2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -6012,6 +6012,522 @@ "supports_vision": true, "supports_web_search": true }, + "azure/gpt-5.6": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-sol": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_priority": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "input_cost_per_token_priority": 5e-06, + "input_cost_per_token_above_272k_tokens_priority": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "output_cost_per_token_priority": 3e-05, + "output_cost_per_token_above_272k_tokens_priority": 4.5e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-luna": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "cache_read_input_token_cost_priority": 2e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "input_cost_per_token_priority": 2e-06, + "input_cost_per_token_above_272k_tokens_priority": 4e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "output_cost_per_token_priority": 1.2e-05, + "output_cost_per_token_above_272k_tokens_priority": 1.8e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": 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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, "azure/gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -25659,6 +26175,42 @@ "supports_function_calling": true, "supports_tool_choice": false }, + "meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 3034ada56ba..65db63dc045 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1984,6 +1984,23 @@ "interactions": true } }, + "meta": { + "display_name": "Meta Model API (`meta`)", + "url": "https://docs.litellm.ai/docs/providers/meta", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "pinstripes": { "display_name": "Pinstripes (`pinstripes`)", "url": "https://docs.litellm.ai/docs/providers/pinstripes", diff --git a/schema.prisma b/schema.prisma index 6d87102a3f3..fb4d8d0b5a3 100644 --- a/schema.prisma +++ b/schema.prisma @@ -339,6 +339,7 @@ model LiteLLM_MCPServerTable { available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 6d0e12314f0..2af14e1e544 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -36,6 +36,7 @@ IGNORE_FUNCTIONS = [ "_collect_argument_paths", # max depth set. "_split_text", # max depth set. "_mask_sequence", # max depth set. + "_walk_payload", # max depth set (DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER). "_delete_nested_value_custom", # max depth set (bounded by number of path segments). "filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion. "__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion. diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index 4ba9b1cea4b..4debf50bd6c 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -16,8 +16,8 @@ failures are hard test failures (see `tests/e2e/CLAUDE.md`). |-----------|--------|----------|--------|------|--------------| | OpenAI | yes | yes | yes | yes | OpenAI Files | | Azure | yes | yes | yes | yes | Azure Files | -| Vertex AI | yes | yes | yes | yes | GCS bucket (`GCS_BUCKET_NAME` via files_settings) | -| Bedrock | yes | yes | no (limited upstream) | no | S3 bucket (`AWS_BATCH_S3_BUCKET` + `AWS_BATCH_ROLE_ARN` on model) | +| Vertex AI | yes | yes | yes | yes | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) | +| Bedrock | yes (unified only) | yes | no (limited upstream) | no | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) | Bedrock cancel is unreliable upstream and list is unsupported, so both are gated off (`can_cancel=False`, `can_list=False`) when that provider is enabled in the matrix. diff --git a/tests/e2e/batches/capabilities.py b/tests/e2e/batches/capabilities.py index 522e3162e24..3eb0e0328be 100644 --- a/tests/e2e/batches/capabilities.py +++ b/tests/e2e/batches/capabilities.py @@ -1,20 +1,22 @@ -"""The declarative provider x routing-scenario matrix the lifecycle test runs. - -One Capability per supported (provider, scenario) pair, so the parametrized test -has no dead/skipped cells. `provider` is litellm's custom_llm_provider, used to -route provider-fallback calls to /{provider}/v1/... and to assert the raw batch id -shape (the only scenario whose id is not re-encoded by the proxy). Operations that -a provider does not support (Bedrock: no cancel, no list) are gated per row. -""" +"""Provider x routing-scenario matrix for the batches lifecycle e2e.""" from __future__ import annotations import base64 +import os from dataclasses import dataclass from typing import Literal from models import LiteLLMParamsBody + +def _env_ref(*names: str) -> str: + for name in names: + value = os.environ.get(name) + if value is not None and value.strip() != "": + return f"os.environ/{name}" + return f"os.environ/{names[0]}" + Scenario = Literal["encoded", "unified", "model_param", "provider_fallback"] IdShape = Literal["managed", "model_encoded", "raw"] @@ -44,10 +46,10 @@ class Provider: ) case "azure": return LiteLLMParamsBody( - model="azure/gpt-4.1-mini-batch", + model="azure/gpt-5.4-mini-batch", api_base="os.environ/AZURE_API_BASE", api_key="os.environ/AZURE_API_KEY", - api_version="2024-07-01-preview", + api_version="2025-04-01-preview", ) case "vertex_ai": return LiteLLMParamsBody( @@ -55,14 +57,19 @@ class Provider: vertex_project="os.environ/VERTEXAI_PROJECT", vertex_location="us-central1", vertex_credentials="os.environ/VERTEXAI_CREDENTIALS", + gcs_bucket_name="os.environ/GCS_BUCKET_NAME", + bucket_name="os.environ/GCS_BUCKET_NAME", ) case "bedrock": return LiteLLMParamsBody( model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID", + aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", + aws_region_name="os.environ/AWS_REGION", + s3_region_name="os.environ/AWS_REGION", + s3_bucket_name=_env_ref("AWS_BATCH_S3_BUCKET", "AWS_S3_BUCKET_NAME"), s3_access_key_id="os.environ/AWS_ACCESS_KEY_ID", s3_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", - s3_region_name="os.environ/AWS_REGION", - s3_bucket_name="os.environ/AWS_BATCH_S3_BUCKET", aws_batch_role_arn="os.environ/AWS_BATCH_ROLE_ARN", ) case _: @@ -84,20 +91,12 @@ class Capability: @property def jsonl_model(self) -> str: - """Model name embedded in the uploaded JSONL ``body.model``. - - Only the unified upload path rewrites JSONL on upload - (``target_model_names`` → ``llm_router.acreate_file`` → - ``replace_model_in_jsonl``), so that scenario can use the LiteLLM alias - and rely on the proxy to swap it to the deployment model. Every other - scenario uploads raw JSONL with no rewrite, so the provider's real - deployment name is required or create fails upstream validation.""" return self.model if self.scenario == "unified" else self.raw_model PROVIDERS: tuple[Provider, ...] = ( Provider("openai", "openai-batch", "gpt-4o-mini", can_cancel=True, can_list=True), - Provider("azure", "azure-batch", "gpt-4.1-mini-batch", can_cancel=True, can_list=True), + Provider("azure", "azure-batch", "gpt-5.4-mini-batch", can_cancel=True, can_list=True), Provider( "vertex_ai", "vertex-batch", "gemini-2.5-flash", can_cancel=True, can_list=True ), @@ -110,7 +109,7 @@ PROVIDERS: tuple[Provider, ...] = ( ), ) -BEDROCK_SCENARIOS: tuple[Scenario, ...] = ("encoded", "unified") +BEDROCK_SCENARIOS: tuple[Scenario, ...] = ("unified",) def scenarios_for_provider(provider: Provider) -> tuple[Scenario, ...]: @@ -127,8 +126,6 @@ CAPABILITIES: tuple[Capability, ...] = tuple( def raw_id_matches_provider(provider: str, batch_id: str) -> bool: - """The provider-fallback path returns the provider's native batch id (unencoded), - so its shape discriminates which provider actually handled the batch.""" if provider in ("openai", "azure"): return batch_id.startswith("batch") if provider == "vertex_ai": @@ -166,12 +163,10 @@ def _b64_decode(value: str) -> str: def is_managed_id(id_str: str) -> bool: - """A litellm managed unified file/batch id base64-decodes to a litellm_proxy marker.""" return _b64_decode(id_str).startswith("litellm_proxy") def is_model_encoded_id(id_str: str) -> bool: - """A model-encoded id keeps the provider prefix and base64-encodes litellm:;model,.""" for prefix in ("file-", "batch_"): if id_str.startswith(prefix): decoded = _b64_decode(id_str[len(prefix) :]) diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index a998f962c04..7d54f05656e 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -142,10 +142,12 @@ def quietly(action: Callable[[], object]) -> Callable[[], None]: return run -def assert_file_object(file: FileObject) -> None: +def assert_file_object(file: FileObject, *, provider: str) -> None: assert file.object == "file", f"file.object={file.object!r}" assert file.purpose == "batch", f"file.purpose={file.purpose!r}" - assert file.bytes is not None and file.bytes > 0, f"file.bytes={file.bytes!r}" + assert file.bytes is not None, f"file.bytes={file.bytes!r}" + if provider != "bedrock": + assert file.bytes > 0, f"file.bytes={file.bytes!r}" assert file.status, "file.status missing" assert ( file.created_at is not None and file.created_at > 0 @@ -179,7 +181,7 @@ def test_batch_lifecycle( resources.defer( quietly(lambda: client.delete_file(file.id, key=key, provider=provider)) ) - assert_file_object(file) + assert_file_object(file, provider=cap.provider) assert matches_id_shape( FILE_ID_SHAPE[cap.scenario], file.id ), f"{cap.id}: file id {file.id!r} is not a {FILE_ID_SHAPE[cap.scenario]} id" @@ -234,10 +236,31 @@ def test_batch_lifecycle( ) if cap.can_list: - listed = unwrap(client.list_batches(key=key, provider=provider)) + list_result = client.list_batches(key=key, provider=provider) + managed_filter_unsupported = False + match list_result: + case UnknownApiError(body=body) if ( + "Filtering by 'provider' is not supported when using managed batches" in body + ): + managed_filter_unsupported = True + listed = unwrap(client.list_batches(key=key, provider=None)) + case _: + listed = unwrap(list_result) if listed.object is not None: assert listed.object == "list", f"list envelope object={listed.object!r}" match = next((b for b in listed.data if b.id == batch.id), None) + if ( + match is None + and managed_filter_unsupported + and cap.scenario == "provider_fallback" + ): + # provider_fallback keeps the provider's raw batch id (not re-encoded + # into a managed/proxy id). When the gateway rejects provider-scoped + # list, the only available list is the unfiltered managed view, which + # does not index raw provider ids. Membership cannot be asserted here; + # create + retrieve (and raw_id_matches_provider above) already pin + # routing for this scenario. + return assert match is not None, "created batch absent from list" assert match.object == "batch" @@ -289,7 +312,7 @@ def test_file_upload_and_delete_outputs( key=key, ) ) - assert_file_object(file) + assert_file_object(file, provider="openai") deleted = unwrap(client.delete_file(file.id, key=key)) assert deleted.id, "delete response has no id" diff --git a/tests/e2e/budgets/budget_client.py b/tests/e2e/budgets/budget_client.py index 86d49e836fc..fe862b5c119 100644 --- a/tests/e2e/budgets/budget_client.py +++ b/tests/e2e/budgets/budget_client.py @@ -10,12 +10,13 @@ and response models are co-located here because only this suite uses them. from __future__ import annotations +import time from dataclasses import dataclass from pydantic import AliasPath, BaseModel, Field, RootModel from e2e_gateway import Gateway, build_gateway -from e2e_http import NoBody, StreamingResponse, Success, unwrap +from e2e_http import NoBody, Result, StreamingResponse, Success, unwrap from models import ( AnthropicMessagesBody, BudgetWindow, @@ -26,6 +27,9 @@ from models import ( ModelBudgetEntry, ) +_TEAM_READY_ATTEMPTS = 15 +_TEAM_READY_SLEEP_SECONDS = 0.4 + class UserNewBody(BaseModel): max_budget: float @@ -299,7 +303,7 @@ class BudgetClient: organization_id: str | None = None, budget_limits: list[BudgetWindow] | None = None, ) -> str: - return unwrap( + team_id = unwrap( self.gateway.transport.post( "/team/new", headers=self.gateway.transport.master, @@ -312,6 +316,8 @@ class BudgetClient: response_type=TeamNewResponse, ) ).team_id + self._wait_for_team(team_id) + return team_id def delete_team(self, team_id: str) -> None: _ = self.gateway.transport.post( @@ -321,17 +327,43 @@ class BudgetClient: response_type=NoBody, ) + def _wait_for_team(self, team_id: str) -> None: + last: Result[TeamInfoResponse] | None = None + for _ in range(_TEAM_READY_ATTEMPTS): + last = self.gateway.transport.get( + "/team/info", + headers=self.gateway.transport.master, + params=TeamInfoParams(team_id=team_id), + response_type=TeamInfoResponse, + ) + match last: + case Success(): + return + case _: + time.sleep(_TEAM_READY_SLEEP_SECONDS) + assert last is not None + _ = unwrap(last) + def add_team_member(self, team_id: str, user_id: str, *, max_budget_in_team: float | None = None) -> None: - resp = self.gateway.transport.send( - "/team/member_add", - headers=self.gateway.transport.master, - json=TeamMemberAddBody( - team_id=team_id, - member=TeamMember(role="user", user_id=user_id), - max_budget_in_team=max_budget_in_team, - ), - ) - assert resp.ok, resp.body + last_body = "" + for attempt in range(_TEAM_READY_ATTEMPTS): + resp = self.gateway.transport.send( + "/team/member_add", + headers=self.gateway.transport.master, + json=TeamMemberAddBody( + team_id=team_id, + member=TeamMember(role="user", user_id=user_id), + max_budget_in_team=max_budget_in_team, + ), + ) + if resp.ok: + return + last_body = resp.body + if "doesn't exist" in resp.body and attempt + 1 < _TEAM_READY_ATTEMPTS: + time.sleep(_TEAM_READY_SLEEP_SECONDS) + continue + break + assert False, last_body def update_team_member( self, diff --git a/tests/e2e/budgets/test_budget_reset_advances_e2e.py b/tests/e2e/budgets/test_budget_reset_advances_e2e.py new file mode 100644 index 00000000000..d3ee7e7f64f --- /dev/null +++ b/tests/e2e/budgets/test_budget_reset_advances_e2e.py @@ -0,0 +1,228 @@ +"""Live e2e: regression guards for #25109 (budget resets stopped working). + +The existing test_budget_reset_e2e.py / test_multi_window_budget_e2e.py prove a +blocked key flows again after its window. #25109 stored multi-budget-window data +in nullable JSON columns and filtered eligible rows with a `not: None`-style Prisma +filter that misbehaves on a nullable JSON column, so due rows were either skipped +(budget_reset_at stayed pinned, spend never cleared) or the reset path errored +(a non-budget 5xx leaked to callers). These tests assert the precise invariants +that bug broke, built up START-SLOW from scheduling -> enforcement -> the reset +strictly advancing -> the JSON-backed multi-window / team-member edges -> the +error path. They EXTEND the happy-path modules rather than duplicate them: each +asserts a delta (before datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _drive_to_block(client: BudgetClient, key: str) -> None: + """Spend until the cap blocks; fails loudly if enforcement never trips.""" + for _ in range(20): + result = _call(client, key) + if is_budget_block(result): + return + require_successful_call(result) + time.sleep(2) + pytest.fail("budget never enforced before block") + + +# ---- Rung 1: scheduling exists at creation ----------------------------------- + + +def test_key_with_budget_duration_schedules_reset_at_creation( + client: BudgetClient, resources: ResourceManager +) -> None: + """Baseline: a key created with a budget_duration has budget_reset_at populated + immediately. The reset job can only advance a timestamp that was scheduled in + the first place; everything below depends on this.""" + key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s") + resources.defer(lambda: client.delete_key(key)) + + info = client.gateway.key_info(key) + assert info.budget_reset_at is not None, "budget_duration set no budget_reset_at" + assert _as_datetime(info.budget_reset_at) > _as_datetime("1970-01-01T00:00:00Z") + + +# ---- Rung 2: enforcement trips at the cap ------------------------------------ + + +def test_key_spend_blocks_at_cap(client: BudgetClient, resources: ResourceManager) -> None: + """Sanity that the tiny cap is enforced before we test that it resets: spend + accrues across calls and eventually returns budget_exceeded, never a 5xx.""" + key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s") + resources.defer(lambda: client.delete_key(key)) + + # _drive_to_block is the enforcement proof: it fails unless a budget_exceeded + # block follows successful (non-5xx) calls. key_info.spend is deliberately not + # asserted - it is the DB-persisted field that flushes ~60s later + # (proxy_batch_write_at), so reading it right after the block races to 0.0. + _drive_to_block(client, key) + + +# ---- Rung 3: the core regression - reset_at strictly advances + spend zeroes -- + + +def test_key_budget_reset_at_advances_after_window( + client: BudgetClient, resources: ResourceManager +) -> None: + """The core #25109 guard: after the window elapses the reset job must move + budget_reset_at strictly forward AND zero key.spend. The broken nullable-JSON + filter left eligible rows untouched, so the timestamp stayed pinned and spend + never cleared. Asserting before before, ( + "budget_reset_at did not advance past the pre-reset value" + ) + assert (info.spend or 0.0) < TINY_CAP, f"spend not cleared after reset: {info.spend}" + return + pytest.fail(f"key budget never reset within {RESET_DEADLINE_SECONDS}s") + + +# ---- Rung 4: multi-window - tight window resets, roomy window keeps spend ----- + + +def test_multi_window_key_resets_each_window_independently( + client: BudgetClient, resources: ResourceManager +) -> None: + """The JSON-backed path #25109 specifically touched. A tight 30s window and a + roomy 1m window: the tight window must reset on its own boundary while the roomy + window keeps its accumulated spend (independent per-window reset). The + nullable-JSON filter bug skipped these JSON-backed rows entirely, so the tight + window never came back; a job that ERRORS on the JSON column would surface here + as a non-budget 5xx, which we reject throughout the wait.""" + key = client.generate_key( + budget_limits=[ + BudgetWindow(budget_duration=f"{WINDOW_SECONDS}s", max_budget=TINY_CAP), + BudgetWindow(budget_duration="1m", max_budget=1.0), + ] + ) + resources.defer(lambda: client.delete_key(key)) + + start = time.monotonic() + _drive_to_block(client, key) + spend_at_block = client.gateway.key_info(key).spend or 0.0 + + deadline = time.monotonic() + RESET_DEADLINE_SECONDS + while time.monotonic() < deadline: + time.sleep(5) + result = _call(client, key) + if result.ok: + elapsed = time.monotonic() - start + assert elapsed < WINDOW_SECONDS + 90, ( + f"tight window reset took {elapsed:.0f}s - too long for {WINDOW_SECONDS}s" + ) + assert (client.gateway.key_info(key).spend or 0.0) >= spend_at_block, ( + "roomy window spend was wiped when only the tight window should reset" + ) + return + assert is_budget_block(result), f"non-budget error during reset wait: {result.body[:200]}" + pytest.fail(f"tight window never reset within {RESET_DEADLINE_SECONDS}s") + + +# ---- Rung 5: team-member window advances (JSON-backed per-team budget) -------- + + +def test_team_member_budget_reset_at_advances( + client: BudgetClient, resources: ResourceManager +) -> None: + """Per-team member windows are also JSON-backed. member_budget_reset_at must + advance after the window; the explicit before before: + return + pytest.fail(f"member budget_reset_at never advanced past {before.isoformat()} in {RESET_DEADLINE_SECONDS}s") + + +# ---- Rung 6: error-path edge - resets surface as blocks, never 5xx ----------- + + +def test_reset_wait_never_yields_non_budget_error( + client: BudgetClient, resources: ResourceManager +) -> None: + """The other #25109 failure mode: a reset job that ERRORS on the nullable-JSON + column surfaces to the caller as a non-budget 5xx. Across the whole reset wait + every non-ok response must be a budget block (is_budget_block) and never a + server error; this guards the error path independently of whether the reset + eventually fires.""" + key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s") + resources.defer(lambda: client.delete_key(key)) + + _drive_to_block(client, key) + + saw_reset = False + deadline = time.monotonic() + RESET_DEADLINE_SECONDS + while time.monotonic() < deadline: + time.sleep(5) + result = _call(client, key) + if result.ok: + saw_reset = True + break + assert is_budget_block(result), ( + f"reset wait yielded a non-budget error (likely a JSON-column reset crash): {result.body[:200]}" + ) + assert saw_reset, f"key budget never reset within {RESET_DEADLINE_SECONDS}s" diff --git a/tests/e2e/budgets/test_spend_counter_reseed_e2e.py b/tests/e2e/budgets/test_spend_counter_reseed_e2e.py index a6860aeef43..de868f4fd3c 100644 --- a/tests/e2e/budgets/test_spend_counter_reseed_e2e.py +++ b/tests/e2e/budgets/test_spend_counter_reseed_e2e.py @@ -64,25 +64,47 @@ def _redis() -> "redis.Redis[str] | RedisCluster[str]": ) +def _parse_counter(raw: object) -> float | None: + if raw is None: + return None + if isinstance(raw, (int, float)): + return float(raw) + text = str(raw).strip() + if not text: + return None + try: + return float(text) + except ValueError: + try: + import json + + return float(json.loads(text)) + except Exception: + return None + + def _spend_counter(rds: "redis.Redis[str] | RedisCluster[str]", key: str) -> float | None: - """The shared spend counter for `key`, or None if it is cold. A cluster client - can't run a keyspace SCAN that spans shards, so read the key directly - the stage - gateway sets no cache namespace, so the key is the bare ``spend:key:{sha256(key)}``. - A standalone client matches by suffix, so the local cache namespace (litellm.caching) - need not be hard-coded here.""" + """The shared spend counter for `key`, or None if it is cold. + + The gateway keys counters as ``spend:key:{sha256(raw_sk)}``, optionally under a + redis namespace prefix. Cluster mode cannot SCAN all shards, so try the bare key + and a few common namespaces; standalone redis uses a suffix SCAN. + """ from redis.cluster import RedisCluster digest = hashlib.sha256(key.encode()).hexdigest() suffix = f"spend:key:{digest}" if isinstance(rds, RedisCluster): - raw = rds.get(suffix) - return float(raw) if raw is not None else None + for candidate in (suffix, f"litellm:{suffix}", f"litellm.caching:{suffix}"): + parsed = _parse_counter(rds.get(candidate)) + if parsed is not None: + return parsed + return None matches = list(rds.scan_iter(match=f"*{suffix}")) if not matches: return None - raw = rds.get(matches[0]) - return float(raw) if raw is not None else None + return _parse_counter(rds.get(matches[0])) def _chat(client: BudgetClient, key: str) -> StreamingResponse: @@ -97,19 +119,6 @@ def _accumulate(client: BudgetClient, key: str, count: int) -> None: list(pool.map(one, range(count))) -def _burst(client: BudgetClient, key: str, count: int) -> None: - """Fire `count` requests that start together, so multiple workers reseed the cold - counter concurrently rather than one warming it before the others arrive.""" - barrier = Barrier(count) - - def one(_: int) -> StreamingResponse: - barrier.wait() - return _chat(client, key) - - with ThreadPoolExecutor(max_workers=count) as pool: - list(pool.map(one, range(count))) - - def test_cold_counter_reseed_keeps_counter_equal_to_db_spend( client: BudgetClient, resources: ResourceManager ) -> None: @@ -132,10 +141,28 @@ def test_cold_counter_reseed_keeps_counter_equal_to_db_spend( db_spend = client.gateway.key_info(key).spend or 0.0 assert db_spend > 0, f"no DB spend accumulated from real calls: {db_spend}" - _burst(client, key, BURST) - time.sleep(3) + burst_results = [] + barrier = Barrier(BURST) + + def one(_: int) -> StreamingResponse: + barrier.wait() + return _chat(client, key) + + with ThreadPoolExecutor(max_workers=BURST) as pool: + burst_results = list(pool.map(one, range(BURST))) + assert all(r.ok for r in burst_results), ( + "some burst calls failed; cannot exercise concurrent reseed. " + f"statuses={[r.status_code for r in burst_results]}" + ) + + counter: float | None = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + counter = _spend_counter(rds, key) + if counter is not None: + break + time.sleep(0.5) - counter = _spend_counter(rds, key) assert counter is not None, "the burst did not reseed the cold counter" assert db_spend * 0.95 <= counter < db_spend * 1.7, ( f"redis spend counter {counter} does not equal DB spend {db_spend} (expected ~equal " diff --git a/tests/e2e/budgets/test_tag_budget_e2e.py b/tests/e2e/budgets/test_tag_budget_e2e.py index 7cec5bc96c1..06d1d791745 100644 --- a/tests/e2e/budgets/test_tag_budget_e2e.py +++ b/tests/e2e/budgets/test_tag_budget_e2e.py @@ -26,7 +26,7 @@ def _tagged_call(client: BudgetClient, key: str, tag: str): "claude-haiku-4-5", f"hi {unique_marker()}", tags=[tag], - max_tokens=16, + max_tokens=64, ) if not result.ok and not is_budget_block(result): require_successful_call(result) @@ -40,17 +40,20 @@ def test_tag_budget_blocks_tagged_requests( client.create_tag(budgeted_tag, max_budget=TINY_BUDGET) resources.defer(lambda: client.delete_tag(budgeted_tag)) - # Requests under the budgeted tag get blocked once its spend is exceeded. - blocked = False - deadline = time.monotonic() + 60 - while time.monotonic() < deadline: - if is_budget_block(_tagged_call(client, scoped_key, budgeted_tag)): - blocked = True - break - time.sleep(1) + first = _tagged_call(client, scoped_key, budgeted_tag) + if is_budget_block(first): + blocked = True + else: + require_successful_call(first) + blocked = False + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if is_budget_block(_tagged_call(client, scoped_key, budgeted_tag)): + blocked = True + break + time.sleep(1) assert blocked, f"tag budget for {budgeted_tag!r} never enforced" - # A request with an unbudgeted tag on the same key is unaffected. free_tag = f"e2e-free-tag-{unique_marker()}" other = _tagged_call(client, scoped_key, free_tag) assert not is_budget_block(other), ( diff --git a/tests/e2e/budgets/test_team_multi_window_budget_e2e.py b/tests/e2e/budgets/test_team_multi_window_budget_e2e.py index c58e74db965..26a55169051 100644 --- a/tests/e2e/budgets/test_team_multi_window_budget_e2e.py +++ b/tests/e2e/budgets/test_team_multi_window_budget_e2e.py @@ -41,19 +41,19 @@ def test_team_short_window_blocks_then_resets(client: BudgetClient, resources: R ], ) resources.defer(lambda: client.delete_team(team_id)) - key = client.generate_key(team_id=team_id) + key = client.generate_key(team_id=team_id, models=["claude-haiku-4-5"]) resources.defer(lambda: client.delete_key(key)) # 1. exhaust the tight window -> litellm returns budget_exceeded start = time.monotonic() blocked = False - for _ in range(20): + for _ in range(30): result = _call(client, key) if is_budget_block(result): blocked = True break require_successful_call(result) - time.sleep(2) + time.sleep(1) assert blocked, f"team {WINDOW_SECONDS}s window never enforced" # 2. the window resets at the next wall-clock-aligned boundary (up to a window diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 195badc5285..0cfbb5b0b66 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -6,6 +6,8 @@ configs: master_key: os.environ/LITELLM_MASTER_KEY database_url: os.environ/DATABASE_URL store_prompts_in_spend_logs: true + proxy_budget_rescheduler_min_time: 5 + proxy_budget_rescheduler_max_time: 10 litellm_settings: drop_params: true @@ -29,6 +31,14 @@ configs: - custom_llm_provider: openai api_key: os.environ/OPENAI_API_KEY + files_settings: + - custom_llm_provider: openai + api_key: os.environ/OPENAI_API_KEY + - custom_llm_provider: azure + api_base: os.environ/AZURE_API_BASE + api_key: os.environ/AZURE_API_KEY + api_version: "2024-05-01-preview" + model_list: - model_name: gpt-5.5 litellm_params: @@ -64,6 +74,19 @@ services: DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm UI_USERNAME: admin UI_PASSWORD: sk-1234 + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-${AWS_BATCH_S3_BUCKET:-}} + AWS_BATCH_S3_BUCKET: ${AWS_BATCH_S3_BUCKET:-${AWS_S3_BUCKET_NAME:-}} + AWS_BATCH_ROLE_ARN: ${AWS_BATCH_ROLE_ARN:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_REGION: ${AWS_REGION:-us-east-1} + GCS_BUCKET_NAME: ${GCS_BUCKET_NAME:-} + VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-} + VERTEXAI_CREDENTIALS: ${VERTEXAI_CREDENTIALS:-} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-} + MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} + AZURE_API_BASE: ${AZURE_API_BASE:-} + AZURE_API_KEY: ${AZURE_API_KEY:-} ports: - "4000:4000" configs: diff --git a/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md b/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md index ff8b3441d86..ffec7176f10 100644 --- a/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md +++ b/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md @@ -43,7 +43,7 @@ at call time. The provider table below is the source of truth; edit `PROVIDERS` | gemini | `gemini-realtime` | `gemini/gemini-3.1-flash-live-preview` | | vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025` | -Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy but +Bedrock and xai (`xai/grok-4-1-fast`) are supported by the proxy but kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by uncommenting their entry. diff --git a/tests/e2e/llm_translation/realtime/realtime_client.py b/tests/e2e/llm_translation/realtime/realtime_client.py index ef7834d6bbe..51052abe227 100644 --- a/tests/e2e/llm_translation/realtime/realtime_client.py +++ b/tests/e2e/llm_translation/realtime/realtime_client.py @@ -95,7 +95,7 @@ PROVIDERS = ( # "xai", # "xai-realtime", # LiteLLMParamsBody( - # model="xai/grok-4-1-fast-non-reasoning", + # model="xai/grok-4-1-fast", # api_key="os.environ/XAI_API_KEY", # ), # ), # TODO: Enable once xai Grok Voice realtime is passing end-to-end here diff --git a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py index 31c038b4e02..0d290f6f64b 100644 --- a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py +++ b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py @@ -38,6 +38,13 @@ pytestmark = pytest.mark.e2e pytest.importorskip("pipecat", reason="pipecat-ai not installed") +try: + import nltk + + nltk.data.find("tokenizers/punkt_tab") +except LookupError: + pytest.skip("NLTK punkt_tab data is not installed", allow_module_level=True) + from pipecat.adapters.schemas.function_schema import FunctionSchema # noqa: E402 from pipecat.adapters.schemas.tools_schema import ToolsSchema # noqa: E402 from pipecat.frames.frames import ( # noqa: E402 @@ -96,7 +103,7 @@ SERVER_VAD_SETTINGS = rt_events.SessionProperties( noise_reduction=rt_events.InputAudioNoiseReduction(type="near_field"), turn_detection=rt_events.TurnDetection( type="server_vad", - threshold=0.8, + threshold=0.5, prefix_padding_ms=300, silence_duration_ms=700, ), diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py new file mode 100644 index 00000000000..fda05cdc950 --- /dev/null +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -0,0 +1,163 @@ +"""Live e2e: provider-specific /chat/completions features take real effect. + +Each case asserts the feature actually happened, not just a 200. Coverage matrix +(register-on-demand deployments, deleted on teardown): + +- Bedrock (anthropic claude-haiku-4-5): prompt caching. A large cacheable prefix + marked with ``cache_control`` is sent twice; the second call must report + cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS + Bedrock does not expose an OpenAI-style request service tier, so that cell is + intentionally not covered here. +- Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context + caching; the second identical call must report cached prompt tokens > 0. + +service_tier lives in test_provider_features_e2e.py. + +The provider-native cache_control request shape is not expressible with the +shared ``ChatBody`` (whose content is a plain string), so the cacheable body is +modelled locally with typed content blocks. +""" + +from __future__ import annotations + +import time + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import Result, unwrap +from lifecycle import ResourceManager +from models import ChatResponse, LiteLLMParamsBody, Usage +from passthrough_client import PassthroughClient +import os + +pytestmark = pytest.mark.e2e + +BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" +VERTEX_MODEL = "vertex_ai/gemini-2.5-flash" + + +class CacheControl(BaseModel): + type: str = "ephemeral" + + +class TextBlock(BaseModel): + type: str = "text" + text: str + cache_control: CacheControl | None = None + + +class RichMessage(BaseModel): + role: str + content: list[TextBlock] + + +class CacheChatBody(BaseModel): + model: str + messages: list[RichMessage] + max_tokens: int = 64 + cache: dict[str, bool] = {"no-cache": True} + + +def _cacheable_prefix() -> str: + """A prefix long enough to clear provider minimum cacheable sizes (Haiku is + 2048 tokens), unique per run so the first call writes and the second reads.""" + marker = unique_marker() + body = " ".join( + f"Cacheable reference paragraph {index} for run {marker}." for index in range(600) + ) + return f"{body}\nEnd of reference material {marker}." + + +def _cached_read_tokens(usage: Usage | None) -> int: + """Cache-read tokens however the provider reports them: Anthropic-style + ``cache_read_input_tokens`` or OpenAI-style ``prompt_tokens_details.cached_tokens``.""" + if usage is None: + return 0 + if usage.cache_read_input_tokens: + return usage.cache_read_input_tokens + if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens: + return usage.prompt_tokens_details.cached_tokens + return 0 + + +def _cache_chat( + client: PassthroughClient, key: str, model: str, prefix: str +) -> Result[ChatResponse]: + body = CacheChatBody( + model=model, + messages=[ + RichMessage( + role="system", + content=[TextBlock(text=prefix, cache_control=CacheControl())], + ), + RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]), + ], + ) + return client.gateway.transport.post( + "/chat/completions", + headers=client.gateway.transport.bearer(key), + json=body, + response_type=ChatResponse, + ) + + +def _assert_cache_read_on_second_call( + client: PassthroughClient, key: str, model: str +) -> None: + prefix = _cacheable_prefix() + + first = unwrap(_cache_chat(client, key, model, prefix)) + assert first.choices, f"{model}: first cache-priming call returned no choices: {first}" + + read_tokens = 0 + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + second = unwrap(_cache_chat(client, key, model, prefix)) + read_tokens = _cached_read_tokens(second.usage) + if read_tokens > 0: + break + time.sleep(3.0) + + assert read_tokens > 0, ( + f"{model}: second identical call reported no cache-read tokens " + f"({second.usage}); prompt caching did not take effect" + ) + + +class TestCacheControl: + @pytest.mark.covers( + "llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_bedrock_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-bedrock-cache-{unique_marker()}" + model_id = client.gateway.create_model( + model, + LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"), + ) + resources.defer(lambda: client.gateway.delete_model(model_id)) + _assert_cache_read_on_second_call(client, resources.key(), model) + + @pytest.mark.covers( + "llm.chat_completions.vertex.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_vertex_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-vertex-cache-{unique_marker()}" + model_id = client.gateway.create_model( + model, + LiteLLMParamsBody( + model=VERTEX_MODEL, + vertex_project=os.environ.get("VERTEXAI_PROJECT"), + vertex_location="us-central1", + vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"), + ), + ) + resources.defer(lambda: client.gateway.delete_model(model_id)) + _assert_cache_read_on_second_call(client, resources.key(), model) diff --git a/tests/e2e/llm_translation/test_provider_features_e2e.py b/tests/e2e/llm_translation/test_provider_features_e2e.py index d272fffa9b8..822a1d8d4d5 100644 --- a/tests/e2e/llm_translation/test_provider_features_e2e.py +++ b/tests/e2e/llm_translation/test_provider_features_e2e.py @@ -1,4 +1,4 @@ -"""Live e2e for model-specific request features: service_tier and prompt caching. +"""Live e2e for model-specific request features: service_tier. Each case asserts the feature took effect, not just a 200. @@ -11,81 +11,22 @@ avoided here because it is capacity-constrained and returns a transient 429 when flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so that cell is OpenAI-only by design. -Prompt caching is asserted through provider prompt-cache usage tokens. The -deterministic path is explicit ``cache_control`` on an Anthropic-family model -(here Bedrock's Claude): a large cacheable prefix is sent twice and the second -call must report ``cache_read_input_tokens > 0``. OpenAI and Gemini only offer -implicit automatic caching, which does not deterministically produce a cache read -within a test window (verified: repeated >3k-token prompts kept -``prompt_tokens_details.cached_tokens`` at 0), so those caching cells are out of -scope here and covered only by the explicit-cache-control Bedrock case. +Prompt caching lives in test_cache_control.py. """ from __future__ import annotations import pytest -from pydantic import BaseModel, ConfigDict, Field from e2e_config import unique_marker from e2e_http import unwrap from lifecycle import ResourceManager -from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody +from models import ChatBody, ChatMessage, LiteLLMParamsBody from passthrough_client import PassthroughClient pytestmark = pytest.mark.e2e SERVICE_TIER = "priority" -CACHE_MIN_READ_TOKENS = 1 - - -class CacheControl(BaseModel): - type: str = "ephemeral" - - -class CacheTextBlock(BaseModel): - type: str = "text" - text: str - cache_control: CacheControl | None = None - - -class RichMessage(BaseModel): - role: str - content: list[CacheTextBlock] - - -class CacheDirective(BaseModel): - """litellm per-request cache control. ``no-cache`` forces the proxy to skip its - own response cache and make a fresh provider call, so the second identical - request actually reaches Bedrock and reads the provider prompt cache instead of - being served the first response verbatim (which would report cache_read=0).""" - - model_config = ConfigDict(populate_by_name=True) - no_cache: bool = Field(default=True, alias="no-cache") - - -class CacheChatBody(BaseModel): - model: str - messages: list[RichMessage] - max_tokens: int - cache: CacheDirective = CacheDirective() - - -def cacheable_prefix() -> str: - return ( - "You are a policy compliance auditor. The following corpus is the immutable " - "reference the assistant must consult on every turn. " - ) + ("Clause: obey all safety, formatting, and citation rules exactly. " * 400) - - -def post_chat(client: PassthroughClient, key: str, body: BaseModel) -> ChatResponse: - return unwrap( - client.gateway.transport.post( - "/chat/completions", - headers=client.gateway.transport.bearer(key), - json=body, - response_type=ChatResponse, - ) - ) class TestServiceTier: @@ -120,50 +61,3 @@ class TestServiceTier: f"service_tier not honored: sent {SERVICE_TIER!r}, response reported " f"{response.service_tier!r} ({response})" ) - - -class TestPromptCaching: - @pytest.mark.covers( - "llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works", - exercised_on=[], - ) - def test_bedrock_cache_control_produces_cache_read( - self, client: PassthroughClient, resources: ResourceManager - ) -> None: - model = f"e2e-bedrock-cache-{unique_marker()}" - model_id = client.gateway.create_model( - model, - LiteLLMParamsBody( - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - aws_region_name="us-east-1", - ), - ) - resources.defer(lambda: client.gateway.delete_model(model_id)) - key = resources.key() - - body = CacheChatBody( - model=model, - max_tokens=32, - messages=[ - RichMessage( - role="user", - content=[ - CacheTextBlock( - text=cacheable_prefix(), cache_control=CacheControl() - ), - CacheTextBlock(text="Answer in one word: acknowledged?"), - ], - ) - ], - ) - - first = post_chat(client, key, body) - assert first.usage is not None, f"first call reported no usage: {first}" - - second = post_chat(client, key, body) - assert second.usage is not None, f"second call reported no usage: {second}" - cache_read = second.usage.cache_read_input_tokens - assert cache_read is not None and cache_read >= CACHE_MIN_READ_TOKENS, ( - "second identical request did not read the prompt cache: " - f"cache_read_input_tokens={cache_read!r} (usage={second.usage})" - ) diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index 5520b44993d..bc60ce87c98 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -6,10 +6,11 @@ llm-only key hitting a management route). from __future__ import annotations +import time from dataclasses import dataclass from e2e_gateway import Gateway, build_gateway -from e2e_http import NoBody, ProbeResult, StreamingResponse, unwrap +from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, UnknownApiError, unwrap from models import ( ChatBody, ChatMessage, @@ -43,6 +44,8 @@ from models import ( MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied" ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" +_TEAM_READY_ATTEMPTS = 15 +_TEAM_READY_SLEEP_SECONDS = 0.4 @dataclass(frozen=True, slots=True) @@ -53,14 +56,26 @@ class ManagementClient: return self.gateway.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])) def update_key_models(self, key: str, models: list[str]) -> None: - _ = unwrap( - self.gateway.transport.post( + last: Result[NoBody] | None = None + for attempt in range(5): + last = self.gateway.transport.post( "/key/update", headers=self.gateway.transport.master, json=KeyUpdateBody(key=key, models=models), response_type=NoBody, ) - ) + match last: + case Success(): + return + case UnknownApiError(body=body) if ( + "connecting to redis" in body.lower() or "name resolution" in body.lower() + ): + time.sleep(0.5 * (attempt + 1)) + continue + case _: + break + assert last is not None + _ = unwrap(last) def delete_key_strict(self, key: str) -> None: """Strict delete for the act phase of a test: a failed delete is a hard @@ -85,7 +100,7 @@ class ManagementClient: ).total_count def create_team(self, body: TeamNewBody) -> str: - return unwrap( + team_id = unwrap( self.gateway.transport.post( "/team/new", headers=self.gateway.transport.master, @@ -93,6 +108,8 @@ class ManagementClient: response_type=TeamNewResponse, ) ).team_id + self._wait_for_team(team_id) + return team_id def delete_team(self, team_id: str) -> None: _ = self.gateway.transport.post( @@ -115,15 +132,44 @@ class ManagementClient: def team_info_status(self, team_id: str) -> ProbeResult: return self.gateway.transport.probe("/team/info", params=TeamInfoParams(team_id=team_id)) + def _wait_for_team(self, team_id: str) -> None: + last: Result[TeamInfoResponse] | None = None + for _ in range(_TEAM_READY_ATTEMPTS): + last = self.gateway.transport.get( + "/team/info", + headers=self.gateway.transport.master, + params=TeamInfoParams(team_id=team_id), + response_type=TeamInfoResponse, + ) + match last: + case Success(): + return + case _: + time.sleep(_TEAM_READY_SLEEP_SECONDS) + assert last is not None + _ = unwrap(last) + def add_team_member(self, team_id: str, user_id: str) -> None: - _ = unwrap( - self.gateway.transport.post( + last: Result[NoBody] | None = None + for attempt in range(_TEAM_READY_ATTEMPTS): + last = self.gateway.transport.post( "/team/member_add", headers=self.gateway.transport.master, json=TeamMemberAddBody(team_id=team_id, member=TeamMemberEntry(role="user", user_id=user_id)), response_type=NoBody, ) - ) + match last: + case Success(): + return + case UnknownApiError(body=body) if ( + "doesn't exist" in body and attempt + 1 < _TEAM_READY_ATTEMPTS + ): + time.sleep(_TEAM_READY_SLEEP_SECONDS) + continue + case _: + break + assert last is not None + _ = unwrap(last) def delete_team_member(self, team_id: str, user_id: str) -> None: _ = unwrap( diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 38778034de9..ab2835d87c4 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -377,10 +377,13 @@ class LiteLLMParamsBody(BaseModel): api_base: str | None = None api_version: str | None = None realtime_protocol: str | None = None + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None aws_region_name: str | None = None vertex_project: str | None = None vertex_location: str | None = None vertex_credentials: str | None = None + gcs_bucket_name: str | None = None bucket_name: str | None = None s3_bucket_name: str | None = None s3_region_name: str | None = None diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py index 9cb2b0fecda..23ea89fa74d 100644 --- a/tests/proxy_behavior/management/test_team_update.py +++ b/tests/proxy_behavior/management/test_team_update.py @@ -105,27 +105,35 @@ async def test_team_update_authz_matrix( assert row.team_alias != MARKER_ALIAS, "denied but team mutated" -async def test_team_update_requires_proxy_admin_without_org_context( +async def test_team_update_org_admin_resolved_from_team_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.""" + """With no organization_id in the body the route gate resolves the target + team's org from team_id, so an org admin of the team's own org is allowed + (200), same as PROXY_ADMIN. A team admin of that same team stays denied + (401): the resolution grants org admins access, not team admins.""" await _seed_target(prisma, world, "alpha", scratch.prefix) - denied = await proxy_client.post( + allowed_org_admin = 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 + assert allowed_org_admin.status_code == 200, allowed_org_admin.text - allowed = await proxy_client.post( + allowed_proxy_admin = 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 + assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text + + denied_team_admin = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, + ) + assert denied_team_admin.status_code == 401, denied_team_admin.text # Relocation gate — moving a team to a different org. The scratch team starts diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 29e9f4529fc..d300f326b9e 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -833,6 +833,173 @@ class TestGuardrailSensitiveFieldStripping: assert "sk-secret" not in serialized +class TestGuardrailResponseCredentialMasking: + """LIT-4314 issue B regression: credentials embedded in guardrail_response + (via team callback_vars flowing through data["metadata"]) must be masked at + the construction seam so every downstream sink (SpendLogs, OTel, Langfuse, + custom loggers) sees masked values rather than plaintext. + """ + + def _make_guardrail(self): + from litellm.types.guardrails import GuardrailEventHooks + + return CustomGuardrail( + guardrail_name="test_guardrail", + event_hook=GuardrailEventHooks.pre_call, + ) + + def test_callback_vars_api_key_is_masked(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + plaintext_key = "lsv2_pt_abcdef1234567890" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hi"}], + "metadata_snapshot": { + "callback_vars": { + "langsmith_api_key": plaintext_key, + "langsmith_project": "proj-name", + } + }, + }, + request_data=request_data, + guardrail_status="success", + duration=1.0, + ) + + logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ] + + masked_key = logged["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] + assert masked_key != plaintext_key + assert "*" in masked_key + assert plaintext_key not in json.dumps(request_data) + + assert logged["model"] == "gpt-4o-mini" + assert logged["messages"] == [{"role": "user", "content": "hi"}] + assert ( + logged["metadata_snapshot"]["callback_vars"]["langsmith_project"] + == "proj-name" + ) + + def test_nested_user_api_key_auth_metadata_is_masked(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + token_value = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "evaluated_metadata": { + "user_api_key_auth": { + "token": token_value, + "api_key": token_value, + "metadata": { + "callback_vars": { + "langsmith_api_key": "lsv2_pt_super_secret_value_1234", + } + }, + } + } + }, + request_data=request_data, + guardrail_status="success", + ) + + serialized = json.dumps(request_data) + assert token_value not in serialized + assert "lsv2_pt_super_secret_value_1234" not in serialized + + def test_secret_fields_pop_still_runs(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "model": "gpt-4", + "secret_fields": { + "raw_headers": { + "authorization": "Bearer sk-live-should-not-appear", + } + }, + }, + request_data=request_data, + guardrail_status="success", + ) + + serialized = json.dumps(request_data) + assert "secret_fields" not in serialized + assert "sk-live-should-not-appear" not in serialized + + def test_match_and_regex_redaction_still_runs(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}] + }, + request_data=request_data, + guardrail_status="success", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_response"]["filters"][0]["regex"] == "[REDACTED]" + + def test_scalar_types_pass_through_unchanged(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "flagged": True, + "score": 0.94, + "tokens_used": 42, + "categories": ["pii", "toxicity"], + }, + request_data=request_data, + guardrail_status="success", + ) + + logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ] + assert logged["flagged"] is True + assert logged["score"] == 0.94 + assert logged["tokens_used"] == 42 + assert logged["categories"] == ["pii", "toxicity"] + + def test_masking_reveals_prefix_and_suffix(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + plaintext = "lsv2_pt_abcdef1234567890" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "metadata_snapshot": { + "callback_vars": {"langsmith_api_key": plaintext} + } + }, + request_data=request_data, + guardrail_status="success", + ) + + masked = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ]["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] + assert masked != plaintext + assert masked.startswith(plaintext[:4]) + assert masked.endswith(plaintext[-4:]) + + class TestCustomGuardrailPassthroughSupport: """Tests for passthrough endpoint guardrail support - Issue fixes.""" diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index dcc359ceff4..ca23e61352e 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -562,6 +562,49 @@ def test_generic_cost_per_token_gpt56( assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10) +@pytest.mark.parametrize( + "model,input_cost,output_cost,cache_read_cost", + [ + ("azure/gpt-5.6", 5e-6, 3e-5, 5e-7), + ("azure/gpt-5.6-sol", 5e-6, 3e-5, 5e-7), + ("azure/gpt-5.6-terra", 2.5e-6, 1.5e-5, 2.5e-7), + ("azure/gpt-5.6-luna", 1e-6, 6e-6, 1e-7), + ("azure/us/gpt-5.6", 5.5e-6, 3.3e-5, 5.5e-7), + ("azure/eu/gpt-5.6-terra", 2.75e-6, 1.65e-5, 2.75e-7), + ("azure/eu/gpt-5.6-luna", 1.1e-6, 6.6e-6, 1.1e-7), + ], +) +def test_generic_cost_per_token_azure_gpt56( + model, input_cost, output_cost, cache_read_cost +): + """Azure gpt-5.6 (global + us/eu regional): pricing mirrors the openai + family for global deployments and carries the standard 10% regional uplift. + """ + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_cost_map = litellm.model_cost[model] + assert model_cost_map["litellm_provider"] == "azure" + assert model_cost_map["input_cost_per_token"] == input_cost + assert model_cost_map["output_cost_per_token"] == output_cost + assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost + + prompt_tokens = 1000 + completion_tokens = 500 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="azure", + ) + assert round(prompt_cost, 10) == round(input_cost * prompt_tokens, 10) + assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10) + + @pytest.mark.parametrize( "model,expected_none,expected_xhigh,expected_minimal", [ diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 9b0116e6979..ba8540f81e3 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -240,3 +240,78 @@ def test_mask_sensitive_structure_masks_credentials_nested_in_config_shape(): [{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}] ) assert secret not in str(masked) + + +def test_mask_credentials_in_payload_preserves_none_and_scalars(): + """The payload variant does not distort JSON-shaped values: None stays None, + ints/floats/bools stay themselves, lists stay lists. This is what makes it + safe for logging pipelines that persist the record verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + result = mask_credentials_in_payload( + { + "reason": None, + "confidence": 0.42, + "flagged": True, + "tokens_used": 17, + "categories": ["pii", "toxicity"], + "nested": {"end_user_id": None}, + } + ) + assert result == { + "reason": None, + "confidence": 0.42, + "flagged": True, + "tokens_used": 17, + "categories": ["pii", "toxicity"], + "nested": {"end_user_id": None}, + } + + +def test_mask_credentials_in_payload_masks_inside_pydantic_models(): + """A Pydantic model reached during the walk gets dumped to a dict so its + sensitive-named string fields are masked. Without this the credentials + inside a nested ``UserAPIKeyAuth`` in a guardrail_response reach the + logging pipeline unmasked once JSON serialization flattens it.""" + from pydantic import BaseModel + + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + class Auth(BaseModel): + token: str = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + team_alias: str = "acme" + + result = mask_credentials_in_payload({"user_api_key_auth": Auth()}) + auth_dict = result["user_api_key_auth"] + assert isinstance(auth_dict, dict) + assert auth_dict["team_alias"] == "acme" + assert ( + auth_dict["token"] + != "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + ) + assert "*" in auth_dict["token"] + + +def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves(): + """Sensitive-named string leaves get masked; sibling non-string values + (including None) under the same key stay verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + plaintext = "lsv2_pt_abcdef1234567890" + result = mask_credentials_in_payload( + { + "model": "gpt-4o-mini", + "callback_vars": { + "langsmith_api_key": plaintext, + "langsmith_project": "proj", + "extra_token_count": 5, + }, + } + ) + assert result["model"] == "gpt-4o-mini" + assert result["callback_vars"]["langsmith_project"] == "proj" + assert result["callback_vars"]["extra_token_count"] == 5 + masked = result["callback_vars"]["langsmith_api_key"] + assert masked != plaintext + assert masked.startswith(plaintext[:4]) + assert masked.endswith(plaintext[-4:]) diff --git a/tests/test_litellm/llms/openai_like/test_meta_provider.py b/tests/test_litellm/llms/openai_like/test_meta_provider.py new file mode 100644 index 00000000000..11b78828da6 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_meta_provider.py @@ -0,0 +1,224 @@ +""" +Tests for the Meta Model API (Muse Spark) provider configuration and integration. +""" + +import litellm + + +class TestMetaProviderConfig: + def test_meta_in_provider_list(self): + from litellm import LlmProviders + + assert hasattr(LlmProviders, "META") + assert LlmProviders.META.value == "meta" + assert "meta" in litellm.provider_list + + def test_meta_json_config_exists(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.exists("meta") + + meta = JSONProviderRegistry.get("meta") + assert meta is not None + assert meta.base_url == "https://api.meta.ai/v1" + assert meta.api_key_env == "META_API_KEY" + assert meta.api_base_env == "META_API_BASE" + + def test_meta_supports_responses_api(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.supports_responses_api("meta") + + def test_meta_in_openai_compatible_providers(self): + from litellm.constants import openai_compatible_providers + + assert "meta" in openai_compatible_providers + + def test_meta_provider_resolution(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="meta/muse-spark-1.1", + custom_llm_provider=None, + api_base=None, + api_key="sk-test", + ) + + assert model == "muse-spark-1.1" + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + def test_meta_api_base_override(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="meta/muse-spark-1.1", + custom_llm_provider=None, + api_base="https://custom.meta.ai/v1", + api_key="sk-test", + ) + + assert provider == "meta" + assert api_base == "https://custom.meta.ai/v1" + assert api_key == "sk-test" + + def test_meta_url_autodetection(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="muse-spark-1.1", + custom_llm_provider=None, + api_base="https://api.meta.ai/v1", + api_key=None, + ) + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + def test_meta_router_config(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "muse-spark", + "litellm_params": { + "model": "meta/muse-spark-1.1", + "api_key": "test-key", + }, + } + ] + ) + + assert len(router.model_list) == 1 + assert router.model_list[0]["model_name"] == "muse-spark" + + +class TestMetaReasoningParams: + def test_muse_spark_supports_reasoning_effort(self): + params = litellm.get_supported_openai_params( + model="muse-spark-1.1", custom_llm_provider="meta" + ) + assert params is not None + assert "reasoning_effort" in params + + def test_reasoning_effort_mapped_through(self): + cfg = litellm.ProviderConfigManager.get_provider_chat_config( + model="muse-spark-1.1", provider=litellm.LlmProviders.META + ) + assert cfg is not None + mapped = cfg.map_openai_params( + non_default_params={"reasoning_effort": "xhigh"}, + optional_params={}, + model="muse-spark-1.1", + drop_params=False, + ) + assert mapped["reasoning_effort"] == "xhigh" + + def test_reasoning_effort_gated_on_capability(self): + """A meta model without reasoning metadata must not advertise reasoning_effort.""" + params = litellm.get_supported_openai_params( + model="some-non-reasoning-model", custom_llm_provider="meta" + ) + assert params is not None + assert "reasoning_effort" not in params + + +class TestMetaAnthropicMessages: + def test_meta_resolves_native_messages_config(self): + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config( + model="muse-spark-1.1", provider=litellm.LlmProviders.META + ) + assert isinstance(cfg, JSONProviderAnthropicMessagesConfig) + + def test_json_provider_without_messages_endpoint_resolves_none(self): + cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config( + model="some-model", provider=litellm.LlmProviders.PINSTRIPES + ) + assert cfg is None + + def test_complete_url_defaults_to_meta_base(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + provider = JSONProviderRegistry.get("meta") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + url = cfg.get_complete_url( + api_base=None, + api_key="sk-test", + model="muse-spark-1.1", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.meta.ai/v1/messages" + + override_url = cfg.get_complete_url( + api_base="https://custom.meta.ai/v1", + api_key="sk-test", + model="muse-spark-1.1", + optional_params={}, + litellm_params={}, + ) + assert override_url == "https://custom.meta.ai/v1/messages" + + def test_api_key_resolved_from_env(self, monkeypatch): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + monkeypatch.setenv("META_API_KEY", "sk-env-key") + provider = JSONProviderRegistry.get("meta") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + headers, _ = cfg.validate_anthropic_messages_environment( + headers={}, + model="muse-spark-1.1", + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + assert headers["authorization"] == "Bearer sk-env-key" + assert headers["anthropic-version"] == "2023-06-01" + + +class TestMuseSparkModelInfo: + def test_muse_spark_pricing_and_capabilities(self): + info = litellm.get_model_info("meta/muse-spark-1.1") + + assert info["litellm_provider"] == "meta" + assert info["input_cost_per_token"] == 1.25e-06 + assert info["output_cost_per_token"] == 4.25e-06 + assert info["cache_read_input_token_cost"] == 1.5e-07 + assert info["max_input_tokens"] == 1048576 + assert info["supports_reasoning"] is True + assert info["supports_web_search"] is True + assert info["supports_vision"] is True + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + + def test_muse_spark_cost_calculation(self): + from litellm import completion_cost + from litellm.types.utils import ModelResponse, Usage + + response = ModelResponse( + model="muse-spark-1.1", + usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500), + ) + cost = completion_cost( + completion_response=response, + model="meta/muse-spark-1.1", + custom_llm_provider="meta", + ) + expected = 1000 * 1.25e-06 + 500 * 4.25e-06 + assert abs(cost - expected) < 1e-12 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 64226eea821..c88027abcd4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -149,6 +149,29 @@ async def test_authorization_code_isolates_by_subject(): assert isinstance(bob, Error) and bob.error.tag == "unauthorized" +@pytest.mark.asyncio +async def test_authorization_code_isolates_by_server_id_even_when_servers_share_a_url(): + """A token stored for one server must be invisible to a different server_id pointing at the + same upstream URL: credentials bind to the server entry they were authorized for, so a + recreated or duplicated server starts unauthorized instead of inheriting the old grant. Guards + against any future token lookup keyed on the resource URL instead of (user_id, server_id) -- + both the egress resolve and the has_user_token discovery check must agree.""" + shared_url = "https://upstream.example.com" + store = _FakeTokenStore({("alice", "server-a"): OAuthToken(access_token="at-alice")}) + provider = UpstreamCredentialProvider(oauth_token_store=store) + subject = Subject(tenant_id="", subject_id="alice") + spec_a = ServerSpec(server_id="server-a", resource=shared_url, config=AuthorizationCodeConfig()) + spec_b = ServerSpec(server_id="server-b", resource=shared_url, config=AuthorizationCodeConfig()) + + granted = await provider.resolve_credentials(subject, spec_a) + fresh = await provider.resolve_credentials(subject, spec_b) + + assert isinstance(granted, Ok) and _emitted(granted.ok)["Authorization"] == "Bearer at-alice" + assert isinstance(fresh, Error) and fresh.error.tag == "unauthorized" + assert await provider.has_user_token(subject, spec_a) is True + assert await provider.has_user_token(subject, spec_b) is False + + @pytest.mark.asyncio async def test_has_user_token_reflects_the_stored_token(): present = UpstreamCredentialProvider( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index 8b8b8a363d5..7269774442b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work. import base64 import json from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -63,6 +64,264 @@ def _legacy_row(payload: str): return row +def _identity_server(**overrides): + base = dict( + url="https://up.example.com/mcp", + auth_type="oauth2", + oauth2_flow="authorization_code", + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]}, + server_name="srv", + description="d", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.mark.parametrize( + "overrides", + [ + {"url": "https://other.example.com/mcp"}, + {"spec_path": "https://up.example.com/openapi.json"}, + {"auth_type": "oauth_delegate"}, + {"oauth2_flow": "client_credentials"}, + {"authorization_url": "https://other.example.com/authorize"}, + {"token_url": "https://other.example.com/token"}, + {"registration_url": "https://other.example.com/register"}, + {"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}}, + ], +) +def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides)) + + +@pytest.mark.parametrize( + "overrides", + [ + {"server_name": "renamed"}, + {"description": "changed"}, + ], +) +def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides)) + + +def _encrypted_creds_json(client_id: str = "cid", client_secret: str = "csec") -> str: + from litellm.proxy._experimental.mcp_server.db import encrypt_credentials + + encrypted = encrypt_credentials( + credentials={"client_id": client_id, "client_secret": client_secret, "scopes": ["a"]}, + encryption_key=None, + ) + return json.dumps(encrypted) + + +def test_mcp_oauth_token_identity_stable_across_reencryption(): + """Stored client_id/client_secret are NaCl-encrypted with a fresh nonce on every write, so two + saves of the SAME plaintext produce different ciphertext. The identity must compare decrypted + values; comparing ciphertext would flag every routine save as a mint-relevant change and purge + per-user tokens that are still valid.""" + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + first = _encrypted_creds_json() + second = _encrypted_creds_json() + assert first != second + + assert mcp_oauth_token_identity(_identity_server(credentials=first)) == mcp_oauth_token_identity( + _identity_server(credentials=second) + ) + + +def test_mcp_oauth_token_identity_detects_change_under_encryption(): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + unchanged = _identity_server(credentials=_encrypted_creds_json()) + changed = _identity_server(credentials=_encrypted_creds_json(client_id="other")) + assert mcp_oauth_token_identity(unchanged) != mcp_oauth_token_identity(changed) + + +def _oauth_row(user_id: str, server_id: str = "srv-1"): + """A stored per-user OAuth token row (payload tagged type=oauth2, legacy plain-base64 encoding).""" + row = _legacy_row(json.dumps({"type": "oauth2", "access_token": "tok-" + user_id})) + row.user_id = user_id + row.server_id = server_id + return row + + +def _byok_row(user_id: str, server_id: str = "srv-1"): + """A stored BYOK API key row: the same column, but the payload is a plain string, not OAuth JSON.""" + row = _legacy_row("sk-byok-" + user_id) + row.user_id = user_id + row.server_id = server_id + return row + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_invalidates_each_user(): + """The purge must route each (user, server) row through the invalidator exactly once.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert purged == 2 + prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with( + where={"server_id": "srv-1", "user_id": {"in": ["alice", "bob"]}} + ) + assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")} + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_spares_byok_rows(): + """Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share + the LiteLLM_MCPUserCredentials table. Only rows holding an OAuth2 payload may be deleted (one + batched query filtered to their user_ids), and only their users' token caches invalidated.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert purged == 1 + prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with( + where={"server_id": "srv-1", "user_id": {"in": ["alice"]}} + ) + assert invalidations == [("alice", "srv-1")] + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_all_byok_is_noop(): + """An api_key (BYOK-only) server whose identity tuple changes (e.g. its url) must purge nothing.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _byok_row("dave")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 0 + prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_defaults_to_manager_invalidator(monkeypatch): + """When no invalidator is injected, the purge must resolve to the manager's shared + invalidate_user_oauth_token_cache, the single point covering both the legacy per-user token cache + and the v2 per-user OAuth token store; a wrong or no-op default silently leaves every cache + serving tokens minted for the superseded config.""" + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1) + + shared_invalidator = AsyncMock() + monkeypatch.setattr( + mcp_server_manager.global_mcp_server_manager, + "invalidate_user_oauth_token_cache", + shared_invalidator, + ) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 1 + shared_invalidator.assert_awaited_once_with("alice", "srv-1") + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_logs_raced_rows(monkeypatch): + from litellm.proxy._experimental.mcp_server import db as db_module + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=0) + warning = MagicMock() + monkeypatch.setattr(db_module.verbose_proxy_logger, "warning", warning) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=AsyncMock()) + + assert purged == 0 + warning.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_mcp_server_invalidates_cached_tokens_for_enumerated_users(): + """Deleting a server must invalidate each enumerated user's cached per-user token: the caches are + keyed by (user_id, server_id), so a re-created server reusing the same server_id would otherwise + serve tokens minted for the deleted server until TTL.""" + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=MagicMock(server_id="srv-1")) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _byok_row("bob")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2) + prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock(return_value=0) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert deleted is not None + assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")} + + +@pytest.mark.asyncio +async def test_delete_mcp_server_returns_none_without_cleanup_when_server_missing(): + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock() + + deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=AsyncMock()) + + assert deleted is None + prisma.db.litellm_mcpusercredentials.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_noop_when_empty(): + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 0 + prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited() + + def _stored_value(prisma) -> str: """Pull the credential_b64 value passed to the most recent upsert call.""" call = prisma.db.litellm_mcpusercredentials.upsert.call_args @@ -136,9 +395,7 @@ async def test_store_user_oauth_credential_does_not_persist_plaintext(): access_token = "ya29.a0AfH6SMBverysecretaccesstoken" prisma = _make_prisma_with_existing(row=None) - await store_user_oauth_credential( - prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz" - ) + await store_user_oauth_credential(prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz") stored = _stored_value(prisma) try: @@ -221,9 +478,7 @@ async def test_byok_guard_rejects_overwriting_encrypted_byok(): encrypted_row = MagicMock() encrypted_row.credential_b64 = _stored_value(prisma) - prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock( - return_value=encrypted_row - ) + prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=encrypted_row) with pytest.raises(ValueError, match="could not be verified as an OAuth2"): await store_user_oauth_credential(prisma, "alice", "srv-1", "tok") @@ -265,18 +520,14 @@ async def test_list_oauth_credentials_filters_byok_and_returns_payloads(): "connected_at": "2024-01-01T00:00:00Z", } legacy_row = MagicMock() - legacy_row.credential_b64 = base64.urlsafe_b64encode( - json.dumps(legacy_payload).encode() - ).decode() + legacy_row.credential_b64 = base64.urlsafe_b64encode(json.dumps(legacy_payload).encode()).decode() legacy_row.server_id = "srv-legacy" byok_row = MagicMock() byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode() byok_row.server_id = "srv-byok" - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[encrypted_row, legacy_row, byok_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[encrypted_row, legacy_row, byok_row]) results = await list_user_oauth_credentials(prisma, "alice") @@ -326,9 +577,7 @@ async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch): prisma.db.litellm_mcpusercredentials.update = AsyncMock() new_master_key = "rotated-salt-key-9999-9999-9999-9999" - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key=new_master_key - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_master_key) update_call = prisma.db.litellm_mcpusercredentials.update.call_args new_stored = update_call.kwargs["data"]["credential_b64"] @@ -356,19 +605,13 @@ async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch): legacy_row.user_id = "alice" legacy_row.server_id = "srv-legacy" legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode() - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[legacy_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[legacy_row]) prisma.db.litellm_mcpusercredentials.update = AsyncMock() new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd" - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key=new_key - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_key) - new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"][ - "credential_b64" - ] + new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"]["credential_b64"] monkeypatch.setenv("LITELLM_SALT_KEY", new_key) assert ( decrypt_value_helper( @@ -396,14 +639,10 @@ async def test_rotate_skips_undecodable_rows(): good_row.server_id = "srv-ok" good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode() - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[bad_row, good_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[bad_row, good_row]) prisma.db.litellm_mcpusercredentials.update = AsyncMock() - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key="new-key-xxxx" - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key="new-key-xxxx") # Only one update call — the good row. assert prisma.db.litellm_mcpusercredentials.update.call_count == 1 @@ -419,9 +658,7 @@ def _oauth_cred(access_token="at-live", refresh_token=None, expires_in_seconds=N if refresh_token is not None: cred["refresh_token"] = refresh_token if expires_in_seconds is not None: - cred["expires_at"] = ( - datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds) - ).isoformat() + cred["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat() return cred @@ -438,12 +675,7 @@ def test_expiry_buffer_treats_soon_to_expire_as_expired(): cred = _oauth_cred(expires_in_seconds=30) assert is_oauth_credential_expired(cred, buffer_seconds=60) is True # A token comfortably beyond the buffer stays valid. - assert ( - is_oauth_credential_expired( - _oauth_cred(expires_in_seconds=600), buffer_seconds=60 - ) - is False - ) + assert is_oauth_credential_expired(_oauth_cred(expires_in_seconds=600), buffer_seconds=60) is False def test_expiry_past_is_expired_regardless_of_buffer(): @@ -465,9 +697,7 @@ async def test_resolve_returns_valid_token_without_refreshing(monkeypatch): refresh = AsyncMock() monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - cred = _oauth_cred( - access_token="at-live", refresh_token="rt-1", expires_in_seconds=600 - ) + cred = _oauth_cred(access_token="at-live", refresh_token="rt-1", expires_in_seconds=600) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=cred, prisma_client=MagicMock() ) @@ -483,15 +713,11 @@ async def test_resolve_refreshes_expired_token_with_refresh_token(monkeypatch): # new token rather than returning None (which left the UI tool list empty). import litellm.proxy._experimental.mcp_server.db as db_mod - refreshed = _oauth_cred( - access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600 - ) + refreshed = _oauth_cred(access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600) refresh = AsyncMock(return_value=refreshed) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - expired = _oauth_cred( - access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5 - ) + expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock() ) @@ -510,9 +736,7 @@ async def test_resolve_refreshes_token_expiring_within_buffer(monkeypatch): refresh = AsyncMock(return_value=refreshed) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - soon = _oauth_cred( - access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30 - ) + soon = _oauth_cred(access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=soon, prisma_client=MagicMock() ) @@ -546,9 +770,7 @@ async def test_resolve_returns_none_when_refresh_fails(monkeypatch): refresh = AsyncMock(return_value=None) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - expired = _oauth_cred( - access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5 - ) + expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock() ) @@ -565,9 +787,7 @@ async def test_resolve_returns_none_for_missing_credential(monkeypatch): monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) assert ( - await resolve_valid_user_oauth_token( - user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock() - ) + await resolve_valid_user_oauth_token(user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock()) is None ) assert ( @@ -601,19 +821,13 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch): encrypted_old = encrypt_value_helper(json.dumps(values)) prisma = MagicMock() - prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock( - return_value=[_env_var_row(encrypted_old)] - ) + prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[_env_var_row(encrypted_old)]) prisma.db.litellm_mcpuserenvvars.update = AsyncMock() new_master_key = "rotated-env-key-1111-2222-3333-4444" - await rotate_mcp_user_env_vars_master_key( - prisma_client=prisma, new_master_key=new_master_key - ) + await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key=new_master_key) - new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"][ - "values_b64" - ] + new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"]["values_b64"] assert new_stored != encrypted_old, "rotation must produce different ciphertext" monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key) @@ -630,18 +844,14 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch): async def test_rotate_user_env_vars_skips_undecryptable_rows(): # A corrupt row must be skipped (not overwritten) so recoverable data is # preserved and one bad row does not abort the rest of the rotation. - good = _env_var_row( - encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok" - ) + good = _env_var_row(encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok") bad = _env_var_row("!!! not encrypted !!!", server_id="srv-corrupt") prisma = MagicMock() prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[bad, good]) prisma.db.litellm_mcpuserenvvars.update = AsyncMock() - await rotate_mcp_user_env_vars_master_key( - prisma_client=prisma, new_master_key="new-key-xxxx" - ) + await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key="new-key-xxxx") assert prisma.db.litellm_mcpuserenvvars.update.call_count == 1 where = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["where"] @@ -669,9 +879,7 @@ async def test_refresh_user_oauth_token_uses_client_secret_basic(monkeypatch): monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client) monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock()) - monkeypatch.setattr( - db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}) - ) + monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})) result = await db_mod.refresh_user_oauth_token( prisma_client=MagicMock(), @@ -710,9 +918,7 @@ async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypat monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client) monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock()) - monkeypatch.setattr( - db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}) - ) + monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})) await db_mod.refresh_user_oauth_token( prisma_client=MagicMock(), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 9de342eabd7..c8e871b3bc1 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -665,6 +665,178 @@ async def test_register_client_persists_dcr_client_identity(): mock_update_server.assert_called_once() +async def _register_persistence_attempted_for_auth_type(auth_type: MCPAuth) -> bool: + """Run register_client_with_server with persist_credentials=True for a server of ``auth_type`` + and report whether the DCR result was persisted onto the server row. The client-forwarded token + modes must skip the persist even on the admin path: writing it stamps oauth2_flow and a + client_id onto a server whose contract is that the gateway stores nothing, which makes a fresh + pass-through server read as gateway-authorized. The upstream registration must still be relayed + to the browser either way, since the caller needs the minted client to run its own flow.""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="pt_server", + name="pt_server", + server_name="pt_server", + alias="pt_server", + transport=MCPTransport.http, + auth_type=auth_type, + client_id=None, + client_secret=None, + authorization_url="https://provider.example/oauth/authorize", + token_url="https://provider.example/oauth/token", + registration_url="https://provider.example/oauth/register", + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://proxy.litellm.example/" + mock_request.headers = {} + + mock_response = MagicMock() + mock_response.json.return_value = { + "client_id": "generated-client", + "client_secret": "generated-secret", + "token_endpoint_auth_method": "none", + } + mock_response.raise_for_status = MagicMock() + mock_async_client = MagicMock() + mock_async_client.post = AsyncMock(return_value=mock_response) + + mock_update = AsyncMock(return_value=MagicMock()) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=mock_async_client, + ), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + ): + response = await register_client_with_server( + request=mock_request, + mcp_server=server, + client_name="Litellm Proxy", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + persist_credentials=True, + ) + + assert json.loads(response.body.decode("utf-8")) == mock_response.json.return_value + return mock_update.await_count > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +async def test_register_client_does_not_persist_for_client_forwarded_modes(auth_type): + """The admin Authorize path (persist_credentials=True) must not write the DCR client onto a + true_passthrough / oauth_delegate server row: the browser still receives the registration, but + the gateway keeps no OAuth client identity for these modes.""" + assert await _register_persistence_attempted_for_auth_type(auth_type) is False + + +@pytest.mark.asyncio +async def test_register_client_persist_discriminator_oauth2_persists(): + """Guard the no-persist assertion above against vacuity: the same helper run against a genuine + oauth2 server DOES persist, so a regression that silently disables persistence everywhere (or a + helper that never reaches the persist) fails here instead of passing both.""" + assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True + + +@pytest.mark.asyncio +async def test_register_client_persists_only_to_its_own_row_when_another_server_shares_the_url(): + """A fresh server must mint and persist its OWN DCR client even when another server row with + the same upstream URL already holds one: both the reuse lookup and the persist are keyed by + server_id, never by URL, so OAuth client identity is not transferable between server entries. + If either side ever falls back to a URL match, this fails: the fresh server would skip the + upstream registration (adopting the sibling's client) or persist onto the wrong row.""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + shared_url = "https://provider.example/mcp" + fresh_server = MCPServer( + server_id="server-b", + name="server-b", + server_name="server-b", + alias="server-b", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + url=shared_url, + client_id=None, + client_secret=None, + authorization_url="https://provider.example/oauth/authorize", + token_url="https://provider.example/oauth/token", + registration_url="https://provider.example/oauth/register", + ) + sibling_row_with_client = MagicMock(server_id="server-a", url=shared_url) + sibling_row_with_client.credentials = {"client_id": "client-a-do-not-adopt"} + own_row_without_client = MagicMock(server_id="server-b", url=shared_url) + own_row_without_client.credentials = {} + rows_by_server_id = {"server-a": sibling_row_with_client, "server-b": own_row_without_client} + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://proxy.litellm.example/" + mock_request.headers = {} + + mock_response = MagicMock() + mock_response.json.return_value = {"client_id": "fresh-client-b", "token_endpoint_auth_method": "none"} + mock_response.raise_for_status = MagicMock() + mock_async_client = MagicMock() + mock_async_client.post = AsyncMock(return_value=mock_response) + + mock_update = AsyncMock(return_value=MagicMock()) + + async def _get_row(prisma_client, server_id): + return rows_by_server_id.get(server_id) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=mock_async_client, + ), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.get_mcp_server", new=AsyncMock(side_effect=_get_row)), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=mock_update), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + ): + response = await register_client_with_server( + request=mock_request, + mcp_server=fresh_server, + client_name="Litellm Proxy", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + persist_credentials=True, + ) + + mock_async_client.post.assert_called_once() + body = json.loads(response.body.decode("utf-8")) + assert body["client_id"] == "fresh-client-b" + + mock_update.assert_called_once() + update_data = mock_update.call_args.kwargs["data"] + assert update_data.server_id == "server-b" + assert update_data.credentials["client_id"] == "fresh-client-b" + + @pytest.mark.asyncio async def test_register_client_does_not_clobber_token_url_when_absent(): """When the in-memory server has no token_url, the DCR persist must omit it from the diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index ef2a8318d7a..6de35ebc524 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -110,6 +110,42 @@ def test_is_oauth_passthrough_false_without_authorization_header(): assert server.is_oauth_passthrough is False +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_is_dcr_bridge_true_for_flagged_client_forwarded_modes(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + dcr_bridge=True, + ) + assert server.is_dcr_bridge is True + + +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_is_dcr_bridge_false_when_flag_unset(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + ) + assert server.dcr_bridge is None + assert server.is_dcr_bridge is False + + +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.none, MCPAuth.api_key, None]) +def test_is_dcr_bridge_false_for_non_client_forwarded_auth_types(auth_type): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=auth_type, + dcr_bridge=True, + ) + assert server.is_dcr_bridge is False + + def test_is_oauth_passthrough_false_without_extra_headers(): server = MCPServer( server_id="s1", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py index d341d8f7e3b..00d96f975bb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_partial_update.py @@ -201,6 +201,7 @@ async def test_auth_type_switch_clears_stale_flow_scoped_fields(): "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", @@ -225,6 +226,20 @@ async def test_auth_type_switch_keeps_explicitly_provided_flow_fields(): assert data_dict["token_url"] is None +@pytest.mark.asyncio +async def test_auth_type_switch_to_client_forwarded_keeps_explicit_dcr_bridge(): + data = UpdateMCPServerRequest( + server_id="my-test-server", + auth_type="true_passthrough", + dcr_bridge=True, + ) + + data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2") + + assert data_dict["dcr_bridge"] is True + assert data_dict["oauth2_flow"] is None + + @pytest.mark.asyncio async def test_auth_type_switch_back_to_oauth2_clears_token_exchange_fields(): """The reverse switch must not leave token-exchange settings behind to @@ -256,6 +271,7 @@ async def test_unchanged_auth_type_does_not_clear_flow_fields(): "token_url", "registration_url", "oauth2_flow", + "dcr_bridge", "token_exchange_endpoint", "audience", "subject_token_type", 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 bba0eb31cfb..7d25e0ba493 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 @@ -6869,6 +6869,7 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow(): (None, None), ("", None), ("not a url", None), + ("http://[::1", None), ], ) def test_redact_mcp_resource_url_strips_credentials(url, expected): 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 e8fca9ac6ab..d6ddb0131af 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 @@ -373,6 +373,66 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.oauth2_flow is None + def _client_forwarded_config(self, auth_type, **overrides): + base = { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": auth_type, + } + base.update(overrides) + return {"bridgeserver": base} + + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_dcr_bridge_on_gateway_managed_auth_type(self): + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config( + self._oauth2_config(oauth2_flow="authorization_code", dcr_bridge=True) + ) + + assert "dcr_bridge is only supported" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_non_boolean_dcr_bridge(self): + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config( + self._client_forwarded_config(MCPAuth.true_passthrough, dcr_bridge="yes") + ) + + assert "must be a boolean" in str(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) + async def test_load_servers_from_config_accepts_dcr_bridge_on_client_forwarded_modes(self, auth_type): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._client_forwarded_config(auth_type, dcr_bridge=True)) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.dcr_bridge is True + assert server.is_dcr_bridge is True + + @pytest.mark.asyncio + async def test_load_servers_from_config_dcr_bridge_defaults_off(self): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._client_forwarded_config(MCPAuth.true_passthrough)) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.dcr_bridge is None + assert server.is_dcr_bridge is False + @pytest.mark.asyncio async def test_load_servers_from_config_coerces_cost_string_to_float(self): """YAML 1.1 parses `7e-05` as a string; ingest must coerce it to float.""" @@ -3327,9 +3387,35 @@ class TestMCPServerManager: await manager.invalidate_user_oauth_token_cache("alice", "srv-1") assert store.invalidations == [("alice", "srv-1")] + @pytest.mark.asyncio + async def test_invalidate_user_oauth_token_cache_drops_legacy_cache_too(self): + """A per-user token can be served from the legacy per-user token cache as well as the v2 + store; the shared invalidation must evict both, or the path not evicted keeps serving a + token minted for a replaced credential row until its TTL.""" + + class _Store: + async def fetch(self, user_id: str, server_id: str): + return None + + async def invalidate(self, user_id: str, server_id: str) -> None: + return None + + class _LegacyCache: + def __init__(self) -> None: + self.deletes: list[tuple[str, str]] = [] + + async def delete(self, user_id: str, server_id: str) -> None: + self.deletes.append((user_id, server_id)) + + legacy_cache = _LegacyCache() + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache) + await manager.invalidate_user_oauth_token_cache("alice", "srv-1") + assert legacy_cache.deletes == [("alice", "srv-1")] + @pytest.mark.asyncio async def test_invalidate_user_oauth_token_cache_swallows_store_errors(self): - """A cache-drop failure must not fail the credential write that triggered it.""" + """A cache-drop failure must not fail the credential write that triggered it, and the + legacy cache must still be evicted after the v2 store drop fails.""" class _Store: async def fetch(self, user_id: str, server_id: str): @@ -3338,7 +3424,35 @@ class TestMCPServerManager: async def invalidate(self, user_id: str, server_id: str) -> None: raise RuntimeError("redis down") - manager = MCPServerManager(per_user_oauth_token_store=_Store()) + class _LegacyCache: + def __init__(self) -> None: + self.deletes: list[tuple[str, str]] = [] + + async def delete(self, user_id: str, server_id: str) -> None: + self.deletes.append((user_id, server_id)) + + legacy_cache = _LegacyCache() + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache) + await manager.invalidate_user_oauth_token_cache("alice", "srv-1") + assert legacy_cache.deletes == [("alice", "srv-1")] + + @pytest.mark.asyncio + async def test_invalidate_user_oauth_token_cache_swallows_legacy_cache_errors(self): + """The legacy cache drop is best-effort like the v2 drop: a failure must be logged, never + raised into the credential write that triggered the invalidation.""" + + class _Store: + async def fetch(self, user_id: str, server_id: str): + return None + + async def invalidate(self, user_id: str, server_id: str) -> None: + return None + + class _RaisingLegacyCache: + async def delete(self, user_id: str, server_id: str) -> None: + raise RuntimeError("redis down") + + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache()) await manager.invalidate_user_oauth_token_cache("alice", "srv-1") @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py index a60dab9148d..7d2cf4442a5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py @@ -92,6 +92,36 @@ async def test_token_cached_across_calls(): assert mock_client.post.call_count == 1 +@pytest.mark.asyncio +async def test_m2m_token_not_shared_across_server_ids_with_identical_config(): + """Two servers with byte-identical client_credentials config but different server_ids must not + share a cached M2M token: the cache is keyed by server_id, so a new server entry (even one + recreated with the same URL and credentials) mints its own token instead of inheriting the + sibling's. Guards against the cache key ever collapsing to the URL or the client config.""" + cache = MCPOAuth2TokenCache() + server_a = _server(server_id="srv-a") + server_b = _server(server_id="srv-b") + mock_client = AsyncMock() + mock_client.post.side_effect = [_token_response("tok-for-a"), _token_response("tok-for-b")] + + with ( + patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client", + return_value=mock_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.mcp_oauth2_token_cache", + cache, + ), + ): + token_a = await resolve_mcp_auth(server_a) + token_b = await resolve_mcp_auth(server_b) + + assert token_a == "tok-for-a" + assert token_b == "tok-for-b" + assert mock_client.post.call_count == 2 + + @pytest.mark.asyncio async def test_per_request_header_beats_oauth2(): """An explicit mcp_auth_header takes priority over the OAuth2 token.""" diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index d623149ff6a..204e6a671e3 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2595,6 +2595,135 @@ def test_org_admin_of_multiple_orgs_can_operate_on_both(): assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True +# ── LIT-4221: /team/update org-context resolution from team_id ──────────────── +from litellm.proxy.auth.auth_checks_organization import ( + add_team_org_context_to_request_body, +) + + +@pytest.mark.asyncio +async def test_add_team_org_context_resolves_org_from_team(): + """For /team/update with only team_id, the target team's org is resolved and + injected so the org-admin route gate can see it. This is what lets an org + admin update a team budget from the Hub UI, which sends team_id, not + organization_id (LIT-4221).""" + + async def fetch(team_id: str): + assert team_id == "team-1" + return "org-1" + + out = await add_team_org_context_to_request_body( + route="/team/update", + request_body={"team_id": "team-1", "max_budget": 42}, + fetch_team_org_id=fetch, + ) + assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"} + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_org_id_already_present(): + """If the caller already passed organization_id, no lookup happens and the + body is returned unchanged.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve when organization_id is present") + + body = {"team_id": "team-1", "organization_id": "org-explicit"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_for_other_routes(): + """Only /team/update opts into org resolution; other routes are untouched.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve for a non-opted-in route") + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/delete", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_team_has_no_org(): + """A standalone team (no org) resolves to None, so nothing is injected and + the org-admin branch stays unreachable (no blanket access).""" + + async def fetch(team_id: str): + return None + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +def test_team_update_gate_allows_org_admin_with_resolved_org(): + """Post-resolution (organization_id present), an org admin of that org clears + the gate for /team/update.""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-1"}, + ) + + +def test_team_update_gate_rejects_without_org_context(): + """Without organization_id (i.e. resolution found no org, or a non-org-admin), + the gate still rejects /team/update — the fix adds no blanket allow. Guards + against re-widening the route (e.g. dropping it into self_managed_routes).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "max_budget": 42}, + ) + + +def test_team_update_gate_rejects_cross_org_admin_with_resolved_org(): + """Even after the target team's org is resolved, an org admin of a DIFFERENT + org is rejected at the gate (no cross-org escalation).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-2"}, + ) + + @pytest.mark.asyncio async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 86bbce36de3..a669a277d2b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -4900,6 +4900,158 @@ def test_oauth2_flow_defaults_to_none_when_omitted(): assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").oauth2_flow is None +def test_dcr_bridge_rejected_on_create_for_gateway_managed_auth_type(): + from pydantic import ValidationError + + from litellm.proxy._types import NewMCPServerRequest + + with pytest.raises(ValidationError) as exc: + NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + auth_type="oauth2", + oauth2_flow="authorization_code", + dcr_bridge=True, + ) + assert "dcr_bridge is only supported" in str(exc.value) + + +def test_dcr_bridge_rejected_on_create_when_auth_type_omitted(): + from pydantic import ValidationError + + from litellm.proxy._types import NewMCPServerRequest + + with pytest.raises(ValidationError) as exc: + NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + dcr_bridge=True, + ) + assert "dcr_bridge is only supported" in str(exc.value) + + +@pytest.mark.parametrize("auth_type", ["true_passthrough", "oauth_delegate"]) +def test_dcr_bridge_accepted_on_create_for_client_forwarded_modes(auth_type): + from litellm.proxy._experimental.mcp_server.db import _prepare_mcp_server_data + from litellm.proxy._types import NewMCPServerRequest + + payload = NewMCPServerRequest( + server_name="bridge-server", + url="https://example.com/mcp", + transport="http", + auth_type=auth_type, + dcr_bridge=True, + ) + data_dict = _prepare_mcp_server_data(payload) + assert data_dict["dcr_bridge"] is True + + +def test_dcr_bridge_update_rejected_when_payload_auth_type_not_client_forwarded(): + from pydantic import ValidationError + + from litellm.proxy._types import UpdateMCPServerRequest + + with pytest.raises(ValidationError) as exc: + UpdateMCPServerRequest(server_id="srv-1", auth_type="oauth2", dcr_bridge=True) + assert "dcr_bridge is only supported" in str(exc.value) + + +def test_dcr_bridge_update_without_auth_type_defers_to_endpoint(): + from litellm.proxy._types import UpdateMCPServerRequest + + assert UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True).dcr_bridge is True + + +def test_dcr_bridge_round_trips_on_response_model(): + from litellm.proxy._types import LiteLLM_MCPServerTable + + row = LiteLLM_MCPServerTable(server_id="srv-1", transport="http", dcr_bridge=True) + assert row.dcr_bridge is True + assert LiteLLM_MCPServerTable(server_id="srv-1", transport="http").dcr_bridge is None + + +def _edit_endpoint_patches(old_record, update_mock): + return ( + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", + AsyncMock(side_effect=old_record) if isinstance(old_record, Exception) else AsyncMock(return_value=old_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server", + update_mock, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload", + autospec=True, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stored_auth_type", ["oauth2", "api_key", "none"]) +async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_auth_type_not_client_forwarded(stored_auth_type): + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + old_record = MagicMock() + old_record.auth_type = stored_auth_type + update_mock = AsyncMock() + p1, p2, p3, p4, p5 = _edit_endpoint_patches(old_record, update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 400 + assert "dcr_bridge is only supported" in str(exc.value.detail) + update_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_rejects_dcr_bridge_when_stored_record_unreadable(): + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + update_mock = AsyncMock() + p1, p2, p3, p4, p5 = _edit_endpoint_patches(RuntimeError("db down"), update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="srv-1", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 400 + update_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_dcr_bridge_on_unknown_server_returns_404_not_400(): + """A dcr_bridge enablement targeting a server_id that does not exist must surface the accurate + 404 from the update path, not a misleading 400 about the stored auth_type: get_mcp_server + returns None for a missing row without raising, which is distinct from a failed read.""" + from litellm.proxy._types import UpdateMCPServerRequest + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + update_mock = AsyncMock(return_value=None) + p1, p2, p3, p4, p5 = _edit_endpoint_patches(None, update_mock) + with p1, p2, p3, p4, p5: + payload = UpdateMCPServerRequest(server_id="does-not-exist", dcr_bridge=True) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + + assert exc.value.status_code == 404 + update_mock.assert_called_once() + + class TestPerUserCredentialConfigServerResolution: """Per-user credential and env-var endpoints must resolve config-defined MCP servers, which live only in the in-memory registry and never get a DB row, so @@ -5134,3 +5286,93 @@ def test_stamp_oauth2_flow_ignores_non_oauth2(): payload = _oauth2_create_payload(auth_type="none") mgmt_endpoints.stamp_omitted_oauth2_flow(payload) assert payload.oauth2_flow is None + + +async def _run_edit(old_record, updated_record, purge_mock=None): + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + server_id = updated_record.server_id + with ( + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", + AsyncMock(side_effect=old_record) + if isinstance(old_record, Exception) + else AsyncMock(return_value=old_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server", + AsyncMock(return_value=updated_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload", + autospec=True, + ), + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager") as mock_manager, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server", + purge_mock if purge_mock is not None else AsyncMock(return_value=1), + ) as mock_purge, + ): + mock_manager.update_server = AsyncMock() + mock_manager.reload_servers_from_database = AsyncMock() + payload = UpdateMCPServerRequest(server_id=server_id, alias=updated_record.alias, url=updated_record.url) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + result = await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + return result, mock_purge + + +@pytest.mark.asyncio +async def test_edit_mcp_server_purges_user_tokens_on_mint_relevant_change(): + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp") + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(old, updated) + + assert result.server_id == server_id + mock_purge.assert_awaited_once() + assert mock_purge.await_args.args[1] == server_id + + +@pytest.mark.asyncio +async def test_edit_mcp_server_skips_purge_when_identity_unchanged(): + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, alias="Before") + updated = generate_mock_mcp_server_db_record(server_id=server_id, alias="After") + + result, mock_purge = await _run_edit(old, updated) + + assert result.server_id == server_id + mock_purge.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_purge_failure_does_not_fail_the_edit(): + """The purge is best-effort: a purge exception after a successful update must be swallowed and + logged, never turned into an error response for an edit whose primary job already succeeded.""" + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp") + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(old, updated, purge_mock=AsyncMock(side_effect=RuntimeError("db down"))) + + assert result.server_id == server_id + mock_purge.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds(): + """The pre-update snapshot read is advisory (it only feeds the purge decision); a read failure + must skip the stale-token check with a warning, never fail the edit itself.""" + server_id = str(uuid.uuid4()) + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(RuntimeError("db read failed"), updated) + + assert result.server_id == server_id + mock_purge.assert_not_awaited() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index c3899883d58..ffca5e5a368 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -3017,6 +3017,7 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): return_value=[ { "session_id": session_id, + "session_total_spend": 15.0, "mcp_tool_call_count": 1, "mcp_tool_call_spend": 10.0, } @@ -3044,6 +3045,10 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): assert rows[1]["mcp_tool_call_count"] == 1 assert rows[1]["mcp_tool_call_spend"] == 10.0 + # Every row in the session carries the full session spend, not just its own + assert rows[0]["session_total_spend"] == 15.0 + assert rows[1]["session_total_spend"] == 15.0 + # Row without a session_id defaults to 1 assert rows[2]["session_total_count"] == 1 @@ -3055,6 +3060,64 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): ) +@pytest.mark.asyncio +async def test_build_ui_spend_logs_response_sums_multi_round_session_spend(): + """ + Regression test for LIT-4342: for a multi-round session the UI must show the + summed cost of every round, not just the first call. _build_ui_spend_logs_response + enriches each row of a session with session_total_spend aggregated across the + whole session, scoped to the authorized api_keys of the page. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _build_ui_spend_logs_response, + ) + + session_id = "sess-multi-round" + api_key = "hashed-key-xyz" + # Three rounds of the same chat session with different per-call spend. + dict_rows = [ + {"request_id": "req-1", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.01}, + {"request_id": "req-2", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.02}, + {"request_id": "req-3", "session_id": session_id, "call_type": "completion", "api_key": api_key, "spend": 0.03}, + ] + + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.group_by = AsyncMock( + return_value=[{"session_id": session_id, "_count": {"session_id": 3}}] + ) + # The raw aggregate query returns the full session spend (0.01 + 0.02 + 0.03). + mock_prisma.db.query_raw = AsyncMock( + return_value=[ + { + "session_id": session_id, + "session_total_spend": 0.06, + "mcp_tool_call_count": 0, + "mcp_tool_call_spend": 0.0, + } + ] + ) + + result = await _build_ui_spend_logs_response( + prisma_client=mock_prisma, + data=dict_rows, + total_records=3, + page=1, + page_size=50, + total_pages=1, + enrich_session_counts=True, + ) + + rows = result["data"] + assert [row["session_total_spend"] for row in rows] == [0.06, 0.06, 0.06] + # No MCP calls in this session, so MCP fields must not be attached. + assert all("mcp_tool_call_count" not in row for row in rows) + + # The aggregate must be scoped to the authorized api_keys of the page. + _, call_args, _ = mock_prisma.db.query_raw.mock_calls[0] + assert call_args[1] == [session_id] + assert call_args[2] == [api_key] + + # --------------------------------------------------------------------------- # Tests for /spend/logs team-member permission # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 39b5e120c48..9a8f8146d6f 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -33,6 +33,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( _is_master_key, _redact_prompt_leaks_in_error_string, _sanitize_error_information_for_spend_logs, + _sanitize_guardrail_information_for_spend_logs, _sanitize_request_body_for_spend_logs_payload, _should_store_prompts_and_responses_in_spend_logs, get_logging_payload, @@ -1263,6 +1264,260 @@ def test_get_spend_logs_metadata_guardrail_info_fallback_from_metadata(): assert result["guardrail_information"] is None +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_redacts_all_prompt_carrying_fields_when_flag_false( + mock_should_store, +): + """ + match_details and classification are declared as structured metadata but + in-tree writers (litellm_content_filter, block_code_execution) inline + raw prompt content into them, so they leak the same way + guardrail_request/guardrail_response do. Redaction must cover all four. + """ + mock_should_store.return_value = False + guardrail_info = [ + { + "guardrail_name": "demo-echo-guard", + "guardrail_status": "success", + "guardrail_request": {"messages": [{"role": "user", "content": "hi"}]}, + "guardrail_response": {"evaluated_input": "hi"}, + "match_details": [{"type": "pattern", "snippet": "hi", "action_taken": "log"}], + "classification": {"intent": "x", "evidence": [{"match": "hi"}]}, + "guardrail_action": "NONE", + } + ] + + result = _sanitize_guardrail_information_for_spend_logs(guardrail_info) + + assert result is not None + entry = result[0] + assert entry["guardrail_request"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING + assert entry["match_details"] == REDACTED_BY_LITELM_STRING + assert entry["classification"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_name"] == "demo-echo-guard" + assert entry["guardrail_status"] == "success" + assert entry["guardrail_action"] == "NONE" + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_redacts_prompt_fields_when_flag_false( + mock_should_store, +): + """ + LIT-4314 Issue A regression: with store_prompts_in_spend_logs=False, + guardrail_request and guardrail_response must be redacted before they + land in LiteLLM_SpendLogs.metadata, while every other field on the + entry is preserved bit-for-bit. + """ + mock_should_store.return_value = False + guardrail_info = [ + { + "guardrail_name": "demo-echo-guard", + "guardrail_provider": "custom", + "guardrail_mode": "pre_call", + "guardrail_status": "success", + "guardrail_request": { + "messages": [{"role": "user", "content": "Say hi in 3 words"}], + }, + "guardrail_response": { + "evaluated_input": "Say hi in 3 words", + "verdict": "allow", + }, + "start_time": 1_700_000_000.0, + "end_time": 1_700_000_000.5, + "duration": 0.5, + "guardrail_id": "gd-42", + "masked_entity_count": {"EMAIL": 1}, + "violation_categories": ["prompt_injection"], + "risk_score": 3.5, + "guardrail_action": "NONE", + } + ] + + result = _sanitize_guardrail_information_for_spend_logs(guardrail_info) + + assert result is not None + assert len(result) == 1 + entry = result[0] + assert entry["guardrail_request"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_name"] == "demo-echo-guard" + assert entry["guardrail_provider"] == "custom" + assert entry["guardrail_mode"] == "pre_call" + assert entry["guardrail_status"] == "success" + assert entry["start_time"] == 1_700_000_000.0 + assert entry["end_time"] == 1_700_000_000.5 + assert entry["duration"] == 0.5 + assert entry["guardrail_id"] == "gd-42" + assert entry["masked_entity_count"] == {"EMAIL": 1} + assert entry["violation_categories"] == ["prompt_injection"] + assert entry["risk_score"] == 3.5 + assert entry["guardrail_action"] == "NONE" + + assert guardrail_info[0]["guardrail_request"] == { + "messages": [{"role": "user", "content": "Say hi in 3 words"}], + } + assert guardrail_info[0]["guardrail_response"] == { + "evaluated_input": "Say hi in 3 words", + "verdict": "allow", + } + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_passthrough_when_flag_true( + mock_should_store, +): + """ + When store_prompts_in_spend_logs=True the sanitizer must be a no-op so + operators who explicitly opted in still see full guardrail payloads. + """ + mock_should_store.return_value = True + guardrail_info = [ + { + "guardrail_name": "content_filter", + "guardrail_status": "success", + "guardrail_request": {"messages": [{"role": "user", "content": "hi"}]}, + "guardrail_response": {"verdict": "allow"}, + } + ] + + result = _sanitize_guardrail_information_for_spend_logs(guardrail_info) + + assert result == guardrail_info + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_none_passthrough(mock_should_store): + mock_should_store.return_value = False + assert _sanitize_guardrail_information_for_spend_logs(None) is None + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_normalizes_bare_dict_input(mock_should_store): + """ + Regression: xecguard (xecguard.py:246) assigns a bare dict to + standard_logging_object["guardrail_information"] even though the typed + contract is Optional[List[...]]. Without defensive normalization here, + the for-loop would iterate the dict's string keys and _redact... + would TypeError on {**"guardrail_name"}, taking down the entire + spend-log write via update_database's broad except. + """ + mock_should_store.return_value = False + bare_dict_entry = { + "guardrail_name": "xecguard", + "guardrail_status": "success", + "guardrail_response": {"decision": "SAFE", "raw_prompt": "hi"}, + "start_time": 1.0, + "end_time": 2.0, + "duration": 1.0, + } + + result = _sanitize_guardrail_information_for_spend_logs(bare_dict_entry) + + assert result is not None + assert isinstance(result, list) + assert len(result) == 1 + entry = result[0] + assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_name"] == "xecguard" + assert entry["guardrail_status"] == "success" + assert entry["start_time"] == 1.0 + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_drops_non_dict_items_in_list(mock_should_store): + """ + A stray non-dict item in the list (e.g. from a buggy caller that + accidentally appends a string) should be silently skipped instead of + crashing the spend-log write. + """ + mock_should_store.return_value = False + mixed_input = [ + {"guardrail_name": "x", "guardrail_response": {"leak": "hi"}}, + "not-a-dict", + None, + ] + + result = _sanitize_guardrail_information_for_spend_logs(mixed_input) + + assert result == [{"guardrail_name": "x", "guardrail_response": REDACTED_BY_LITELM_STRING}] + + +@patch("litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs") +def test_sanitize_guardrail_information_preserves_absent_prompt_fields(mock_should_store): + """ + Entries that never carried guardrail_request or guardrail_response must + not gain those keys after sanitization; consumers keying on presence + (`"guardrail_request" in entry`) would otherwise flip from absent to + the sentinel string. + """ + mock_should_store.return_value = False + guardrail_info = [ + { + "guardrail_name": "demo-echo-guard", + "guardrail_status": "success", + "guardrail_response": {"verdict": "allow", "evaluated_input": "hi"}, + } + ] + + result = _sanitize_guardrail_information_for_spend_logs(guardrail_info) + + assert result is not None + entry = result[0] + assert "guardrail_request" not in entry + assert entry["guardrail_response"] == REDACTED_BY_LITELM_STRING + assert entry["guardrail_name"] == "demo-echo-guard" + assert entry["guardrail_status"] == "success" + + +@patch("litellm.proxy.proxy_server.master_key", "sk-master") +@patch( + "litellm.proxy.proxy_server.general_settings", + {"store_prompts_in_spend_logs": False}, +) +def test_get_logging_payload_redacts_guardrail_prompt_fields_when_flag_false(): + """ + End-to-end wire-in check: get_logging_payload -> _get_spend_logs_metadata + -> sanitizer. Without the wire-in at line 139, the raw guardrail_response + lands in payload["metadata"] verbatim. + """ + guardrail_info = [ + { + "guardrail_name": "demo-echo-guard", + "guardrail_provider": "custom", + "guardrail_status": "success", + "guardrail_request": {"messages": [{"role": "user", "content": "secret"}]}, + "guardrail_response": {"evaluated_input": "secret"}, + } + ] + kwargs = { + "model": "gpt-4o-mini", + "litellm_call_id": "test-call-id", + "litellm_params": { + "metadata": { + "user_api_key": "test-key", + "standard_logging_guardrail_information": guardrail_info, + }, + "proxy_server_request": {}, + }, + } + + payload = get_logging_payload( + kwargs=kwargs, + response_obj={}, + start_time=datetime.datetime.now(tz=timezone.utc), + end_time=datetime.datetime.now(tz=timezone.utc), + ) + + metadata_result = json.loads(payload["metadata"]) + stored = metadata_result["guardrail_information"][0] + assert stored["guardrail_request"] == REDACTED_BY_LITELM_STRING + assert stored["guardrail_response"] == REDACTED_BY_LITELM_STRING + assert stored["guardrail_name"] == "demo-echo-guard" + assert stored["guardrail_status"] == "success" + + def test_get_logging_payload_guardrail_info_when_no_standard_logging_payload(): """ When a guardrail blocks a request before the LLM call, the standard_logging_object @@ -1295,7 +1550,10 @@ def test_get_logging_payload_guardrail_info_when_no_standard_logging_payload(): } with patch("litellm.proxy.proxy_server.master_key", "sk-master"): - with patch("litellm.proxy.proxy_server.general_settings", {}): + with patch( + "litellm.proxy.proxy_server.general_settings", + {"store_prompts_in_spend_logs": True}, + ): payload = get_logging_payload( kwargs=kwargs, response_obj={}, diff --git a/tests/test_litellm/test_gpt_5_6_model_metadata.py b/tests/test_litellm/test_gpt_5_6_model_metadata.py index 30a0777f477..af9bb117f78 100644 --- a/tests/test_litellm/test_gpt_5_6_model_metadata.py +++ b/tests/test_litellm/test_gpt_5_6_model_metadata.py @@ -62,6 +62,77 @@ def test_openai_gpt_5_6_model_info(model): assert provider == "openai" +AZURE_GLOBAL_MODELS = ( + "azure/gpt-5.6", + "azure/gpt-5.6-sol", + "azure/gpt-5.6-terra", + "azure/gpt-5.6-luna", +) + +AZURE_REGIONAL_MODELS = tuple( + f"azure/{region}/{tier}" + for region in ("us", "eu") + for tier in ("gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna") +) + + +def _tier_key(azure_model): + return azure_model.split("/")[-1] + + +def _load_main(): + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + return json.load(f) + + +@pytest.mark.parametrize("model", AZURE_GLOBAL_MODELS) +def test_azure_gpt_5_6_global_model_info(model): + model_cost = _load_main() + info = model_cost.get(model) + assert info is not None, f"{model} not found in model_prices_and_context_window.json" + + assert info["litellm_provider"] == "azure" + assert info["mode"] == "chat" + + input_cost, output_cost, cache_read_cost, _ = STANDARD_PRICING[_tier_key(model)] + assert info["input_cost_per_token"] == input_cost + assert info["output_cost_per_token"] == output_cost + assert info["cache_read_input_token_cost"] == cache_read_cost + + assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2) + assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(output_cost * 1.5) + assert info["input_cost_per_token_priority"] == pytest.approx(input_cost * 2) + assert info["output_cost_per_token_priority"] == pytest.approx(output_cost * 2) + assert info["input_cost_per_token_above_272k_tokens_priority"] == pytest.approx(input_cost * 4) + assert info["output_cost_per_token_above_272k_tokens_priority"] == pytest.approx(output_cost * 3) + + assert info["max_input_tokens"] == 1050000 + assert info["max_output_tokens"] == 128000 + assert info["supports_reasoning"] is True + + routed_model, provider, _, _ = get_llm_provider(model=model) + assert provider == "azure" + + +@pytest.mark.parametrize("model", AZURE_REGIONAL_MODELS) +def test_azure_gpt_5_6_regional_model_info(model): + model_cost = _load_main() + info = model_cost.get(model) + assert info is not None, f"{model} not found in model_prices_and_context_window.json" + + assert info["litellm_provider"] == "azure" + input_cost, output_cost, cache_read_cost, _ = STANDARD_PRICING[_tier_key(model)] + + assert info["input_cost_per_token"] == pytest.approx(input_cost * 1.1) + assert info["output_cost_per_token"] == pytest.approx(output_cost * 1.1) + assert info["cache_read_input_token_cost"] == pytest.approx(cache_read_cost * 1.1) + assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2.2) + assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(output_cost * 1.65) + assert info["input_cost_per_token_priority"] == pytest.approx(input_cost * 2.75) + assert info["output_cost_per_token_priority"] == pytest.approx(output_cost * 2.75) + + def test_gpt_5_6_backup_matches_main(): """Ensure the bundled model cost map stays in sync with the canonical file.""" repo_root = Path(__file__).parents[2] @@ -73,7 +144,7 @@ def test_gpt_5_6_backup_matches_main(): with open(backup_path) as f: backup_cost = json.load(f) - for model in GPT_5_6_MODELS: + for model in GPT_5_6_MODELS + AZURE_GLOBAL_MODELS + AZURE_REGIONAL_MODELS: assert backup_cost.get(model) == main_cost.get(model), ( f"{model} differs between main and backup model cost maps" ) diff --git a/tests/test_litellm/test_muse_spark_1_1_model_metadata.py b/tests/test_litellm/test_muse_spark_1_1_model_metadata.py new file mode 100644 index 00000000000..540b97884dc --- /dev/null +++ b/tests/test_litellm/test_muse_spark_1_1_model_metadata.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + +MUSE_SPARK_MODEL = "meta/muse-spark-1.1" + + +def test_muse_spark_1_1_model_info(): + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + info = model_cost.get(MUSE_SPARK_MODEL) + assert info is not None, f"{MUSE_SPARK_MODEL} not found in model_prices_and_context_window.json" + + assert info["litellm_provider"] == "meta" + assert info["mode"] == "chat" + + assert info["input_cost_per_token"] == 1.25e-06 + assert info["output_cost_per_token"] == 4.25e-06 + assert info["cache_read_input_token_cost"] == 1.5e-07 + + assert info["max_input_tokens"] == 1048576 + assert info["max_output_tokens"] == 131072 + assert info["max_tokens"] == 131072 + + assert info["supports_function_calling"] is True + assert info["supports_parallel_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_response_schema"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_pdf_input"] is True + assert info["supports_web_search"] is True + assert info["supports_minimal_reasoning_effort"] is True + assert info["supports_xhigh_reasoning_effort"] is True + + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + assert info["supported_modalities"] == ["text", "image", "video"] + assert info["supported_output_modalities"] == ["text"] + + routed_model, provider, _, api_base = get_llm_provider(model=MUSE_SPARK_MODEL, api_key="sk-test") + assert routed_model == "muse-spark-1.1" + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + +def test_muse_spark_1_1_backup_matches_main(): + """Ensure the bundled model cost map stays in sync with the canonical file.""" + repo_root = Path(__file__).parents[2] + main_path = repo_root / "model_prices_and_context_window.json" + backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json" + + with open(main_path) as f: + main_cost = json.load(f) + with open(backup_path) as f: + backup_cost = json.load(f) + + assert backup_cost.get(MUSE_SPARK_MODEL) == main_cost.get(MUSE_SPARK_MODEL), ( + f"{MUSE_SPARK_MODEL} differs between main and backup model cost maps" + ) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 47be8cb2eec..d739f9c116a 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -874,6 +874,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "/v1/embeddings", "/v1/chat/completions", "/v1/completions", + "/v1/messages", "/v1/images/generations", "/v1/realtime", "/v1/realtime/transcription_sessions", @@ -884,7 +885,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "/v1/audio/speech", "/v1/ocr", "/vertex_ai/live", - "/v1/realtime/transcription_sessions", ], }, }, diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 0900584f5ce..7cf140eddf6 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,6 +1,6 @@ { - "@typescript-eslint/no-explicit-any": 1980, - "complexity": 128, + "@typescript-eslint/no-explicit-any": 1977, + "complexity": 129, "local/no-large-inline-object-arg": 509, "local/no-long-condition-chain": 233, "max-depth": 59, diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6d310a97dbf..d12ad1df199 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,32 +4,40 @@ "count": 1 } }, - "src/app/(dashboard)/api-reference/APIReferenceView.tsx": { + "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/budget_modal.tsx": { + "src/app/(dashboard)/budgets/_components/budget_modal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/budget_panel.test.tsx": { + "src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": { "unused-imports/no-unused-imports": { "count": 2 } }, - "src/app/(dashboard)/budgets/components/budget_panel.tsx": { + "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/edit_budget_modal.tsx": { + "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_dashboard.tsx": { + "src/app/(dashboard)/caching/_components/cache_dashboard.tsx": { "no-restricted-imports": { "count": 1 }, @@ -40,17 +48,17 @@ "count": 2 } }, - "src/app/(dashboard)/caching/components/cache_health.tsx": { + "src/app/(dashboard)/caching/_components/cache_health.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": { + "src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_settings/index.tsx": { + "src/app/(dashboard)/caching/_components/cache_settings/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -58,17 +66,17 @@ "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/add_margin_form.tsx": { + "src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/add_provider_form.tsx": { + "src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.tsx": { + "src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": { "no-nested-ternary": { "count": 2 }, @@ -76,96 +84,86 @@ "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/how_it_works.tsx": { + "src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.test.tsx": { + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.tsx": { + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.test.tsx": { + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.tsx": { + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.ts": { + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.ts": { "no-restricted-syntax": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/provider_discount_table.test.tsx": { + "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/provider_discount_table.tsx": { + "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/provider_display_helpers.test.ts": { + "src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.test.ts": { "unused-imports/no-unused-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/provider_margin_table.tsx": { + "src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/components/use_discount_config.ts": { + "src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": { "no-restricted-syntax": { "count": 2 } }, - "src/app/(dashboard)/cost-tracking/components/use_margin_config.ts": { + "src/app/(dashboard)/cost-tracking/_components/use_margin_config.ts": { "no-restricted-syntax": { "count": 2 } }, - "src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": { "no-nested-ternary": { "count": 3 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": { "no-nested-ternary": { "count": 8 } }, - "src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx": { - "react/display-name": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": { "no-restricted-syntax": { "count": 1 @@ -326,7 +324,7 @@ "count": 2 } }, - "src/app/(dashboard)/memory/components/MemoryView.tsx": { + "src/app/(dashboard)/memory/_components/MemoryView.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } @@ -373,6 +371,22 @@ "count": 1 } }, + "src/app/(dashboard)/old-usage/_components/usage.tsx": { + "no-restricted-imports": { + "count": 2 + }, + "react-hooks/immutability": { + "count": 1 + }, + "react-hooks/purity": { + "count": 1 + } + }, + "src/app/(dashboard)/organizations/_components/organizations.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { "no-restricted-imports": { "count": 1 @@ -522,7 +536,7 @@ "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx": { + "src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": { "no-nested-ternary": { "count": 3 }, @@ -530,27 +544,27 @@ "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectKeysSection.tsx": { + "src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx": { + "src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": { "react-hooks/set-state-in-effect": { "count": 2 } }, - "src/app/(dashboard)/projects/components/ProjectsPage.tsx": { + "src/app/(dashboard)/projects/_components/ProjectsPage.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/add_prompt_form.tsx": { + "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/index.tsx": { + "src/app/(dashboard)/prompts/_components/index.tsx": { "no-nested-ternary": { "count": 1 }, @@ -561,17 +575,17 @@ "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/DeveloperMessageCard.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/DeveloperMessageCard.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/ModelConfigCard.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/PromptCodeSnippets.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx": { "no-nested-ternary": { "count": 1 }, @@ -582,32 +596,32 @@ "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/PromptEditorHeader.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/PromptMessagesCard.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptMessagesCard.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/PublishModal.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/PublishModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/ToolsCard.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/ToolsCard.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/VersionHistorySidePanel.test.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx": { "max-nested-callbacks": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/VersionHistorySidePanel.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx": { "no-nested-ternary": { "count": 1 }, @@ -615,22 +629,22 @@ "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/MessageInput.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageInput.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/index.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/index.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_editor_view/conversation_panel/useConversation.ts": { + "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/useConversation.ts": { "no-restricted-syntax": { "count": 1 } }, - "src/app/(dashboard)/prompts/components/prompt_info.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_info.tsx": { "no-nested-ternary": { "count": 3 }, @@ -641,7 +655,7 @@ "count": 2 } }, - "src/app/(dashboard)/prompts/components/prompt_table.tsx": { + "src/app/(dashboard)/prompts/_components/prompt_table.tsx": { "no-nested-ternary": { "count": 1 }, @@ -649,6 +663,14 @@ "count": 1 } }, + "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { + "no-nested-ternary": { + "count": 3 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": { "no-restricted-imports": { "count": 1 @@ -851,14 +873,6 @@ "count": 1 } }, - "src/components/AdminPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/CreateUserButton.tsx": { "no-restricted-imports": { "count": 1 @@ -1247,7 +1261,7 @@ "count": 1 } }, - "src/components/agents.tsx": { + "src/app/(dashboard)/agents/_components/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -1255,7 +1269,7 @@ "count": 1 } }, - "src/components/agents/add_agent_form.tsx": { + "src/app/(dashboard)/agents/_components/add_agent_form.tsx": { "no-nested-ternary": { "count": 3 }, @@ -1269,7 +1283,7 @@ "count": 1 } }, - "src/components/agents/agent_card_discovery.tsx": { + "src/app/(dashboard)/agents/_components/agent_card_discovery.tsx": { "react-hooks/refs": { "count": 3 }, @@ -1277,17 +1291,17 @@ "count": 1 } }, - "src/components/agents/agent_cost_view.tsx": { + "src/app/(dashboard)/agents/_components/agent_cost_view.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/agents/agent_form_fields.tsx": { + "src/app/(dashboard)/agents/_components/agent_form_fields.tsx": { "no-nested-ternary": { "count": 1 } }, - "src/components/agents/agent_info.tsx": { + "src/app/(dashboard)/agents/_components/agent_info.tsx": { "no-nested-ternary": { "count": 1 }, @@ -1298,12 +1312,12 @@ "count": 1 } }, - "src/components/agents/agent_virtual_keys.tsx": { + "src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx": { "no-nested-ternary": { "count": 1 } }, - "src/components/agents/dynamic_agent_form_fields.tsx": { + "src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx": { "no-nested-ternary": { "count": 2 } @@ -1520,35 +1534,27 @@ "count": 1 } }, - "src/components/general_settings.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 2 - } - }, - "src/components/guardrails.tsx": { + "src/app/(dashboard)/guardrails/_components/index.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/guardrails/GuardrailTestPanel.tsx": { + "src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/guardrails/GuardrailTestPlayground.tsx": { + "src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx": { "no-nested-ternary": { "count": 1 } }, - "src/components/guardrails/GuardrailTestResults.tsx": { + "src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/guardrails/TeamGuardrailsTab.tsx": { + "src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx": { "no-nested-ternary": { "count": 2 }, @@ -1556,7 +1562,7 @@ "count": 1 } }, - "src/components/guardrails/add_guardrail_form.tsx": { + "src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx": { "no-nested-ternary": { "count": 4 }, @@ -1567,7 +1573,7 @@ "count": 2 } }, - "src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx": { + "src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": { "no-nested-ternary": { "count": 1 }, @@ -1575,7 +1581,7 @@ "count": 1 } }, - "src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx": { + "src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx": { "no-nested-ternary": { "count": 3 }, @@ -1583,12 +1589,12 @@ "count": 1 } }, - "src/components/guardrails/content_filter/ContentFilterDisplay.tsx": { + "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/guardrails/content_filter/ContentFilterManager.tsx": { + "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx": { "max-params": { "count": 2 }, @@ -1596,7 +1602,7 @@ "count": 1 } }, - "src/components/guardrails/custom_code/CustomCodeModal.tsx": { + "src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": { "no-nested-ternary": { "count": 6 }, @@ -1607,7 +1613,7 @@ "count": 1 } }, - "src/components/guardrails/edit_guardrail_form.tsx": { + "src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx": { "no-restricted-imports": { "count": 1 }, @@ -1618,7 +1624,7 @@ "count": 1 } }, - "src/components/guardrails/guardrail_info.tsx": { + "src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": { "max-params": { "count": 1 }, @@ -1629,7 +1635,7 @@ "count": 3 } }, - "src/components/guardrails/guardrail_optional_params.tsx": { + "src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx": { "no-nested-ternary": { "count": 5 }, @@ -1637,7 +1643,7 @@ "count": 1 } }, - "src/components/guardrails/guardrail_provider_fields.tsx": { + "src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx": { "no-nested-ternary": { "count": 5 }, @@ -1645,7 +1651,7 @@ "count": 1 } }, - "src/components/guardrails/guardrail_table.tsx": { + "src/app/(dashboard)/guardrails/_components/guardrail_table.tsx": { "no-nested-ternary": { "count": 1 }, @@ -1653,7 +1659,7 @@ "count": 1 } }, - "src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx": { + "src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2028,11 +2034,6 @@ "count": 1 } }, - "src/components/organizations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/page_utils.test.ts": { "max-nested-callbacks": { "count": 3 @@ -2079,7 +2080,7 @@ "count": 1 } }, - "src/components/policies/add_attachment_form.tsx": { + "src/app/(dashboard)/policies/_components/add_attachment_form.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2087,7 +2088,7 @@ "count": 1 } }, - "src/components/policies/add_policy_form.tsx": { + "src/app/(dashboard)/policies/_components/add_policy_form.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2098,7 +2099,7 @@ "count": 1 } }, - "src/components/policies/ai_suggestion_modal.tsx": { + "src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx": { "no-nested-ternary": { "count": 10 }, @@ -2109,12 +2110,12 @@ "count": 1 } }, - "src/components/policies/attachment_table.test.tsx": { + "src/app/(dashboard)/policies/_components/attachment_table.test.tsx": { "react/display-name": { "count": 1 } }, - "src/components/policies/attachment_table.tsx": { + "src/app/(dashboard)/policies/_components/attachment_table.tsx": { "no-nested-ternary": { "count": 1 }, @@ -2122,7 +2123,7 @@ "count": 1 } }, - "src/components/policies/guardrail_selection_modal.tsx": { + "src/app/(dashboard)/policies/_components/guardrail_selection_modal.tsx": { "no-nested-ternary": { "count": 1 }, @@ -2130,12 +2131,12 @@ "count": 1 } }, - "src/components/policies/impact_popover.test.tsx": { + "src/app/(dashboard)/policies/_components/impact_popover.test.tsx": { "react/display-name": { "count": 1 } }, - "src/components/policies/impact_popover.tsx": { + "src/app/(dashboard)/policies/_components/impact_popover.tsx": { "no-nested-ternary": { "count": 1 }, @@ -2143,12 +2144,12 @@ "count": 1 } }, - "src/components/policies/index.test.tsx": { + "src/app/(dashboard)/policies/_components/index.test.tsx": { "react/display-name": { "count": 1 } }, - "src/components/policies/index.tsx": { + "src/app/(dashboard)/policies/_components/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2156,7 +2157,7 @@ "count": 1 } }, - "src/components/policies/pipeline_flow_builder.tsx": { + "src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx": { "no-nested-ternary": { "count": 1 }, @@ -2167,7 +2168,7 @@ "count": 2 } }, - "src/components/policies/policy_info.tsx": { + "src/app/(dashboard)/policies/_components/policy_info.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2175,12 +2176,12 @@ "count": 1 } }, - "src/components/policies/policy_table.test.tsx": { + "src/app/(dashboard)/policies/_components/policy_table.test.tsx": { "react/display-name": { "count": 1 } }, - "src/components/policies/policy_table.tsx": { + "src/app/(dashboard)/policies/_components/policy_table.tsx": { "no-nested-ternary": { "count": 2 }, @@ -2188,7 +2189,7 @@ "count": 1 } }, - "src/components/policies/policy_test_panel.tsx": { + "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2196,7 +2197,7 @@ "count": 1 } }, - "src/components/policies/template_parameter_modal.tsx": { + "src/app/(dashboard)/policies/_components/template_parameter_modal.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2271,17 +2272,17 @@ "count": 1 } }, - "src/components/tag_management/TagTable.tsx": { + "src/app/(dashboard)/tag-management/_components/TagTable.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/tag_management/components/CreateTagModal.tsx": { + "src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/tag_management/index.tsx": { + "src/app/(dashboard)/tag-management/_components/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2289,7 +2290,7 @@ "count": 1 } }, - "src/components/tag_management/tag_info.tsx": { + "src/app/(dashboard)/tag-management/_components/tag_info.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2371,17 +2372,6 @@ "count": 1 } }, - "src/components/usage.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/purity": { - "count": 1 - } - }, "src/components/user_agent_activity.tsx": { "no-restricted-imports": { "count": 2 @@ -2398,12 +2388,12 @@ "count": 2 } }, - "src/components/vector_store_management/CreateVectorStore.tsx": { + "src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/vector_store_management/VectorStoreForm.tsx": { + "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { "no-nested-ternary": { "count": 2 }, @@ -2414,12 +2404,12 @@ "count": 1 } }, - "src/components/vector_store_management/VectorStoreTable.tsx": { + "src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/vector_store_management/index.tsx": { + "src/app/(dashboard)/vector-stores/_components/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -2427,7 +2417,7 @@ "count": 1 } }, - "src/components/vector_store_management/vector_store_info.tsx": { + "src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx": { "no-restricted-imports": { "count": 1 }, diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index ae3660f59e9..56c0a4f9500 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -34,6 +34,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" @@ -2927,6 +2928,32 @@ "npm": ">=9.5.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", @@ -3284,6 +3311,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3804,6 +3843,42 @@ "react-dom": ">=16.6.0" } }, + "node_modules/@tremor/react/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tremor/react/node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -3814,6 +3889,28 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/@tremor/react/node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -4069,6 +4166,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -6309,6 +6412,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -6872,9 +6985,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { @@ -6901,9 +7014,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -7688,6 +7801,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11589,7 +11712,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/react-json-view-lite": { @@ -11631,6 +11753,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -11707,26 +11852,33 @@ } }, "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", "license": "MIT", + "workspaces": [ + "www" + ], "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/recharts-scale": { @@ -11738,12 +11890,6 @@ "decimal.js-light": "^2.4.1" } }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11758,6 +11904,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13429,9 +13590,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 1b0ce315e4d..1747a40da56 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -50,6 +50,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupBaseForm.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupCreateModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupCreateModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupEditModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/types.ts rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx index ae4712b826e..4e9f7031c6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AccessGroupsPage } from "./components/AccessGroupsPage"; +import { AccessGroupsPage } from "./_components/AccessGroupsPage"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function AccessGroups() { diff --git a/ui/litellm-dashboard/src/components/AdminPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/AdminPanel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx index 7d1d2f46cf1..220db23338e 100644 --- a/ui/litellm-dashboard/src/components/AdminPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx @@ -8,34 +8,34 @@ const mockGetAllowedIPs = vi.fn(); const mockAddAllowedIP = vi.fn(); const mockDeleteAllowedIP = vi.fn(); -vi.mock("./networking", () => ({ +vi.mock("@/components/networking", () => ({ getSSOSettings: (...args: unknown[]) => mockGetSSOSettings(...args), getAllowedIPs: (...args: unknown[]) => mockGetAllowedIPs(...args), addAllowedIP: (...args: unknown[]) => mockAddAllowedIP(...args), deleteAllowedIP: (...args: unknown[]) => mockDeleteAllowedIP(...args), })); -vi.mock("./constants", () => ({ +vi.mock("@/components/constants", () => ({ useBaseUrl: () => "http://localhost:4000", })); -vi.mock("./Settings/AdminSettings/SSOSettings/SSOSettings", () => ({ +vi.mock("@/components/Settings/AdminSettings/SSOSettings/SSOSettings", () => ({ default: () =>
SSO Settings
, })); -vi.mock("./Settings/AdminSettings/UISettings/UISettings", () => ({ +vi.mock("@/components/Settings/AdminSettings/UISettings/UISettings", () => ({ default: () =>
UI Settings
, })); -vi.mock("./SCIM", () => ({ +vi.mock("@/components/SCIM", () => ({ default: () =>
SCIM Config
, })); -vi.mock("./SSOModals", () => ({ +vi.mock("@/components/SSOModals", () => ({ default: () =>
SSO Modals
, })); -vi.mock("./UIAccessControlForm", () => ({ +vi.mock("@/components/UIAccessControlForm", () => ({ default: () =>
UI Access Control Form
, })); diff --git a/ui/litellm-dashboard/src/components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx similarity index 93% rename from ui/litellm-dashboard/src/components/AdminPanel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 7867c184ed2..611efd6a588 100644 --- a/ui/litellm-dashboard/src/components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -16,18 +16,18 @@ import { } from "@tremor/react"; import { Alert, Button as Button2, Form, Input, Modal, Space, Tabs, Typography } from "antd"; import React, { useEffect, useState } from "react"; -import NewBadge from "./common_components/NewBadge"; -import { useBaseUrl } from "./constants"; -import NotificationsManager from "./molecules/notifications_manager"; -import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./networking"; -import SCIMConfig from "./SCIM"; -import LoggingSettings from "./Settings/AdminSettings/LoggingSettings/LoggingSettings"; -import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings"; -import UISettings from "./Settings/AdminSettings/UISettings/UISettings"; -import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault"; -import PluginSettings from "./Settings/AdminSettings/PluginSettings/PluginSettings"; -import SSOModals from "./SSOModals"; -import UIAccessControlForm from "./UIAccessControlForm"; +import NewBadge from "@/components/common_components/NewBadge"; +import { useBaseUrl } from "@/components/constants"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "@/components/networking"; +import SCIMConfig from "@/components/SCIM"; +import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings"; +import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings"; +import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings"; +import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault"; +import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings"; +import SSOModals from "@/components/SSOModals"; +import UIAccessControlForm from "@/components/UIAccessControlForm"; const { Title, Paragraph, Text } = Typography; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx index aac835b02fc..47076acc9f0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx @@ -1,6 +1,6 @@ "use client"; -import AdminPanel from "@/components/AdminPanel"; +import AdminPanel from "./_components/AdminPanel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/agents/add_agent_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 5b146adeebe..8ca2b5afe16 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -4,7 +4,7 @@ import MessageManager from "@/components/molecules/message_manager"; import { resolveLogoSrc } from "@/lib/assetPaths"; import { Button } from "@tremor/react"; import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons"; -import CreatedKeyDisplay from "../shared/CreatedKeyDisplay"; +import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay"; import { createAgentCall, getAgentCreateMetadata, @@ -14,19 +14,19 @@ import { keyUpdateCall, modelAvailableCall, AgentCreateInfo, -} from "../networking"; +} from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; -import { Team } from "../key_team_helpers/key_list"; -import TeamDropdown from "../common_components/team_dropdown"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import { Team } from "@/components/key_team_helpers/key_list"; +import TeamDropdown from "@/components/common_components/team_dropdown"; import AgentFormFields from "./agent_form_fields"; import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery"; import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; -import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; -import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; -import GuardrailSelector from "../guardrails/GuardrailSelector"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; +import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; const { Step } = Steps; diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx index 3a9b7cbb41c..dc70aaa409b 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx @@ -2,18 +2,18 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders } from "@/../tests/test-utils"; import AgentCardDiscovery from "./agent_card_discovery"; -vi.mock("../networking", async () => { - const actual = await vi.importActual("../networking"); +vi.mock("@/components/networking", async () => { + const actual = await vi.importActual("@/components/networking"); return { ...actual, discoverAgentCardCall: vi.fn(), }; }); -import { discoverAgentCardCall } from "../networking"; +import { discoverAgentCardCall } from "@/components/networking"; const mockDiscover = discoverAgentCardCall as unknown as ReturnType; diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx index 87effb95278..5ea7458f643 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx @@ -11,7 +11,7 @@ import { SearchOutlined, } from "@ant-design/icons"; -import { DiscoveredAgentCard, discoverAgentCardCall } from "../networking"; +import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking"; import { ALLOWED_CAPABILITY_KEYS, selectionsFromSavedAgentCard, diff --git a/ui/litellm-dashboard/src/components/agents/agent_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts similarity index 100% rename from ui/litellm-dashboard/src/components/agents/agent_config.ts rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts diff --git a/ui/litellm-dashboard/src/components/agents/agent_cost_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_cost_view.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/agents/agent_cost_view.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_cost_view.tsx index 04837fe823f..e5d88ecc721 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_cost_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_cost_view.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Title } from "@tremor/react"; import { Descriptions } from "antd"; -import { Agent } from "./types"; +import { Agent } from "@/components/agents/types"; interface AgentCostViewProps { agent: Agent; diff --git a/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts similarity index 100% rename from ui/litellm-dashboard/src/components/agents/agent_discovery_utils.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts diff --git a/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts similarity index 99% rename from ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts index 8040bb3089b..fd34ec471eb 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts @@ -1,4 +1,4 @@ -import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "../networking"; +import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking"; export interface DiscoveryRequestPlan { url: string; diff --git a/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx diff --git a/ui/litellm-dashboard/src/components/agents/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/agents/agent_info.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx index 46ffa9b5ccf..d82ef82df8c 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx @@ -3,11 +3,11 @@ import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabP import { Form, Input, InputNumber, Button as AntButton, Spin, Descriptions, Divider } from "antd"; import MessageManager from "@/components/molecules/message_manager"; import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking"; -import { Agent } from "./types"; -import { KeyResponse } from "../key_team_helpers/key_list"; +import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking"; +import { Agent } from "@/components/agents/types"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import KeyInfoView from "../templates/key_info_view"; +import KeyInfoView from "@/components/templates/key_info_view"; import AgentVirtualKeys from "./agent_virtual_keys"; import AgentFormFields from "./agent_form_fields"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; diff --git a/ui/litellm-dashboard/src/components/agents/agent_type_utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_type_utils.ts similarity index 95% rename from ui/litellm-dashboard/src/components/agents/agent_type_utils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_type_utils.ts index 5bcdd0abe64..f91590c5732 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_type_utils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_type_utils.ts @@ -1,5 +1,5 @@ -import { Agent } from "./types"; -import { AgentCreateInfo } from "../networking"; +import { Agent } from "@/components/agents/types"; +import { AgentCreateInfo } from "@/components/networking"; /** * Detects the agent type from an agent's litellm_params. diff --git a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.test.tsx similarity index 93% rename from ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.test.tsx index 9a58ad557a2..ce13c22dfbf 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.test.tsx @@ -2,9 +2,9 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders } from "@/../tests/test-utils"; import AgentVirtualKeys from "./agent_virtual_keys"; -import type { KeyResponse } from "../key_team_helpers/key_list"; +import type { KeyResponse } from "@/components/key_team_helpers/key_list"; const makeKey = (overrides: Partial): KeyResponse => ({ diff --git a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx index b2ddf5efb2a..9a7001ae941 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_virtual_keys.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button, Tooltip, Typography } from "antd"; import { KeyOutlined } from "@ant-design/icons"; -import { KeyResponse } from "../key_team_helpers/key_list"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; const { Title, Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/agents/cost_config_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/cost_config_fields.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/agents/cost_config_fields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/cost_config_fields.tsx diff --git a/ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx index 3edea4362c9..435e628bfc0 100644 --- a/ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Form, Input, Select, Collapse } from "antd"; -import { AgentCreateInfo, AgentCredentialFieldMetadata } from "../networking"; +import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking"; import { AGENT_FORM_CONFIG } from "./agent_config"; import CostConfigFields from "./cost_config_fields"; diff --git a/ui/litellm-dashboard/src/components/agents.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.test.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/agents.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.test.tsx index 848b8d5e891..6228253222a 100644 --- a/ui/litellm-dashboard/src/components/agents.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.test.tsx @@ -1,19 +1,19 @@ import React from "react"; import { render, screen, waitFor, act, fireEvent, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import AgentsPanel from "./agents"; -import * as networking from "./networking"; +import AgentsPanel from "./index"; +import * as networking from "@/components/networking"; -vi.mock("./networking", () => ({ +vi.mock("@/components/networking", () => ({ getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), deleteAgentCall: vi.fn(), })); -vi.mock("./agents/add_agent_form", () => ({ +vi.mock("./add_agent_form", () => ({ default: () =>
, })); -vi.mock("./agents/agent_info", () => ({ +vi.mock("./agent_info", () => ({ default: () =>
, })); diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.tsx similarity index 94% rename from ui/litellm-dashboard/src/components/agents.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.tsx index e3703f6c588..84634620426 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/index.tsx @@ -13,15 +13,15 @@ import { } from "@tremor/react"; import { Modal, Alert, Tooltip, Skeleton, Switch } from "antd"; import { CheckCircleOutlined } from "@ant-design/icons"; -import { getAgentsList, deleteAgentCall } from "./networking"; -import AddAgentForm from "./agents/add_agent_form"; +import { getAgentsList, deleteAgentCall } from "@/components/networking"; +import AddAgentForm from "./add_agent_form"; import { isAdminRole } from "@/utils/roles"; -import AgentInfoView from "./agents/agent_info"; -import NotificationsManager from "./molecules/notifications_manager"; -import { Agent } from "./agents/types"; -import { Team } from "./key_team_helpers/key_list"; +import AgentInfoView from "./agent_info"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { Agent } from "@/components/agents/types"; +import { Team } from "@/components/key_team_helpers/key_list"; import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; interface AgentsPanelProps { accessToken: string | null; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/page.tsx index d60daae13a7..98085ab20a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/page.tsx @@ -1,6 +1,6 @@ "use client"; -import AgentsPanel from "@/components/agents"; +import AgentsPanel from "./_components"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx index a73973bd742..66fa0dfa63f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx @@ -2,7 +2,7 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import APIReferenceView from "./APIReferenceView"; -vi.mock("./components/CodeBlock", () => ({ +vi.mock("@/components/CodeBlock", () => ({ __esModule: true, default: ({ code }: { code: string }) =>
{code}
, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx similarity index 97% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx index 5861cc87e5b..333bd1cad13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx @@ -1,8 +1,8 @@ "use client"; import React from "react"; import { Text, Tab, TabGroup, TabList, TabPanel, TabPanels, Grid } from "@tremor/react"; -import CodeBlock from "./components/CodeBlock"; -import DocLink from "@/app/(dashboard)/api-reference/components/DocLink"; +import CodeBlock from "@/components/CodeBlock"; +import DocLink from "./DocLink"; interface ApiRefProps { proxySettings: { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/DocLink.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/DocLink.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/DocLink.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/DocLink.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx index 42cf094f0bb..d7c977b0870 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx @@ -1,6 +1,6 @@ "use client"; -import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView"; +import APIReferenceView from "./_components/APIReferenceView"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_modal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/constants.ts b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/constants.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/constants.ts rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/constants.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/edit_budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/edit_budget_modal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx index 547699411e7..ca34589a679 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import BudgetPanel from "./components/budget_panel"; +import BudgetPanel from "./_components/budget_panel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Budgets() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_dashboard.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_health.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFieldSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFieldSection.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFieldSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFieldSection.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFormField.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsFields.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/response_time_indicator.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/response_time_indicator.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/response_time_indicator.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/response_time_indicator.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx index 0ef88ec9eb5..33f3e81c689 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx @@ -1,6 +1,6 @@ "use client"; -import CacheDashboard from "./components/cache_dashboard"; +import CacheDashboard from "./_components/cache_dashboard"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Caching() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_margin_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_margin_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_margin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_margin_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_provider_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_provider_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_provider_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/add_provider_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/cost_tracking_settings.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.test.tsx index 711a8795f15..a574f4b628e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import HowItWorks from "./how_it_works"; -vi.mock("@/app/(dashboard)/api-reference/components/CodeBlock", () => ({ +vi.mock("@/components/CodeBlock", () => ({ default: ({ code }: { code: string }) =>
{code}
, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx index 79abf6baa31..5fa27551d16 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo } from "react"; import { Text, TextInput } from "@tremor/react"; -import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock"; +import CodeBlock from "@/components/CodeBlock"; const HowItWorks: React.FC = () => { const [responseCost, setResponseCost] = useState(""); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/index.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/index.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/index.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/index.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/index.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/index.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_cost_results.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_dropdown.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_utils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_utils.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_utils.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_utils.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_utils.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/multi_export_utils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_utils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/types.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/pricing_calculator/use_multi_cost_estimate.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_discount_table.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_discount_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_discount_table.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_display_helpers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_display_helpers.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_display_helpers.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_display_helpers.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_display_helpers.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_margin_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_margin_table.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_margin_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/provider_margin_table.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/types.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_discount_config.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_discount_config.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_discount_config.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_discount_config.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_discount_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_discount_config.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_margin_config.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_margin_config.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_margin_config.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_margin_config.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_margin_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_margin_config.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/use_margin_config.ts rename to ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_margin_config.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/page.tsx index c72fed4c594..6c251ca61d0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { CostTrackingSettings } from "./components"; +import { CostTrackingSettings } from "./_components"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function CostTracking() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx new file mode 100644 index 00000000000..dba34ea9a86 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import { ScoreChart } from "./ScoreChart"; + +describe("ScoreChart", () => { + it("should render the title", () => { + renderWithProviders(); + + expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); + }); + + it("should show empty state when no data is provided", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should show empty state when data is an empty array", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should render the chart when data is provided", () => { + const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, + ]; + + const { container } = renderWithProviders(); + + expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(screen.getByText("blocked")).toBeInTheDocument(); + expect(screen.getAllByText(/2026-03-01/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/2026-03-02/).length).toBeGreaterThan(0); + const bars = container.querySelectorAll(".recharts-bar"); + expect(bars).toHaveLength(2); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx new file mode 100644 index 00000000000..bc11a6fd3e0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { BarChart } from "@/components/shared/charts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +/** + * Overview chart: Request Outcomes Over Time (passed vs blocked). + * Stacked bar chart. Data from usage/overview API (chart array). + */ +interface ScoreChartProps { + data?: Array<{ date: string; passed: number; blocked: number }>; +} + +export function ScoreChart({ data }: ScoreChartProps) { + const chartData = data && data.length > 0 ? data : []; + + return ( + + + Request Outcomes Over Time + + +
+ {chartData.length > 0 ? ( + v.toLocaleString()} + yAxisWidth={48} + showLegend={true} + stack={true} + className="h-full" + /> + ) : ( +
+ No chart data for this period +
+ )} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx deleted file mode 100644 index 3a36eb9621e..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "../../../../../tests/test-utils"; -import { ScoreChart } from "./ScoreChart"; - -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - // Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level - // vi.mock fully replaces the setup-level mock, so without this the real Tremor Button - // leaks through and its useTooltip(300) schedules a native setTimeout that can fire - // post-teardown -> "window is not defined". - return { - ...actual, - BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => ( -
- {data.map((d, i) => ( - - {d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")} - - ))} -
- ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, - }; -}); - -describe("ScoreChart", () => { - it("should render the title", () => { - renderWithProviders(); - - expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); - }); - - it("should show empty state when no data is provided", () => { - renderWithProviders(); - - expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); - }); - - it("should show empty state when data is an empty array", () => { - renderWithProviders(); - - expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); - }); - - it("should render the chart when data is provided", () => { - const data = [ - { date: "2026-03-01", passed: 10, blocked: 2 }, - { date: "2026-03-02", passed: 15, blocked: 1 }, - ]; - - renderWithProviders(); - - expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); - expect(screen.getByText(/2026-03-01/)).toBeInTheDocument(); - expect(screen.getByText(/2026-03-02/)).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx deleted file mode 100644 index daa6054a552..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { BarChart, Card, Title } from "@tremor/react"; -import React from "react"; - -/** - * Overview chart: Request Outcomes Over Time (passed vs blocked). - * Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array). - */ -interface ScoreChartProps { - data?: Array<{ date: string; passed: number; blocked: number }>; -} - -export function ScoreChart({ data }: ScoreChartProps) { - const chartData = data && data.length > 0 ? data : []; - - return ( - - Request Outcomes Over Time -
- {chartData.length > 0 ? ( - v.toLocaleString()} - yAxisWidth={48} - showLegend={true} - stack={true} - /> - ) : ( -
- No chart data for this period -
- )} -
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx index 388ed168f17..0c4e69c2d80 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import GuardrailsMonitorView from "./components/GuardrailsMonitorView"; +import GuardrailsMonitorView from "./_components/GuardrailsMonitorView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function GuardrailsMonitor() { diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx index 81a9edb91c6..a65b980d8b8 100644 --- a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Button } from "@tremor/react"; import { Input, Typography, Tooltip } from "antd"; import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; -import NotificationsManager from "../molecules/notifications_manager"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import GuardrailTestResults from "./GuardrailTestResults"; const { TextArea } = Input; diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.test.tsx index 2a8d58a58cc..252b01dbc4b 100644 --- a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import GuardrailTestPlayground from "./GuardrailTestPlayground"; -vi.mock("../networking"); +vi.mock("@/components/networking"); Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx index 5774d078cbf..45612d9fba3 100644 --- a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestPlayground.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { Card, List, Empty, Spin, Input, Typography } from "antd"; import { ExperimentOutlined, SearchOutlined } from "@ant-design/icons"; import GuardrailTestPanel from "./GuardrailTestPanel"; -import { applyGuardrail } from "../networking"; -import NotificationsManager from "../molecules/notifications_manager"; +import { applyGuardrail } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; interface GuardrailItem { guardrail_id?: string; diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx index ff6fc5744b6..3c974de3632 100644 --- a/ui/litellm-dashboard/src/components/guardrails/GuardrailTestResults.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Button, Card } from "@tremor/react"; import { Typography } from "antd"; import { CopyOutlined, CheckCircleOutlined, ClockCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; -import NotificationsManager from "../molecules/notifications_manager"; +import NotificationsManager from "@/components/molecules/notifications_manager"; const { Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/guardrails/README.md b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/README.md similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/README.md rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/README.md diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.test.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.test.tsx index a13f12e4b42..8fc0d36c2b4 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { fireEvent, screen } from "@testing-library/react"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AddGuardrailForm from "./add_guardrail_form"; diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 847c3557280..9a45bf0be0d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -1,12 +1,12 @@ import { Form, Input, Modal, Select, Tag, Typography, Button } from "antd"; import React, { useEffect, useMemo, useState } from "react"; -import NotificationsManager from "../molecules/notifications_manager"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUISettings, modelAvailableCall, -} from "../networking"; +} from "@/components/networking"; import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; import { choiceToSkipSystemForCreate, diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CategoryTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/CategoryTable.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index 4e740b03b21..e3867156d01 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { Card, Typography, Select, Switch, Form, Space, InputNumber } from "antd"; -import { getMajorAirlines } from "../../networking"; +import { getMajorAirlines } from "@/components/networking"; const { Title, Text } = Typography; const { Option } = Select; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx index e646daf51fe..713694f98e7 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentCategoryConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Card, Typography, Select, Table, Tag, Collapse, Button } from "antd"; import { DeleteOutlined, PlusOutlined, FileTextOutlined } from "@ant-design/icons"; -import { getCategoryYaml } from "../../networking"; +import { getCategoryYaml } from "@/components/networking"; const { Title, Text } = Typography; const { Option } = Select; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx index 34f92b8618c..337520fac7d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx @@ -1,8 +1,8 @@ import React, { useState } from "react"; import { Typography, Space, Upload, Card, Button } from "antd"; import { PlusOutlined, UploadOutlined } from "@ant-design/icons"; -import { validateBlockedWordsFile } from "../../networking"; -import NotificationsManager from "../../molecules/notifications_manager"; +import { validateBlockedWordsFile } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import PatternModal from "./PatternModal"; import CustomPatternModal from "./CustomPatternModal"; import KeywordModal from "./KeywordModal"; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterDisplay.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterDisplay.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/CustomPatternModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/KeywordTable.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/PatternModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/PatternTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/content_filter/PatternTable.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx index 411c2ed13ae..79cd9e19e6f 100644 --- a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx @@ -11,8 +11,8 @@ import { UsergroupAddOutlined, ExportOutlined, } from "@ant-design/icons"; -import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "../../networking"; -import NotificationsManager from "../../molecules/notifications_manager"; +import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; const { Panel } = Collapse; const { TextArea } = Input; diff --git a/ui/litellm-dashboard/src/components/guardrails/custom_code/index.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/index.ts similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/custom_code/index.ts rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/index.ts diff --git a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx index 73857f85fb3..83c21718a6e 100644 --- a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx @@ -9,9 +9,9 @@ import { type SkipToolMessageChoice, } from "./guardrail_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; -import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "../networking"; +import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "@/components/networking"; import PiiConfiguration from "./pii_configuration"; -import NotificationsManager from "../molecules/notifications_manager"; +import NotificationsManager from "@/components/molecules/notifications_manager"; const { Title, Text } = Typography; const { Option } = Select; diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden_card.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_card.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden_card.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_card.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden_card.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_card.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden_card.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_card.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden_configs.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden_configs.ts rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden_data.ts rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_garden_detail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_garden_detail.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_info.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index e8fec98a40c..1941ec94a60 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -11,7 +11,7 @@ import { Badge, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, T import { Button, Divider, Form, Input, Select, Tooltip } from "antd"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; -import NotificationsManager from "../molecules/notifications_manager"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager"; import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal"; import { diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_optional_params.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_optional_params.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx index 061bff90045..8b119c4933c 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_optional_params.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Form, Select, Typography, Input, Button } from "antd"; -import NumericalInput from "../shared/numerical_input"; +import NumericalInput from "@/components/shared/numerical_input"; const { Title } = Typography; diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_provider_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_provider_fields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx index b9629749310..123aae0dd62 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_provider_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx @@ -6,8 +6,8 @@ import { populateGuardrailProviderMap, shouldRenderContentFilterConfigSettings, } from "./guardrail_info_helpers"; -import { getGuardrailProviderSpecificParams } from "../networking"; -import NumericalInput from "../shared/numerical_input"; +import { getGuardrailProviderSpecificParams } from "@/components/networking"; +import NumericalInput from "@/components/shared/numerical_input"; interface GuardrailProviderFieldsProps { selectedProvider: string | null; diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_table.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx index dcc67677357..70b5998ad95 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx @@ -1,7 +1,7 @@ import GuardrailTable from "./guardrail_table"; import { render } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { GuardrailDefinitionLocation } from "./types"; +import { GuardrailDefinitionLocation } from "@/components/guardrails/types"; describe("GuardrailTable", () => { it("should render", () => { const { getByText } = render( diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx index 99f6b2793fd..4ea4505f506 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx @@ -18,7 +18,7 @@ import { skipToolMessageToChoice, } from "./guardrail_info_helpers"; import EditGuardrailForm from "./edit_guardrail_form"; -import { Guardrail, GuardrailDefinitionLocation } from "./types"; +import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types"; interface GuardrailTableProps { guardrailsList: Guardrail[]; diff --git a/ui/litellm-dashboard/src/components/guardrails.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.test.tsx similarity index 85% rename from ui/litellm-dashboard/src/components/guardrails.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.test.tsx index 99c2474347e..ee33276cfd6 100644 --- a/ui/litellm-dashboard/src/components/guardrails.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.test.tsx @@ -1,19 +1,19 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import GuardrailsPanel from "./guardrails"; -import { getGuardrailsList } from "./networking"; +import GuardrailsPanel from "./index"; +import { getGuardrailsList } from "@/components/networking"; -vi.mock("./networking", () => ({ +vi.mock("@/components/networking", () => ({ getGuardrailsList: vi.fn(), deleteGuardrailCall: vi.fn(), })); -vi.mock("./guardrails/add_guardrail_form", () => ({ +vi.mock("./add_guardrail_form", () => ({ __esModule: true, default: () =>
Mock Add Guardrail Form
, })); -vi.mock("./guardrails/guardrail_table", () => ({ +vi.mock("./guardrail_table", () => ({ __esModule: true, default: ({ guardrailsList, onDeleteClick }: any) => (
@@ -30,17 +30,17 @@ vi.mock("./guardrails/guardrail_table", () => ({ ), })); -vi.mock("./guardrails/guardrail_info", () => ({ +vi.mock("./guardrail_info", () => ({ __esModule: true, default: () =>
Mock Guardrail Info View
, })); -vi.mock("./guardrails/GuardrailTestPlayground", () => ({ +vi.mock("./GuardrailTestPlayground", () => ({ __esModule: true, default: () =>
Mock Guardrail Test Playground
, })); -vi.mock("./guardrails/TeamGuardrailsTab", () => ({ +vi.mock("./TeamGuardrailsTab", () => ({ TeamGuardrailsTab: () =>
Mock Team Guardrails Tab
, })); @@ -48,7 +48,7 @@ vi.mock("@/utils/roles", () => ({ isAdminRole: vi.fn((role: string) => role === "admin"), })); -vi.mock("./guardrails/guardrail_info_helpers", () => ({ +vi.mock("./guardrail_info_helpers", () => ({ getGuardrailLogoAndName: vi.fn(() => ({ logo: null, displayName: "Test Provider", diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.tsx similarity index 87% rename from ui/litellm-dashboard/src/components/guardrails.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.tsx index 4a74989d142..af4c85d0b9f 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/index.tsx @@ -1,39 +1,25 @@ import React, { useState, useEffect } from "react"; import { Button, Dropdown, Tabs } from "antd"; import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons"; -import { getGuardrailsList, deleteGuardrailCall } from "./networking"; -import AddGuardrailForm from "./guardrails/add_guardrail_form"; -import GuardrailTable from "./guardrails/guardrail_table"; +import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking"; +import AddGuardrailForm from "./add_guardrail_form"; +import GuardrailTable from "./guardrail_table"; import { isAdminRole } from "@/utils/roles"; -import GuardrailInfoView from "./guardrails/guardrail_info"; -import GuardrailTestPlayground from "./guardrails/GuardrailTestPlayground"; -import NotificationsManager from "./molecules/notifications_manager"; -import { Guardrail, GuardrailDefinitionLocation } from "./guardrails/types"; -import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import { getGuardrailLogoAndName } from "./guardrails/guardrail_info_helpers"; -import { CustomCodeModal } from "./guardrails/custom_code"; -import GuardrailGarden from "./guardrails/guardrail_garden"; -import { TeamGuardrailsTab } from "./guardrails/TeamGuardrailsTab"; +import GuardrailInfoView from "./guardrail_info"; +import GuardrailTestPlayground from "./GuardrailTestPlayground"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { Guardrail } from "@/components/guardrails/types"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import { getGuardrailLogoAndName } from "./guardrail_info_helpers"; +import { CustomCodeModal } from "./custom_code"; +import GuardrailGarden from "./guardrail_garden"; +import { TeamGuardrailsTab } from "./TeamGuardrailsTab"; interface GuardrailsPanelProps { accessToken: string | null; userRole?: string; } -interface GuardrailItem { - guardrail_id?: string; - guardrail_name: string | null; - litellm_params: { - guardrail: string; - mode: string; - default_on: boolean; - }; - guardrail_info: Record | null; - created_at?: string; - updated_at?: string; - guardrail_definition_location: GuardrailDefinitionLocation; -} - interface GuardrailsResponse { guardrails: Guardrail[]; } diff --git a/ui/litellm-dashboard/src/components/guardrails/llm_judge/LLMJudgeFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/llm_judge/LLMJudgeFields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/pii_components.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.test.tsx similarity index 94% rename from ui/litellm-dashboard/src/components/guardrails/pii_components.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.test.tsx index 3accbf13bd1..2f839487111 100644 --- a/ui/litellm-dashboard/src/components/guardrails/pii_components.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.test.tsx @@ -1,7 +1,7 @@ import { render } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { CategoryFilter, QuickActions, PiiEntityList } from "./pii_components"; -import type { PiiEntityCategory } from "./types"; +import type { PiiEntityCategory } from "@/components/guardrails/types"; describe("CategoryFilter", () => { it("should render", () => { diff --git a/ui/litellm-dashboard/src/components/guardrails/pii_components.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/guardrails/pii_components.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx index 6d35122c69f..3043832ddc4 100644 --- a/ui/litellm-dashboard/src/components/guardrails/pii_components.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Typography, Select, Button, Checkbox, Tooltip, Tag } from "antd"; import { CloseOutlined, EyeInvisibleOutlined, StopOutlined, FilterOutlined } from "@ant-design/icons"; -import { PiiEntityCategory } from "./types"; +import { PiiEntityCategory } from "@/components/guardrails/types"; const { Text } = Typography; const { Option } = Select; diff --git a/ui/litellm-dashboard/src/components/guardrails/pii_configuration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_configuration.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/pii_configuration.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_configuration.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/pii_configuration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_configuration.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/guardrails/pii_configuration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_configuration.tsx index be2c9ff0298..14408b121ee 100644 --- a/ui/litellm-dashboard/src/components/guardrails/pii_configuration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_configuration.tsx @@ -1,7 +1,7 @@ import { Typography } from "antd"; import React, { useState } from "react"; import { CategoryFilter, PiiEntityList, QuickActions } from "./pii_components"; -import { PiiConfigurationProps } from "./types"; +import { PiiConfigurationProps } from "@/components/guardrails/types"; const { Title, Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/guardrails/prompt_security.png b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/prompt_security.png similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/prompt_security.png rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/prompt_security.png diff --git a/ui/litellm-dashboard/src/components/guardrails/tool_permission/ToolPermissionRulesEditor.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/tool_permission/ToolPermissionRulesEditor.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.test.tsx diff --git a/ui/litellm-dashboard/src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx index 4e7fa88f70f..2d378437371 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/page.tsx @@ -1,6 +1,6 @@ "use client"; -import GuardrailsPanel from "@/components/guardrails"; +import GuardrailsPanel from "./_components"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Guardrails() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryEditModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx index 031a027d518..b88996c5396 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { MemoryView } from "./components/MemoryView"; +import { MemoryView } from "./_components/MemoryView"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/usage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 91c12fd1fa2..01f8cb1cd45 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -14,9 +14,9 @@ import { import React, { useState, useEffect } from "react"; -import ViewUserSpend from "./view_user_spend"; -import { ProxySettings } from "./user_dashboard"; -import UsageDatePicker from "./shared/usage_date_picker"; +import ViewUserSpend from "@/components/view_user_spend"; +import { ProxySettings } from "@/components/user_dashboard"; +import UsageDatePicker from "@/components/shared/usage_date_picker"; import { Grid, Col, @@ -48,8 +48,8 @@ import { adminGlobalActivity, adminGlobalActivityPerModel, getProxyUISettings, -} from "./networking"; -import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView"; +} from "@/components/networking"; +import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx index cc1f2c35e44..138dd97e5e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx @@ -1,6 +1,6 @@ "use client"; -import Usage from "@/components/usage"; +import Usage from "./_components/usage"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; diff --git a/ui/litellm-dashboard/src/components/organizations.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx similarity index 87% rename from ui/litellm-dashboard/src/components/organizations.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx index 9be31be6170..75a6d30ac2e 100644 --- a/ui/litellm-dashboard/src/components/organizations.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx @@ -3,11 +3,11 @@ import { render } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; -vi.mock("./vector_store_management/VectorStoreSelector", () => ({ +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ __esModule: true, default: () => null, })); -vi.mock("./mcp_server_management/MCPServerSelector", () => ({ +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ __esModule: true, default: () => null, })); diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/organizations.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx index edebc17087a..d3af5b62668 100644 --- a/ui/litellm-dashboard/src/components/organizations.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx @@ -28,16 +28,21 @@ import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; -import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; -import { ModelSelect } from "./ModelSelect/ModelSelect"; -import NotificationsManager from "./molecules/notifications_manager"; -import { Organization, organizationCreateCall, organizationDeleteCall, organizationListCall } from "./networking"; -import OrganizationInfoView from "./organization/organization_view"; -import NumericalInput from "./shared/numerical_input"; -import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { + Organization, + organizationCreateCall, + organizationDeleteCall, + organizationListCall, +} from "@/components/networking"; +import OrganizationInfoView from "@/components/organization/organization_view"; +import NumericalInput from "@/components/shared/numerical_input"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; interface OrganizationsTableProps { userRole: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx index 87e0faf9cce..649e54f63eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import OrganizationsTable from "@/components/organizations"; +import OrganizationsTable from "./_components/organizations"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function OrganizationsPage() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx index 35f5dcf06c0..d4333b95c62 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx @@ -11,7 +11,7 @@ import { } from "@ant-design/icons"; import { Button, Input, Modal, Select, Spin, Tabs } from "antd"; import React, { useCallback, useEffect, useState } from "react"; -import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock"; +import CodeBlock from "@/components/CodeBlock"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { keyCreateCall, diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx index adbbb1b6997..6788e97d25d 100644 --- a/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx @@ -1,15 +1,15 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as networking from "../networking"; +import * as networking from "@/components/networking"; import AddAttachmentForm from "./add_attachment_form"; -import { Policy } from "./types"; +import { Policy } from "@/components/policies/types"; -vi.mock("../networking"); +vi.mock("@/components/networking"); -vi.mock("../molecules/notifications_manager", () => ({ +vi.mock("@/components/molecules/notifications_manager", () => ({ default: { success: vi.fn(), fromBackend: vi.fn(), error: vi.fn(), info: vi.fn() }, })); diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx index dd4d07b3f35..57203e9ccce 100644 --- a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect } from "react"; import { Modal, Form, Select, Radio, Divider, Typography } from "antd"; import { Button } from "@tremor/react"; -import { Policy } from "./types"; -import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "../networking"; -import NotificationsManager from "../molecules/notifications_manager"; +import { Policy } from "@/components/policies/types"; +import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { buildAttachmentData } from "./build_attachment_data"; import { getInvalidTeamEntries } from "./scope_validation"; diff --git a/ui/litellm-dashboard/src/components/policies/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/policies/add_policy_form.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 744f77c74a7..8da2d3af36f 100644 --- a/ui/litellm-dashboard/src/components/policies/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from "react"; import { Form, Select, Modal, Divider, Typography, Tag, Alert, Radio } from "antd"; import { Button, TextInput, Textarea } from "@tremor/react"; -import { Policy, PolicyCreateRequest, PolicyUpdateRequest } from "./types"; -import { Guardrail } from "../guardrails/types"; -import { getResolvedGuardrails, modelAvailableCall } from "../networking"; -import NotificationsManager from "../molecules/notifications_manager"; +import { Policy, PolicyCreateRequest, PolicyUpdateRequest } from "@/components/policies/types"; +import { Guardrail } from "@/components/guardrails/types"; +import { getResolvedGuardrails, modelAvailableCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const { Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/policies/ai_suggestion_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/policies/ai_suggestion_modal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx index 3727db4d9cf..34204624702 100644 --- a/ui/litellm-dashboard/src/components/policies/ai_suggestion_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx @@ -8,7 +8,12 @@ import { DownOutlined, RightOutlined, } from "@ant-design/icons"; -import { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } from "../networking"; +import { + suggestPolicyTemplates, + modelHubCall, + testPolicyTemplate, + enrichPolicyTemplateStream, +} from "@/components/networking"; const { TextArea } = Input; const { Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx index 0cfd4e41e0a..c53881e5cce 100644 --- a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx @@ -1,10 +1,10 @@ import React from "react"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AttachmentTable from "./attachment_table"; -import { PolicyAttachment } from "./types"; +import { PolicyAttachment } from "@/components/policies/types"; vi.mock("./impact_popover", () => ({ default: () =>