From 495ce34165b9118fa5c69fa18619ef0fceb891d3 Mon Sep 17 00:00:00 2001 From: jo-nike Date: Sat, 14 Feb 2026 02:08:36 -0500 Subject: [PATCH] fix(responses-bridge): extract list-format system content into instructions (#21192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Access groups UI * new badge changes * adding tests * fix: add custom_body parameter to endpoint_func in create_pass_through_route (#20849) * fix: add custom_body parameter to endpoint_func in create_pass_through_route The bedrock_proxy_route calls `endpoint_func(custom_body=data)` to pass a pre-parsed, SigV4-signed request body. However, the `endpoint_func` closure created by `create_pass_through_route` does not accept a `custom_body` keyword argument, causing: TypeError: endpoint_func() got an unexpected keyword argument 'custom_body' Add `custom_body: Optional[dict] = None` to both `endpoint_func` definitions (adapter-based and URL-based). In the URL-based path, when `custom_body` is provided by the caller, use it instead of re-parsing the body from the raw request. Fixes #16999 * Add tests for custom_body handling in create_pass_through_route Address reviewer feedback on PR #20849: - Document why the adapter-based endpoint_func accepts custom_body for signature compatibility but does not forward it (the underlying chat_completion_pass_through_endpoint does not support it). - Add test_create_pass_through_route_custom_body_url_target: verifies that when a caller (e.g. bedrock_proxy_route) supplies custom_body, it takes precedence over the body parsed from the raw request. - Add test_create_pass_through_route_no_custom_body_falls_back: verifies that the default path (no custom_body) correctly uses the request-parsed body, preserving existing behavior. Both tests are fully mocked following the project's CONTRIBUTING.md guidelines and the patterns established in the existing test file. Co-authored-by: Cursor --------- Co-authored-by: themavik Co-authored-by: Cursor * change to model name for backwards compat * addressing comments * allow editing of access group names * fix: populate identity fields in proxy admin JWT early-return path (#21169) * fix: populate identity fields in proxy admin JWT early-return path When is_proxy_admin is True, the UserAPIKeyAuth early-return now includes user_id, team_id, team_alias, team_metadata, org_id, and end_user_id resolved from the JWT. Previously only user_role and parent_otel_span were set, causing blank Team Name and Internal User in Request Logs UI. * test: add unit tests for proxy admin JWT identity fields * bump: version 0.4.36 → 0.4.37 * migration + build files * Add pyroscope for observability (#21167) * Pyroscope: require PYROSCOPE_APP_NAME and PYROSCOPE_SERVER_ADDRESS, add UTF-8 locale hint - No defaults for PYROSCOPE_APP_NAME or PYROSCOPE_SERVER_ADDRESS; fail at startup if unset when Pyroscope is enabled - Set LANG/LC_ALL to C.UTF-8 when unset to reduce malformed_profile (invalid UTF-8) rejections - Startup message suggests PYTHONUTF8=1 if server rejects profiles - Simplify LITELLM_ENABLE_PYROSCOPE in config_settings; document Pyroscope env vars as required with no default - Add pyroscope_profiling to sidebar (Alerting & Monitoring) - pyproject.toml: pyroscope-io as required dep on non-Windows (marker), in proxy extra * proxy: add PYROSCOPE_SAMPLE_RATE env, use verbose logging, fix int type - Add optional PYROSCOPE_SAMPLE_RATE env (integer, no default) - Pass sample_rate to pyroscope.configure() as int for pyroscope-io - Replace print with verbose_proxy_logger (info/warning) - Document PYROSCOPE_SAMPLE_RATE in config_settings.md * Address Greptile PR feedback: Pyroscope optional, docs, tests, docstring - pyproject.toml: mark pyroscope-io as optional=true (proxy extra only) - Add docs/my-website/docs/proxy/pyroscope_profiling.md (fix broken sidebar link) - Add tests/test_litellm/proxy/test_pyroscope.py for _init_pyroscope() - proxy_server: fix _init_pyroscope docstring (required server/app name, sample rate as int) * Update litellm/proxy/proxy_server.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(model_info): Add missing tpm/rpm for Gemini models (#21175) Several Gemini models (TTS, native-audio, robotics, gemma) were missing tpm/rpm values, causing test_get_model_info_gemini to fail. Added conservative default values (tpm=250000, rpm=10) for preview models. gemini-2.5-flash-preview-tts gets tpm=4000000, rpm=10. Co-authored-by: OpenClaw * fix(ci): Fix ruff lint error - unused import in vertex_ai_ingestion (#21178) Co-authored-by: shin-bot-litellm * fix(ci): Fix mypy type errors across 6 files (#21179) - vertex_ai/gemini: fix TypedDict assignment via explicit dict cast - mcp_server: convert MutableMapping scope to dict for type safety - pass_through_endpoints: simplify custom_body logic to fix type narrowing - vector_store_endpoints: add Any annotation for dynamic hook return - responses transformation: use dict() for Reasoning and setattr for dynamic field - zscaler_ai_guard: add assert for api_base None check Co-authored-by: shin-bot-litellm * fix(ci): Fix E2E login button selector - use exact match (#21176) * fix(ci): Fix ruff lint error - unused import Remove unused 'cast' import in vertex_ai_ingestion.py (ruff F401) * fix(ci): Fix E2E login button selector - use exact match Login button selector now matches both 'Login' and 'Login with SSO', causing strict mode violation. Use { exact: true } to match only 'Login'. --------- Co-authored-by: OpenClaw * fix(mypy): Fix type errors across multiple files (#21180) - vertex_ai/gemini/transformation.py: Fix TypedDict assignment via dict alias - mcp_server/server.py: Convert ASGI scope to dict for type compatibility - pass_through_endpoints.py: Add explicit Optional[dict] type annotation - vector_store_endpoints/endpoints.py: Add Any type for dynamic proxy hook - responses transformation.py: Use dict(Reasoning()) and setattr for compatibility - zscaler_ai_guard.py: Add assert for api_base nullability Co-authored-by: OpenClaw * [Guardrails] Add guardrail pipeline support for conditional sequential execution (#21177) * Add pipeline type definitions for guardrail pipelines PipelineStep, GuardrailPipeline, PipelineStepResult, PipelineExecutionResult with validation for actions (allow/block/next/modify_response) and modes. * Export pipeline types from policy_engine types package * Add optional pipeline field to Policy model * Add pipeline executor for sequential guardrail execution * Parse pipeline config in policy registry * Add pipeline validation in policy validator * Add pipeline resolution and managed guardrail tracking * Resolve pipelines and exclude managed guardrails in pre-call * Integrate pipeline execution into proxy pre_call_hook * Add test guardrails for pipeline E2E testing * Add example pipeline config YAML * Add unit tests for pipeline type definitions * Add unit tests for pipeline executor * Update litellm/proxy/policy_engine/pipeline_executor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update litellm/proxy/utils.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Add pipeline flow builder UI for guardrail policies (#21188) * Add pipeline type definitions for guardrail pipelines PipelineStep, GuardrailPipeline, PipelineStepResult, PipelineExecutionResult with validation for actions (allow/block/next/modify_response) and modes. * Export pipeline types from policy_engine types package * Add optional pipeline field to Policy model * Add pipeline executor for sequential guardrail execution * Parse pipeline config in policy registry * Add pipeline validation in policy validator * Add pipeline resolution and managed guardrail tracking * Resolve pipelines and exclude managed guardrails in pre-call * Integrate pipeline execution into proxy pre_call_hook * Add test guardrails for pipeline E2E testing * Add example pipeline config YAML * Add unit tests for pipeline type definitions * Add unit tests for pipeline executor * Add pipeline column to LiteLLM_PolicyTable schema * Add pipeline field to policy CRUD request/response types * Add pipeline support to policy DB CRUD operations * Add PipelineStep and GuardrailPipeline TypeScript types * Add Zapier-style pipeline flow builder UI component * Integrate pipeline flow builder with mode toggle in policy form * Add pipeline display section to policy info view * Add unit tests for pipeline in policy CRUD types * Refactor policy form to show mode picker first with icon cards * Add full-screen FlowBuilderPage component for pipeline editing * Wire up full-screen flow builder in PoliciesPanel with edit routing * Restyle flow builder to match dev-tool UI aesthetic * Restyle flow builder cards to match reference design * Update step card to expanded layout with stacked ON PASS / ON FAIL sections * Add end card to flow builder showing return to normal control flow * Add PipelineTestRequest type for test-pipeline endpoint * Export PipelineTestRequest from policy_engine types * Add POST /policies/test-pipeline endpoint * Add testPipelineCall networking function * Add PipelineStepResult and PipelineTestResult types * Add test pipeline panel to flow builder with run button and results display * Fix pipeline executor: inject guardrail name into metadata so should_run_guardrail allows execution * Update litellm/proxy/policy_engine/pipeline_executor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update litellm/proxy/utils.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update litellm/proxy/policy_engine/policy_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update litellm/proxy/policy_engine/pipeline_executor.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(responses-bridge): extract list-format system content into instructions When system message content is a list of content blocks (e.g. [{"type": "text", "text": "..."}]) instead of a plain string, the responses API bridge was passing it through as a role: system message in the input items. APIs like ChatGPT Codex reject this with "System messages are not allowed". This happens when requests come through the Anthropic /v1/messages adapter, which converts system prompts into list-format content blocks in the OpenAI chat completions format. Fix: extract text from list content blocks and concatenate into the instructions parameter, matching the existing behavior for string system content. * test: add tests for system message extraction in responses bridge Add three tests for convert_chat_completion_messages_to_responses_api: - String system content → instructions - List-format content blocks → instructions (the bug this PR fixes) - Multiple system messages (mixed string and list) concatenated * fix: add warning log for unexpected system content types Address review feedback: add an else clause that logs a warning for any system content that is neither str nor list, rather than silently dropping it. --------- Co-authored-by: yuneng-jiang Co-authored-by: The Mavik <179817126+themavik@users.noreply.github.com> Co-authored-by: themavik Co-authored-by: Cursor Co-authored-by: Ishaan Jaff Co-authored-by: Alexsander Hamir Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: shin-bot-litellm Co-authored-by: OpenClaw Co-authored-by: shin-bot-litellm --- docs/my-website/docs/proxy/config_settings.md | 4 + .../docs/proxy/pyroscope_profiling.md | 43 + docs/my-website/sidebars.js | 3 +- ...tellm_proxy_extras-0.4.37-py3-none-any.whl | Bin 0 -> 55573 bytes .../dist/litellm_proxy_extras-0.4.37.tar.gz | Bin 0 -> 25050 bytes .../migration.sql | 3 - .../migration.sql | 3 + .../litellm_proxy_extras/schema.prisma | 2 +- litellm-proxy-extras/pyproject.toml | 4 +- .../transformation.py | 24 +- .../llms/vertex_ai/gemini/transformation.py | 7 +- .../proxy/_experimental/mcp_server/server.py | 2 +- litellm/proxy/auth/user_api_key_auth.py | 13 + .../pipeline_test_guardrails.py | 69 ++ .../test_pipeline_config.yaml | 64 ++ litellm/proxy/litellm_pre_call_utils.py | 24 +- .../access_group_endpoints.py | 21 +- .../pass_through_endpoints.py | 14 +- .../proxy/policy_engine/pipeline_executor.py | 216 ++++ .../proxy/policy_engine/policy_endpoints.py | 66 ++ .../proxy/policy_engine/policy_registry.py | 43 +- .../proxy/policy_engine/policy_resolver.py | 64 +- .../proxy/policy_engine/policy_validator.py | 57 +- litellm/proxy/proxy_server.py | 66 ++ litellm/proxy/schema.prisma | 3 +- litellm/proxy/utils.py | 112 +- .../proxy/vector_store_endpoints/endpoints.py | 4 +- litellm/rag/ingestion/vertex_ai_ingestion.py | 2 +- .../transformation.py | 4 +- litellm/types/access_group.py | 7 +- .../guardrail_hooks/zscaler_ai_guard.py | 1 + litellm/types/proxy/policy_engine/__init__.py | 14 + .../proxy/policy_engine/pipeline_types.py | 98 ++ .../types/proxy/policy_engine/policy_types.py | 8 +- .../proxy/policy_engine/resolver_types.py | 22 + model_prices_and_context_window.json | 32 +- poetry.lock | 22 +- pyproject.toml | 6 +- requirements.txt | 2 +- schema.prisma | 2 +- ...responses_transformation_transformation.py | 83 ++ .../proxy/auth/test_user_api_key_auth.py | 74 ++ .../test_access_group_endpoints.py | 63 +- .../test_pass_through_endpoints.py | 137 +++ .../policy_engine/test_pipeline_executor.py | 484 +++++++++ tests/test_litellm/proxy/test_pyroscope.py | 138 +++ tests/test_litellm/types/__init__.py | 0 tests/test_litellm/types/proxy/__init__.py | 0 .../types/proxy/policy_engine/__init__.py | 0 .../policy_engine/test_pipeline_types.py | 152 +++ .../policy_engine/test_resolver_types.py | 102 ++ ui/litellm-dashboard/e2e_tests/globalSetup.ts | 2 +- .../e2e_tests/tests/login/login.spec.ts | 2 +- .../accessGroups/useAccessGroupDetails.ts | 63 ++ .../accessGroups/useAccessGroups.test.ts | 242 +++++ .../hooks/accessGroups/useAccessGroups.ts | 70 ++ .../accessGroups/useCreateAccessGroup.ts | 68 ++ .../accessGroups/useDeleteAccessGroup.ts | 55 + .../hooks/accessGroups/useEditAccessGroup.ts | 77 ++ ui/litellm-dashboard/src/app/page.tsx | 3 + .../AccessGroupsDetailsPage.test.tsx | 384 +++++++ .../AccessGroups/AccessGroupsDetailsPage.tsx | 345 ++++++ .../AccessGroupsModal/AccessGroupBaseForm.tsx | 159 +++ .../AccessGroupCreateModal.tsx | 67 ++ .../AccessGroupEditModal.tsx | 85 ++ .../AccessGroups/AccessGroupsPage.test.tsx | 321 ++++++ .../AccessGroups/AccessGroupsPage.tsx | 401 +++++++ .../src/components/AccessGroups/types.ts | 46 + .../DefaultProxyAdminTag.tsx | 24 + .../src/components/leftnav.tsx | 17 +- .../src/components/networking.tsx | 31 + .../src/components/page_metadata.ts | 1 + .../components/policies/add_policy_form.tsx | 200 +++- .../src/components/policies/index.tsx | 36 +- .../policies/pipeline_flow_builder.tsx | 999 ++++++++++++++++++ .../src/components/policies/policy_info.tsx | 16 + .../src/components/policies/types.ts | 33 + .../SpendLogsSettingsModal.tsx | 3 +- .../src/components/view_logs/index.tsx | 5 +- 79 files changed, 6037 insertions(+), 102 deletions(-) create mode 100644 docs/my-website/docs/proxy/pyroscope_profiling.md create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37-py3-none-any.whl create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37.tar.gz delete mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260211181323_baseline_diff/migration.sql create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260213170952_access_group_change_to_model_name/migration.sql create mode 100644 litellm/proxy/example_config_yaml/pipeline_test_guardrails.py create mode 100644 litellm/proxy/example_config_yaml/test_pipeline_config.yaml create mode 100644 litellm/proxy/policy_engine/pipeline_executor.py create mode 100644 litellm/types/proxy/policy_engine/pipeline_types.py create mode 100644 tests/test_litellm/proxy/policy_engine/test_pipeline_executor.py create mode 100644 tests/test_litellm/proxy/test_pyroscope.py create mode 100644 tests/test_litellm/types/__init__.py create mode 100644 tests/test_litellm/types/proxy/__init__.py create mode 100644 tests/test_litellm/types/proxy/policy_engine/__init__.py create mode 100644 tests/test_litellm/types/proxy/policy_engine/test_pipeline_types.py create mode 100644 tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/types.ts create mode 100644 ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 847f8623e72..d563adcaa74 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -775,6 +775,10 @@ router_settings: | LITELLM_METER_NAME | Name for OTEL Meter | LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS | Optionally enable semantic logs for OTEL | LITELLM_OTEL_INTEGRATION_ENABLE_METRICS | Optionally enable emantic metrics for OTEL +| LITELLM_ENABLE_PYROSCOPE | If true, enables Pyroscope CPU profiling. Profiles are sent to PYROSCOPE_SERVER_ADDRESS. Off by default. See [Pyroscope profiling](/proxy/pyroscope_profiling). +| PYROSCOPE_APP_NAME | Application name reported to Pyroscope. Required when LITELLM_ENABLE_PYROSCOPE is true. No default. +| PYROSCOPE_SERVER_ADDRESS | Pyroscope server URL to send profiles to. Required when LITELLM_ENABLE_PYROSCOPE is true. No default. +| PYROSCOPE_SAMPLE_RATE | Optional. Sample rate for Pyroscope profiling (integer). No default; when unset, the pyroscope-io library default is used. | LITELLM_MASTER_KEY | Master key for proxy authentication | LITELLM_MODE | Operating mode for LiteLLM (e.g., production, development) | LITELLM_NON_ROOT | Flag to run LiteLLM in non-root mode for enhanced security in Docker containers diff --git a/docs/my-website/docs/proxy/pyroscope_profiling.md b/docs/my-website/docs/proxy/pyroscope_profiling.md new file mode 100644 index 00000000000..fa3db3a8782 --- /dev/null +++ b/docs/my-website/docs/proxy/pyroscope_profiling.md @@ -0,0 +1,43 @@ +# Grafana Pyroscope CPU profiling + +LiteLLM proxy can send continuous CPU profiles to [Grafana Pyroscope](https://grafana.com/docs/pyroscope/latest/) when enabled via environment variables. This is optional and off by default. + +## Quick start + +1. **Install the optional dependency** (required only when enabling Pyroscope): + + ```bash + pip install pyroscope-io + ``` + + Or install the proxy extra: + + ```bash + pip install "litellm[proxy]" + ``` + +2. **Set environment variables** before starting the proxy: + + | Variable | Required | Description | + |----------|----------|-------------| + | `LITELLM_ENABLE_PYROSCOPE` | Yes (to enable) | Set to `true` to enable Pyroscope profiling. | + | `PYROSCOPE_APP_NAME` | Yes (when enabled) | Application name shown in the Pyroscope UI. | + | `PYROSCOPE_SERVER_ADDRESS` | Yes (when enabled) | Pyroscope server URL (e.g. `http://localhost:4040`). | + | `PYROSCOPE_SAMPLE_RATE` | No | Sample rate (integer). If unset, the pyroscope-io library default is used. | + +3. **Start the proxy**; profiling will begin automatically when the proxy starts. + + ```bash + export LITELLM_ENABLE_PYROSCOPE=true + export PYROSCOPE_APP_NAME=litellm-proxy + export PYROSCOPE_SERVER_ADDRESS=http://localhost:4040 + litellm --config config.yaml + ``` + +4. **View profiles** in the Pyroscope (or Grafana) UI and select your `PYROSCOPE_APP_NAME`. + +## Notes + +- **Optional dependency**: `pyroscope-io` is an optional dependency. If it is not installed and `LITELLM_ENABLE_PYROSCOPE=true`, the proxy will log a warning and continue without profiling. +- **Platform support**: The `pyroscope-io` package uses a native extension and is not available on all platforms (e.g. Windows is excluded by the package). +- **Other settings**: See [Configuration settings](/proxy/config_settings) for all proxy environment variables. diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 9e2eb47f4c9..9b3581cce32 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -107,7 +107,8 @@ const sidebars = { items: [ "proxy/alerting", "proxy/pagerduty", - "proxy/prometheus" + "proxy/prometheus", + "proxy/pyroscope_profiling" ] }, { diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..695dc102c72c835c262553e208ff2134e1b379d6 GIT binary patch literal 55573 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 F_J4Pb5&!@I literal 0 HcmV?d00001 diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.37.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d3ecef1752eb84ba1a780f054e7dcbbc54b05905 GIT binary patch literal 25050 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 Ku>vCi5Az> None: """Pop extra_body from optional_params and shallow-merge into data, deep-merging dict values.""" extra_body: Optional[dict] = optional_params.pop("extra_body", None) if extra_body is not None: + data_dict: dict = data # type: ignore[assignment] for k, v in extra_body.items(): - if k in data and isinstance(data[k], dict) and isinstance(v, dict): - data[k].update(v) + if k in data_dict and isinstance(data_dict[k], dict) and isinstance(v, dict): + data_dict[k].update(v) else: - data[k] = v + data_dict[k] = v def _transform_request_body( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index ec31652aa54..ba107a9dd10 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -2029,7 +2029,7 @@ if MCP_AVAILABLE: # Inject masked debug headers when client sends x-litellm-mcp-debug: true _debug_headers = MCPDebug.maybe_build_debug_headers( raw_headers=raw_headers, - scope=scope, + scope=dict(scope), mcp_servers=mcp_servers, mcp_auth_header=mcp_auth_header, mcp_server_auth_headers=mcp_server_auth_headers, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ba4e3b42c37..f643f7205bf 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -585,7 +585,20 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 if is_proxy_admin: return UserAPIKeyAuth( + api_key=None, user_role=LitellmUserRoles.PROXY_ADMIN, + user_id=user_id, + team_id=team_id, + team_alias=( + team_object.team_alias + if team_object is not None + else None + ), + team_metadata=team_object.metadata + if team_object is not None + else None, + org_id=org_id, + end_user_id=end_user_id, parent_otel_span=parent_otel_span, ) diff --git a/litellm/proxy/example_config_yaml/pipeline_test_guardrails.py b/litellm/proxy/example_config_yaml/pipeline_test_guardrails.py new file mode 100644 index 00000000000..539a520fcef --- /dev/null +++ b/litellm/proxy/example_config_yaml/pipeline_test_guardrails.py @@ -0,0 +1,69 @@ +""" +Test guardrails for pipeline E2E testing. + +- StrictFilter: blocks any message containing "bad" (case-insensitive) +- PermissiveFilter: always passes (simulates an advanced guardrail that is more lenient) +""" + +from typing import Optional, Union + +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.caching.caching import DualCache +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.utils import CallTypesLiteral + + +class StrictFilter(CustomGuardrail): + """Blocks any message containing the word 'bad'.""" + + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict, + call_type: CallTypesLiteral, + ) -> Optional[Union[Exception, str, dict]]: + for msg in data.get("messages", []): + content = msg.get("content", "") + if isinstance(content, str) and "bad" in content.lower(): + verbose_proxy_logger.info("StrictFilter: BLOCKED - found 'bad'") + raise HTTPException( + status_code=400, + detail="StrictFilter: content contains forbidden word 'bad'", + ) + verbose_proxy_logger.info("StrictFilter: PASSED") + return data + + +class PermissiveFilter(CustomGuardrail): + """Always passes - simulates a lenient advanced guardrail.""" + + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict, + call_type: CallTypesLiteral, + ) -> Optional[Union[Exception, str, dict]]: + verbose_proxy_logger.info("PermissiveFilter: PASSED (always passes)") + return data + + +class AlwaysBlockFilter(CustomGuardrail): + """Always blocks - for testing full escalation->block path.""" + + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict, + call_type: CallTypesLiteral, + ) -> Optional[Union[Exception, str, dict]]: + verbose_proxy_logger.info("AlwaysBlockFilter: BLOCKED") + raise HTTPException( + status_code=400, + detail="AlwaysBlockFilter: all content blocked", + ) diff --git a/litellm/proxy/example_config_yaml/test_pipeline_config.yaml b/litellm/proxy/example_config_yaml/test_pipeline_config.yaml new file mode 100644 index 00000000000..d3a8c56b48a --- /dev/null +++ b/litellm/proxy/example_config_yaml/test_pipeline_config.yaml @@ -0,0 +1,64 @@ +model_list: + - model_name: fake-openai-endpoint + litellm_params: + model: openai/gpt-3.5-turbo + api_key: fake-key + api_base: https://exampleopenaiendpoint-production.up.railway.app/ + - model_name: fake-blocked-endpoint + litellm_params: + model: openai/gpt-3.5-turbo + api_key: fake-key + api_base: https://exampleopenaiendpoint-production.up.railway.app/ + +guardrails: + - guardrail_name: "strict-filter" + litellm_params: + guardrail: pipeline_test_guardrails.StrictFilter + mode: "pre_call" + - guardrail_name: "permissive-filter" + litellm_params: + guardrail: pipeline_test_guardrails.PermissiveFilter + mode: "pre_call" + - guardrail_name: "always-block-filter" + litellm_params: + guardrail: pipeline_test_guardrails.AlwaysBlockFilter + mode: "pre_call" + +policies: + # Pipeline: strict-filter fails -> escalate to permissive-filter + # If strict fails but permissive passes -> allow the request + content-safety-permissive: + description: "Multi-tier: strict filter with permissive fallback" + guardrails: + add: [strict-filter, permissive-filter] + pipeline: + mode: "pre_call" + steps: + - guardrail: strict-filter + on_fail: next # escalate to permissive + on_pass: allow # clean content proceeds + - guardrail: permissive-filter + on_fail: block # hard block + on_pass: allow # permissive says OK + + # Pipeline: strict-filter fails -> escalate to always-block + # Both fail -> block + content-safety-strict: + description: "Multi-tier: strict filter with strict fallback (both block)" + guardrails: + add: [strict-filter, always-block-filter] + pipeline: + mode: "pre_call" + steps: + - guardrail: strict-filter + on_fail: next + on_pass: allow + - guardrail: always-block-filter + on_fail: block + on_pass: allow + +policy_attachments: + - policy: content-safety-permissive + models: [fake-openai-endpoint] + - policy: content-safety-strict + models: [fake-blocked-endpoint] diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 49d31c1efec..fa024cc33d4 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1642,20 +1642,40 @@ def add_guardrails_from_policy_engine( f"Policy engine: resolved guardrails: {resolved_guardrails}" ) - if not resolved_guardrails: - return + # Resolve pipelines from matching policies + pipelines = PolicyResolver.resolve_pipelines_for_context(context=context) # Add resolved guardrails to request metadata if metadata_variable_name not in data: data[metadata_variable_name] = {} + # Track pipeline-managed guardrails to exclude from independent execution + pipeline_managed_guardrails: set = set() + if pipelines: + pipeline_managed_guardrails = PolicyResolver.get_pipeline_managed_guardrails( + pipelines + ) + data[metadata_variable_name]["_guardrail_pipelines"] = pipelines + data[metadata_variable_name]["_pipeline_managed_guardrails"] = ( + pipeline_managed_guardrails + ) + verbose_proxy_logger.debug( + f"Policy engine: resolved {len(pipelines)} pipeline(s), " + f"managed guardrails: {pipeline_managed_guardrails}" + ) + + if not resolved_guardrails and not pipelines: + return + existing_guardrails = data[metadata_variable_name].get("guardrails", []) if not isinstance(existing_guardrails, list): existing_guardrails = [] # Combine existing guardrails with policy-resolved guardrails (no duplicates) + # Exclude pipeline-managed guardrails from the flat list combined = set(existing_guardrails) combined.update(resolved_guardrails) + combined -= pipeline_managed_guardrails data[metadata_variable_name]["guardrails"] = list(combined) verbose_proxy_logger.debug( diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 33b81e85654..100b1d2659b 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -31,7 +31,7 @@ def _record_to_response(record) -> AccessGroupResponse: access_group_id=record.access_group_id, access_group_name=record.access_group_name, description=record.description, - access_model_ids=record.access_model_ids, + access_model_names=record.access_model_names, access_mcp_server_ids=record.access_mcp_server_ids, access_agent_ids=record.access_agent_ids, assigned_team_ids=record.assigned_team_ids, @@ -69,7 +69,7 @@ async def create_access_group( data={ "access_group_name": data.access_group_name, "description": data.description, - "access_model_ids": data.access_model_ids or [], + "access_model_names": data.access_model_names or [], "access_mcp_server_ids": data.access_mcp_server_ids or [], "access_agent_ids": data.access_agent_ids or [], "assigned_team_ids": data.assigned_team_ids or [], @@ -153,10 +153,19 @@ async def update_access_group( for field, value in data.model_dump(exclude_unset=True).items(): update_data[field] = value - record = await prisma_client.db.litellm_accessgrouptable.update( - where={"access_group_id": access_group_id}, - data=update_data, - ) + try: + record = await prisma_client.db.litellm_accessgrouptable.update( + where={"access_group_id": access_group_id}, + data=update_data, + ) + except Exception as e: + # Unique constraint violation (e.g. access_group_name already exists). + if "unique constraint" in str(e).lower() or "P2002" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Access group '{update_data.get('access_group_name', '')}' already exists", + ) + raise return _record_to_response(record) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index a7b60c8b185..56b513554a8 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1099,6 +1099,7 @@ def create_pass_through_route( fastapi_response: Response, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), subpath: str = "", # captures sub-paths when include_subpath=True + custom_body: Optional[dict] = None, # accepted for signature compatibility with URL-based path; not forwarded because chat_completion_pass_through_endpoint does not support it ): return await chat_completion_pass_through_endpoint( fastapi_response=fastapi_response, @@ -1115,6 +1116,7 @@ def create_pass_through_route( fastapi_response: Response, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), subpath: str = "", # captures sub-paths when include_subpath=True + custom_body: Optional[dict] = None, ): from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( InitPassThroughEndpointHelpers, @@ -1189,11 +1191,13 @@ def create_pass_through_route( ) if query_params: final_query_params.update(query_params) - final_custom_body = ( - custom_body_data - if isinstance(custom_body_data, dict) or custom_body_data is None - else None - ) + # When a caller (e.g. bedrock_proxy_route) supplies a pre-built + # body, use it instead of the body parsed from the raw request. + final_custom_body: Optional[dict] = None + if custom_body is not None: + final_custom_body = custom_body + elif isinstance(custom_body_data, dict): + final_custom_body = custom_body_data return await pass_through_request( # type: ignore request=request, diff --git a/litellm/proxy/policy_engine/pipeline_executor.py b/litellm/proxy/policy_engine/pipeline_executor.py new file mode 100644 index 00000000000..b3982678d37 --- /dev/null +++ b/litellm/proxy/policy_engine/pipeline_executor.py @@ -0,0 +1,216 @@ +""" +Pipeline Executor - Executes guardrail pipelines with conditional step logic. + +Runs guardrails sequentially per pipeline step definitions, handling +pass/fail actions (allow, block, next, modify_response) and data forwarding. +""" + +import time +from typing import Any, List, Optional + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, +) +from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( + UnifiedLLMGuardrails, +) +from litellm.types.proxy.policy_engine.pipeline_types import ( + PipelineExecutionResult, + PipelineStep, + PipelineStepResult, +) + +try: + from fastapi.exceptions import HTTPException +except ImportError: + HTTPException = None # type: ignore + + +class PipelineExecutor: + """Executes guardrail pipelines with ordered, conditional step logic.""" + + @staticmethod + async def execute_steps( + steps: List[PipelineStep], + mode: str, + data: dict, + user_api_key_dict: Any, + call_type: str, + policy_name: str, + ) -> PipelineExecutionResult: + """ + Execute pipeline steps sequentially with conditional actions. + + Args: + steps: Ordered list of pipeline steps + mode: Event hook mode (pre_call, post_call) + data: Request data dict + user_api_key_dict: User API key auth + call_type: Type of call (completion, etc.) + policy_name: Name of the owning policy (for logging) + + Returns: + PipelineExecutionResult with terminal action and step results + """ + step_results: List[PipelineStepResult] = [] + working_data = data.copy() + if "metadata" in working_data: + working_data["metadata"] = working_data["metadata"].copy() + + for i, step in enumerate(steps): + start_time = time.perf_counter() + + outcome, modified_data, error_detail = await PipelineExecutor._run_step( + step=step, + mode=mode, + data=working_data, + user_api_key_dict=user_api_key_dict, + call_type=call_type, + ) + + duration = time.perf_counter() - start_time + + action = step.on_pass if outcome == "pass" else step.on_fail + + step_result = PipelineStepResult( + guardrail_name=step.guardrail, + outcome=outcome, + action_taken=action, + modified_data=modified_data, + error_detail=error_detail, + duration_seconds=round(duration, 4), + ) + step_results.append(step_result) + + verbose_proxy_logger.debug( + f"Pipeline '{policy_name}' step {i}: guardrail={step.guardrail}, " + f"outcome={outcome}, action={action}" + ) + + # Forward modified data to next step if pass_data is True + if step.pass_data and modified_data is not None: + working_data = {**working_data, **modified_data} + + # Handle terminal actions + if action == "allow": + return PipelineExecutionResult( + terminal_action="allow", + step_results=step_results, + modified_data=working_data if working_data != data else None, + ) + + if action == "block": + return PipelineExecutionResult( + terminal_action="block", + step_results=step_results, + error_message=error_detail, + ) + + if action == "modify_response": + return PipelineExecutionResult( + terminal_action="modify_response", + step_results=step_results, + modify_response_message=step.modify_response_message or error_detail, + ) + + # action == "next" → continue to next step + + # Ran out of steps without a terminal action → default allow + return PipelineExecutionResult( + terminal_action="allow", + step_results=step_results, + modified_data=working_data if working_data != data else None, + ) + + @staticmethod + async def _run_step( + step: PipelineStep, + mode: str, + data: dict, + user_api_key_dict: Any, + call_type: str, + ) -> tuple: + """ + Run a single pipeline step's guardrail. + + Returns: + Tuple of (outcome, modified_data, error_detail) where: + - outcome: "pass", "fail", or "error" + - modified_data: dict if guardrail returned modified data, else None + - error_detail: error message string if fail/error, else None + """ + callback = PipelineExecutor._find_guardrail_callback(step.guardrail) + if callback is None: + verbose_proxy_logger.warning( + f"Pipeline: guardrail '{step.guardrail}' not found in callbacks" + ) + return ("error", None, f"Guardrail '{step.guardrail}' not found") + + try: + # Inject guardrail name into metadata so should_run_guardrail() allows it + if "metadata" not in data: + data["metadata"] = {} + original_guardrails = data["metadata"].get("guardrails") + data["metadata"]["guardrails"] = [step.guardrail] + + # Use unified_guardrail path if callback implements apply_guardrail + target = callback + use_unified = "apply_guardrail" in type(callback).__dict__ + if use_unified: + data["guardrail_to_apply"] = callback + target = UnifiedLLMGuardrails() + + if mode == "pre_call": + response = await target.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=None, # type: ignore + data=data, + call_type=call_type, # type: ignore + ) + elif mode == "post_call": + response = await target.async_post_call_success_hook( + user_api_key_dict=user_api_key_dict, + data=data, + response=data.get("response"), # type: ignore + ) + else: + return ("error", None, f"Unsupported pipeline mode: {mode}") + + # Normal return means pass + modified_data = None + if response is not None and isinstance(response, dict): + modified_data = response + return ("pass", modified_data, None) + + except Exception as e: + if CustomGuardrail._is_guardrail_intervention(e): + error_msg = _extract_error_message(e) + return ("fail", None, error_msg) + else: + verbose_proxy_logger.error( + f"Pipeline: unexpected error from guardrail '{step.guardrail}': {e}" + ) + return ("error", None, str(e)) + + @staticmethod + def _find_guardrail_callback(guardrail_name: str) -> Optional[CustomGuardrail]: + """Look up an initialized guardrail callback by name from litellm.callbacks.""" + for callback in litellm.callbacks: + if isinstance(callback, CustomGuardrail): + if callback.guardrail_name == guardrail_name: + return callback + return None + + +def _extract_error_message(e: Exception) -> str: + """Extract a human-readable error message from a guardrail exception.""" + if isinstance(e, ModifyResponseException): + return str(e) + if HTTPException is not None and isinstance(e, HTTPException): + detail = getattr(e, "detail", None) + if detail: + return str(detail) + return str(e) diff --git a/litellm/proxy/policy_engine/policy_endpoints.py b/litellm/proxy/policy_engine/policy_endpoints.py index 3bd893b0034..af12a8598f6 100644 --- a/litellm/proxy/policy_engine/policy_endpoints.py +++ b/litellm/proxy/policy_engine/policy_endpoints.py @@ -10,8 +10,11 @@ from litellm._logging import verbose_proxy_logger from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry +from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor from litellm.proxy.policy_engine.policy_registry import get_policy_registry from litellm.types.proxy.policy_engine import ( + GuardrailPipeline, + PipelineTestRequest, PolicyAttachmentCreateRequest, PolicyAttachmentDBResponse, PolicyAttachmentListResponse, @@ -349,6 +352,69 @@ async def get_resolved_guardrails(policy_id: str): raise HTTPException(status_code=500, detail=str(e)) +# ───────────────────────────────────────────────────────────────────────────── +# Pipeline Test Endpoint +# ───────────────────────────────────────────────────────────────────────────── + + +@router.post( + "/policies/test-pipeline", + tags=["Policies"], + dependencies=[Depends(user_api_key_auth)], +) +async def test_pipeline( + request: PipelineTestRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Test a guardrail pipeline with sample messages. + + Executes the pipeline steps against the provided test messages and returns + step-by-step results showing which guardrails passed/failed, actions taken, + and timing information. + + Example Request: + ```bash + curl -X POST "http://localhost:4000/policies/test-pipeline" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "pipeline": { + "mode": "pre_call", + "steps": [ + {"guardrail": "pii-guard", "on_pass": "next", "on_fail": "block"} + ] + }, + "test_messages": [{"role": "user", "content": "My SSN is 123-45-6789"}] + }' + ``` + """ + try: + validated_pipeline = GuardrailPipeline(**request.pipeline) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid pipeline: {e}") + + data = { + "messages": request.test_messages, + "model": "test", + "metadata": {}, + } + + try: + result = await PipelineExecutor.execute_steps( + steps=validated_pipeline.steps, + mode=validated_pipeline.mode, + data=data, + user_api_key_dict=user_api_key_dict, + call_type="completion", + policy_name="test-pipeline", + ) + return result.model_dump() + except Exception as e: + verbose_proxy_logger.exception(f"Error testing pipeline: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ───────────────────────────────────────────────────────────────────────────── # Policy Attachment CRUD Endpoints # ───────────────────────────────────────────────────────────────────────────── diff --git a/litellm/proxy/policy_engine/policy_registry.py b/litellm/proxy/policy_engine/policy_registry.py index a2431977b24..50acddd2b9d 100644 --- a/litellm/proxy/policy_engine/policy_registry.py +++ b/litellm/proxy/policy_engine/policy_registry.py @@ -10,8 +10,12 @@ by policy_attachments (see AttachmentRegistry). from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Dict, List, Optional +from prisma import Json as PrismaJson + from litellm._logging import verbose_proxy_logger from litellm.types.proxy.policy_engine import ( + GuardrailPipeline, + PipelineStep, Policy, PolicyCondition, PolicyCreateRequest, @@ -93,11 +97,32 @@ class PolicyRegistry: if condition_data: condition = PolicyCondition(model=condition_data.get("model")) + # Parse pipeline (optional ordered guardrail execution) + pipeline = PolicyRegistry._parse_pipeline(policy_data.get("pipeline")) + return Policy( inherit=policy_data.get("inherit"), description=policy_data.get("description"), guardrails=guardrails, condition=condition, + pipeline=pipeline, + ) + + @staticmethod + def _parse_pipeline(pipeline_data: Optional[Dict[str, Any]]) -> Optional[GuardrailPipeline]: + """Parse a pipeline configuration from raw data.""" + if pipeline_data is None: + return None + + steps_data = pipeline_data.get("steps", []) + steps = [ + PipelineStep(**step_data) if isinstance(step_data, dict) else step_data + for step_data in steps_data + ] + + return GuardrailPipeline( + mode=pipeline_data.get("mode", "pre_call"), + steps=steps, ) def get_policy(self, policy_name: str) -> Optional[Policy]: @@ -225,7 +250,10 @@ class PolicyRegistry: data["created_by"] = created_by data["updated_by"] = created_by if policy_request.condition is not None: - data["condition"] = policy_request.condition.model_dump() + data["condition"] = PrismaJson(policy_request.condition.model_dump()) + if policy_request.pipeline is not None: + validated_pipeline = GuardrailPipeline(**policy_request.pipeline) + data["pipeline"] = PrismaJson(validated_pipeline.model_dump()) created_policy = await prisma_client.db.litellm_policytable.create( data=data @@ -244,6 +272,7 @@ class PolicyRegistry: "condition": policy_request.condition.model_dump() if policy_request.condition else None, + "pipeline": policy_request.pipeline, }, ) self.add_policy(policy_request.policy_name, policy) @@ -256,6 +285,7 @@ class PolicyRegistry: guardrails_add=created_policy.guardrails_add or [], guardrails_remove=created_policy.guardrails_remove or [], condition=created_policy.condition, + pipeline=created_policy.pipeline, created_at=created_policy.created_at, updated_at=created_policy.updated_at, created_by=created_policy.created_by, @@ -302,7 +332,10 @@ class PolicyRegistry: if policy_request.guardrails_remove is not None: update_data["guardrails_remove"] = policy_request.guardrails_remove if policy_request.condition is not None: - update_data["condition"] = policy_request.condition.model_dump() + update_data["condition"] = PrismaJson(policy_request.condition.model_dump()) + if policy_request.pipeline is not None: + validated_pipeline = GuardrailPipeline(**policy_request.pipeline) + update_data["pipeline"] = PrismaJson(validated_pipeline.model_dump()) updated_policy = await prisma_client.db.litellm_policytable.update( where={"policy_id": policy_id}, @@ -320,6 +353,7 @@ class PolicyRegistry: "remove": updated_policy.guardrails_remove, }, "condition": updated_policy.condition, + "pipeline": updated_policy.pipeline, }, ) self.add_policy(updated_policy.policy_name, policy) @@ -332,6 +366,7 @@ class PolicyRegistry: guardrails_add=updated_policy.guardrails_add or [], guardrails_remove=updated_policy.guardrails_remove or [], condition=updated_policy.condition, + pipeline=updated_policy.pipeline, created_at=updated_policy.created_at, updated_at=updated_policy.updated_at, created_by=updated_policy.created_by, @@ -409,6 +444,7 @@ class PolicyRegistry: guardrails_add=policy.guardrails_add or [], guardrails_remove=policy.guardrails_remove or [], condition=policy.condition, + pipeline=policy.pipeline, created_at=policy.created_at, updated_at=policy.updated_at, created_by=policy.created_by, @@ -445,6 +481,7 @@ class PolicyRegistry: guardrails_add=p.guardrails_add or [], guardrails_remove=p.guardrails_remove or [], condition=p.condition, + pipeline=p.pipeline, created_at=p.created_at, updated_at=p.updated_at, created_by=p.created_by, @@ -480,6 +517,7 @@ class PolicyRegistry: "remove": policy_response.guardrails_remove, }, "condition": policy_response.condition, + "pipeline": policy_response.pipeline, }, ) self.add_policy(policy_response.policy_name, policy) @@ -528,6 +566,7 @@ class PolicyRegistry: "remove": policy_response.guardrails_remove, }, "condition": policy_response.condition, + "pipeline": policy_response.pipeline, }, ) temp_policies[policy_response.policy_name] = policy diff --git a/litellm/proxy/policy_engine/policy_resolver.py b/litellm/proxy/policy_engine/policy_resolver.py index cfdedc467d8..a8ad78d6491 100644 --- a/litellm/proxy/policy_engine/policy_resolver.py +++ b/litellm/proxy/policy_engine/policy_resolver.py @@ -8,10 +8,11 @@ Handles: - Combining guardrails from multiple matching policies """ -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, Tuple from litellm._logging import verbose_proxy_logger from litellm.types.proxy.policy_engine import ( + GuardrailPipeline, Policy, PolicyMatchContext, ResolvedPolicy, @@ -190,6 +191,67 @@ class PolicyResolver: return result + @staticmethod + def resolve_pipelines_for_context( + context: PolicyMatchContext, + policies: Optional[Dict[str, Policy]] = None, + ) -> List[Tuple[str, GuardrailPipeline]]: + """ + Resolve pipelines from matching policies for a request context. + + Returns (policy_name, pipeline) tuples for policies that have pipelines. + Guardrails managed by pipelines should be excluded from the flat + guardrails list to avoid double execution. + + Args: + context: The request context + policies: Dictionary of all policies (if None, uses global registry) + + Returns: + List of (policy_name, GuardrailPipeline) tuples + """ + from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher + from litellm.proxy.policy_engine.policy_registry import get_policy_registry + + if policies is None: + registry = get_policy_registry() + if not registry.is_initialized(): + return [] + policies = registry.get_all_policies() + + matching_policy_names = PolicyMatcher.get_matching_policies(context=context) + if not matching_policy_names: + return [] + + pipelines: List[Tuple[str, GuardrailPipeline]] = [] + for policy_name in matching_policy_names: + policy = policies.get(policy_name) + if policy is None: + continue + if policy.pipeline is not None: + pipelines.append((policy_name, policy.pipeline)) + verbose_proxy_logger.debug( + f"Policy '{policy_name}' has pipeline with " + f"{len(policy.pipeline.steps)} steps" + ) + + return pipelines + + @staticmethod + def get_pipeline_managed_guardrails( + pipelines: List[Tuple[str, GuardrailPipeline]], + ) -> Set[str]: + """ + Get the set of guardrail names managed by pipelines. + + These guardrails should be excluded from normal independent execution. + """ + managed: Set[str] = set() + for _policy_name, pipeline in pipelines: + for step in pipeline.steps: + managed.add(step.guardrail) + return managed + @staticmethod def get_all_resolved_policies( policies: Optional[Dict[str, Policy]] = None, diff --git a/litellm/proxy/policy_engine/policy_validator.py b/litellm/proxy/policy_engine/policy_validator.py index 3eaa67a54d3..89c9b0e2e99 100644 --- a/litellm/proxy/policy_engine/policy_validator.py +++ b/litellm/proxy/policy_engine/policy_validator.py @@ -283,8 +283,14 @@ class PolicyValidator: ) ) - # Note: Team, key, and model validation is done via policy_attachments - # Policies no longer have scope - attachments define where policies apply + # Validate pipeline if present + if policy.pipeline is not None: + pipeline_errors = PolicyValidator._validate_pipeline( + policy_name=policy_name, + policy=policy, + available_guardrails=available_guardrails, + ) + errors.extend(pipeline_errors) # Validate inheritance inheritance_errors = self._validate_inheritance_chain( @@ -298,6 +304,53 @@ class PolicyValidator: warnings=warnings, ) + @staticmethod + def _validate_pipeline( + policy_name: str, + policy: Policy, + available_guardrails: Set[str], + ) -> List[PolicyValidationError]: + """Validate a policy's pipeline configuration.""" + errors: List[PolicyValidationError] = [] + pipeline = policy.pipeline + if pipeline is None: + return errors + + guardrails_add = set(policy.guardrails.get_add()) + + for i, step in enumerate(pipeline.steps): + # Check guardrail is in policy's guardrails.add + if step.guardrail not in guardrails_add: + errors.append( + PolicyValidationError( + policy_name=policy_name, + error_type=PolicyValidationErrorType.INVALID_GUARDRAIL, + message=( + f"Pipeline step {i} guardrail '{step.guardrail}' " + f"is not in the policy's guardrails.add list" + ), + field="pipeline.steps", + value=step.guardrail, + ) + ) + + # Check guardrail exists in registry + if available_guardrails and step.guardrail not in available_guardrails: + errors.append( + PolicyValidationError( + policy_name=policy_name, + error_type=PolicyValidationErrorType.INVALID_GUARDRAIL, + message=( + f"Pipeline step {i} guardrail '{step.guardrail}' " + f"not found in guardrail registry" + ), + field="pipeline.steps", + value=step.guardrail, + ) + ) + + return errors + async def validate_policy_config( self, policy_config: Dict[str, Any], diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 45751c5724c..bc2d32f141d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -867,6 +867,9 @@ async def proxy_startup_event(app: FastAPI): # noqa: PLR0915 ## [Optional] Initialize dd tracer ProxyStartupEvent._init_dd_tracer() + ## [Optional] Initialize Pyroscope continuous profiling (env: LITELLM_ENABLE_PYROSCOPE=true) + ProxyStartupEvent._init_pyroscope() + ## Initialize shared aiohttp session for connection reuse shared_aiohttp_session = await _initialize_shared_aiohttp_session() @@ -5814,6 +5817,69 @@ class ProxyStartupEvent: prof.start() verbose_proxy_logger.debug("Datadog Profiler started......") + @classmethod + def _init_pyroscope(cls): + """ + Optional continuous profiling via Grafana Pyroscope. + + Off by default. Enable with LITELLM_ENABLE_PYROSCOPE=true. + Requires: pip install pyroscope-io (optional dependency). + When enabled, PYROSCOPE_SERVER_ADDRESS and PYROSCOPE_APP_NAME are required (no defaults). + Optional: PYROSCOPE_SAMPLE_RATE (parsed as integer) to set the sample rate. + """ + if not get_secret_bool("LITELLM_ENABLE_PYROSCOPE", False): + verbose_proxy_logger.debug( + "LiteLLM: Pyroscope profiling is disabled (set LITELLM_ENABLE_PYROSCOPE=true to enable)." + ) + try: + import pyroscope + + app_name = os.getenv("PYROSCOPE_APP_NAME") + if not app_name: + raise ValueError( + "LITELLM_ENABLE_PYROSCOPE is true but PYROSCOPE_APP_NAME is not set. " + "Set PYROSCOPE_APP_NAME when enabling Pyroscope." + ) + server_address = os.getenv("PYROSCOPE_SERVER_ADDRESS") + if not server_address: + raise ValueError( + "LITELLM_ENABLE_PYROSCOPE is true but PYROSCOPE_SERVER_ADDRESS is not set. " + "Set PYROSCOPE_SERVER_ADDRESS when enabling Pyroscope." + ) + tags = {} + env_name = os.getenv("OTEL_ENVIRONMENT_NAME") or os.getenv( + "LITELLM_DEPLOYMENT_ENVIRONMENT", + ) + if env_name: + tags["environment"] = env_name + sample_rate_env = os.getenv("PYROSCOPE_SAMPLE_RATE") + configure_kwargs = { + "app_name": app_name, + "server_address": server_address, + "tags": tags if tags else None, + } + if sample_rate_env is not None: + try: + # pyroscope-io expects sample_rate as an integer + configure_kwargs["sample_rate"] = int(float(sample_rate_env)) + except (ValueError, TypeError): + raise ValueError( + "PYROSCOPE_SAMPLE_RATE must be a number, got: " + f"{sample_rate_env!r}" + ) + pyroscope.configure(**configure_kwargs) + msg = ( + f"LiteLLM: Pyroscope profiling started (app_name={app_name}, server_address={server_address}). " + f"View CPU profiles at the Pyroscope UI and select application '{app_name}'." + ) + if "sample_rate" in configure_kwargs: + msg += f" sample_rate={configure_kwargs['sample_rate']}" + verbose_proxy_logger.info(msg) + except ImportError: + verbose_proxy_logger.warning( + "LiteLLM: LITELLM_ENABLE_PYROSCOPE is set but the 'pyroscope-io' package is not installed. " + "Pyroscope profiling will not run. Install with: pip install pyroscope-io" + ) #### API ENDPOINTS #### @router.get( diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 390b0415d15..dab0c8237a7 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -917,6 +917,7 @@ model LiteLLM_PolicyTable { guardrails_add String[] @default([]) guardrails_remove String[] @default([]) condition Json? @default("{}") // Policy conditions (e.g., model matching) + pipeline Json? // Optional guardrail pipeline (mode + steps[]) created_at DateTime @default(now()) created_by String? updated_at DateTime @default(now()) @updatedAt @@ -945,7 +946,7 @@ model LiteLLM_AccessGroupTable { description String? // Resource memberships - explicit arrays per type - access_model_ids String[] @default([]) + access_model_names String[] @default([]) access_mcp_server_ids String[] @default([]) access_agent_ids String[] @default([]) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index d977751004c..66cf95f8e6b 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -77,7 +77,10 @@ from litellm._logging import verbose_proxy_logger from litellm._service_logger import ServiceLogging, ServiceTypes from litellm.caching.caching import DualCache, RedisCache from litellm.exceptions import RejectedRequestError -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, +) from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting from litellm.integrations.SlackAlerting.utils import _add_langfuse_trace_id_to_alert @@ -110,6 +113,7 @@ from litellm.proxy.hooks.parallel_request_limiter import ( _PROXY_MaxParallelRequestsHandler, ) from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor from litellm.secret_managers.main import str_to_bool from litellm.types.integrations.slack_alerting import DEFAULT_ALERT_TYPES from litellm.types.mcp import ( @@ -117,6 +121,7 @@ from litellm.types.mcp import ( MCPPreCallRequestObject, MCPPreCallResponseObject, ) +from litellm.types.proxy.policy_engine.pipeline_types import PipelineExecutionResult from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams if TYPE_CHECKING: @@ -1141,6 +1146,95 @@ class ProxyLogging: request_data=data, guardrail_name=guardrail_name ) + async def _maybe_execute_pipelines( + self, + data: dict, + user_api_key_dict: UserAPIKeyAuth, + call_type: str, + event_hook: str, + ) -> dict: + """ + Execute guardrail pipelines if any are configured for this request. + + Checks metadata for pipelines resolved by the policy engine + and executes them. Handles the result (allow/block/modify_response). + + Returns the (possibly modified) data dict. + """ + metadata = data.get("metadata", data.get("litellm_metadata", {})) or {} + pipelines = metadata.get("_guardrail_pipelines") + if not pipelines: + return data + + for policy_name, pipeline in pipelines: + if pipeline.mode != event_hook: + continue + + result: PipelineExecutionResult = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data=data, + user_api_key_dict=user_api_key_dict, + call_type=call_type, + policy_name=policy_name, + ) + + data = self._handle_pipeline_result( + result=result, + data=data, + policy_name=policy_name, + ) + + return data + + @staticmethod + def _handle_pipeline_result( + result: Any, + data: dict, + policy_name: str, + ) -> dict: + """ + Handle a PipelineExecutionResult — allow, block, or modify_response. + + Returns data dict if allowed, raises on block/modify_response. + """ + if result.terminal_action == "allow": + if result.modified_data is not None: + data.update(result.modified_data) + return data + + if result.terminal_action == "block": + step_results_serializable = [ + { + "guardrail": sr.guardrail_name, + "outcome": sr.outcome, + "action": sr.action_taken, + } + for sr in result.step_results + ] + error_detail = { + "error": { + "message": f"Content blocked by guardrail pipeline '{policy_name}'", + "type": "guardrail_pipeline_error", + "pipeline_context": { + "policy": policy_name, + "step_results": step_results_serializable, + }, + } + } + raise HTTPException(status_code=400, detail=error_detail) + + if result.terminal_action == "modify_response": + raise ModifyResponseException( + message=result.modify_response_message or "Response modified by pipeline", + model=data.get("model", "unknown"), + request_data=data, + guardrail_name=f"pipeline:{policy_name}", + detection_info=None, + ) + + return data + # The actual implementation of the function @overload async def pre_call_hook( @@ -1203,6 +1297,18 @@ class ProxyLogging: ) try: + # Execute guardrail pipelines before the normal callback loop + data = await self._maybe_execute_pipelines( + data=data, + user_api_key_dict=user_api_key_dict, + call_type=call_type, + event_hook="pre_call", + ) + + # Get pipeline-managed guardrails to skip in normal loop + metadata = data.get("metadata", data.get("litellm_metadata", {})) or {} + pipeline_managed: set = metadata.get("_pipeline_managed_guardrails", set()) + for callback in litellm.callbacks: start_time = time.time() _callback = None @@ -1217,6 +1323,10 @@ class ProxyLogging: and isinstance(_callback, CustomGuardrail) and data is not None ): + # Skip guardrails managed by a pipeline + if _callback.guardrail_name and _callback.guardrail_name in pipeline_managed: + continue + result = await self._process_guardrail_callback( callback=_callback, data=data, # type: ignore diff --git a/litellm/proxy/vector_store_endpoints/endpoints.py b/litellm/proxy/vector_store_endpoints/endpoints.py index 0775e05f4fa..30cabd3eeff 100644 --- a/litellm/proxy/vector_store_endpoints/endpoints.py +++ b/litellm/proxy/vector_store_endpoints/endpoints.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -230,7 +230,7 @@ async def vector_store_create( ) # Get managed vector stores hook - managed_vector_stores = proxy_logging_obj.get_proxy_hook("managed_vector_stores") + managed_vector_stores: Any = proxy_logging_obj.get_proxy_hook("managed_vector_stores") if managed_vector_stores is None: raise HTTPException( status_code=500, diff --git a/litellm/rag/ingestion/vertex_ai_ingestion.py b/litellm/rag/ingestion/vertex_ai_ingestion.py index 47a94185d1d..7394ec7a616 100644 --- a/litellm/rag/ingestion/vertex_ai_ingestion.py +++ b/litellm/rag/ingestion/vertex_ai_ingestion.py @@ -10,7 +10,7 @@ Based on: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-refer from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 08e31c59662..900f56fea26 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1500,7 +1500,7 @@ class LiteLLMCompletionResponsesConfig: previous_response_id=getattr( chat_completion_response, "previous_response_id", None ), - reasoning=Reasoning(), + reasoning=dict(Reasoning()), status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( finish_reason ), @@ -1516,7 +1516,7 @@ class LiteLLMCompletionResponsesConfig: # Surface provider-specific fields (generic passthrough from any provider) provider_fields = responses_api_response._hidden_params.get("provider_specific_fields") if provider_fields: - responses_api_response.provider_specific_fields = provider_fields + setattr(responses_api_response, "provider_specific_fields", provider_fields) return responses_api_response diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index 3a6b75768ef..e26ebe00625 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -7,7 +7,7 @@ from pydantic import BaseModel class AccessGroupCreateRequest(BaseModel): access_group_name: str description: Optional[str] = None - access_model_ids: Optional[List[str]] = None + access_model_names: Optional[List[str]] = None access_mcp_server_ids: Optional[List[str]] = None access_agent_ids: Optional[List[str]] = None assigned_team_ids: Optional[List[str]] = None @@ -15,8 +15,9 @@ class AccessGroupCreateRequest(BaseModel): class AccessGroupUpdateRequest(BaseModel): + access_group_name: Optional[str] = None description: Optional[str] = None - access_model_ids: Optional[List[str]] = None + access_model_names: Optional[List[str]] = None access_mcp_server_ids: Optional[List[str]] = None access_agent_ids: Optional[List[str]] = None assigned_team_ids: Optional[List[str]] = None @@ -27,7 +28,7 @@ class AccessGroupResponse(BaseModel): access_group_id: str access_group_name: str description: Optional[str] = None - access_model_ids: List[str] + access_model_names: List[str] access_mcp_server_ids: List[str] access_agent_ids: List[str] assigned_team_ids: List[str] diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/zscaler_ai_guard.py b/litellm/types/proxy/guardrails/guardrail_hooks/zscaler_ai_guard.py index 7cbdf751e1b..f522f5b470a 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/zscaler_ai_guard.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/zscaler_ai_guard.py @@ -106,6 +106,7 @@ class ZscalerAIGuardConfigModel(GuardrailConfigModel): ) # Check for configuration issues + assert api_base is not None # always set via env default above is_resolve_policy = api_base.endswith("/resolve-and-execute-policy") is_execute_policy = api_base.endswith("/execute-policy") and not is_resolve_policy diff --git a/litellm/types/proxy/policy_engine/__init__.py b/litellm/types/proxy/policy_engine/__init__.py index 42490c2eddc..e0c1d6f30da 100644 --- a/litellm/types/proxy/policy_engine/__init__.py +++ b/litellm/types/proxy/policy_engine/__init__.py @@ -10,6 +10,12 @@ Configuration: - `policy_attachments`: Define WHERE policies apply (teams, keys, models) """ +from litellm.types.proxy.policy_engine.pipeline_types import ( + GuardrailPipeline, + PipelineExecutionResult, + PipelineStep, + PipelineStepResult, +) from litellm.types.proxy.policy_engine.policy_types import ( Policy, PolicyAttachment, @@ -20,6 +26,7 @@ from litellm.types.proxy.policy_engine.policy_types import ( ) from litellm.types.proxy.policy_engine.resolver_types import ( AttachmentImpactResponse, + PipelineTestRequest, PolicyAttachmentCreateRequest, PolicyAttachmentDBResponse, PolicyAttachmentListResponse, @@ -48,6 +55,11 @@ from litellm.types.proxy.policy_engine.validation_types import ( ) __all__ = [ + # Pipeline types + "GuardrailPipeline", + "PipelineStep", + "PipelineStepResult", + "PipelineExecutionResult", # Policy types "Policy", "PolicyConfig", @@ -79,6 +91,8 @@ __all__ = [ "PolicyAttachmentCreateRequest", "PolicyAttachmentDBResponse", "PolicyAttachmentListResponse", + # Pipeline test types + "PipelineTestRequest", # Resolve types "PolicyResolveRequest", "PolicyResolveResponse", diff --git a/litellm/types/proxy/policy_engine/pipeline_types.py b/litellm/types/proxy/policy_engine/pipeline_types.py new file mode 100644 index 00000000000..29d2e576000 --- /dev/null +++ b/litellm/types/proxy/policy_engine/pipeline_types.py @@ -0,0 +1,98 @@ +""" +Pipeline type definitions for guardrail pipelines. + +Pipelines define ordered, conditional execution of guardrails within a policy. +When a policy has a `pipeline`, its guardrails run in the defined step order +with configurable actions on pass/fail, rather than independently. +""" + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +VALID_PIPELINE_ACTIONS = {"allow", "block", "next", "modify_response"} +VALID_PIPELINE_MODES = {"pre_call", "post_call"} + + +class PipelineStep(BaseModel): + """ + A single step in a guardrail pipeline. + + Each step runs a guardrail and takes an action based on pass/fail. + """ + + guardrail: str = Field(description="Name of the guardrail to run.") + on_fail: str = Field( + default="block", + description="Action when guardrail rejects: next | block | allow | modify_response", + ) + on_pass: str = Field( + default="allow", + description="Action when guardrail passes: next | block | allow | modify_response", + ) + pass_data: bool = Field( + default=False, + description="Forward modified request data (e.g., PII-masked) to next step.", + ) + modify_response_message: Optional[str] = Field( + default=None, + description="Custom message for modify_response action.", + ) + + model_config = ConfigDict(extra="forbid") + + @field_validator("on_fail", "on_pass") + @classmethod + def validate_action(cls, v: str) -> str: + if v not in VALID_PIPELINE_ACTIONS: + raise ValueError( + f"Invalid action '{v}'. Must be one of: {sorted(VALID_PIPELINE_ACTIONS)}" + ) + return v + + +class GuardrailPipeline(BaseModel): + """ + Defines ordered execution of guardrails with conditional actions. + + When present on a policy, the guardrails in `steps` are executed + sequentially instead of independently. + """ + + mode: str = Field(description="Event hook: pre_call | post_call") + steps: List[PipelineStep] = Field( + description="Ordered list of pipeline steps. Must have at least 1 step.", + min_length=1, + ) + + model_config = ConfigDict(extra="forbid") + + @field_validator("mode") + @classmethod + def validate_mode(cls, v: str) -> str: + if v not in VALID_PIPELINE_MODES: + raise ValueError( + f"Invalid mode '{v}'. Must be one of: {sorted(VALID_PIPELINE_MODES)}" + ) + return v + + +class PipelineStepResult(BaseModel): + """Result of executing a single pipeline step.""" + + guardrail_name: str + outcome: Literal["pass", "fail", "error"] + action_taken: str + modified_data: Optional[Dict[str, Any]] = None + error_detail: Optional[str] = None + duration_seconds: Optional[float] = None + + +class PipelineExecutionResult(BaseModel): + """Result of executing an entire pipeline.""" + + terminal_action: str # block | allow | modify_response + step_results: List[PipelineStepResult] + modified_data: Optional[Dict[str, Any]] = None + error_message: Optional[str] = None + modify_response_message: Optional[str] = None diff --git a/litellm/types/proxy/policy_engine/policy_types.py b/litellm/types/proxy/policy_engine/policy_types.py index f221ba7e038..53a74ca6fd8 100644 --- a/litellm/types/proxy/policy_engine/policy_types.py +++ b/litellm/types/proxy/policy_engine/policy_types.py @@ -29,10 +29,12 @@ Key concepts: - `condition`: Optional model condition for when guardrails apply """ -from typing import Any, Dict, List, Optional, Union +from typing import Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field +from litellm.types.proxy.policy_engine.pipeline_types import GuardrailPipeline + # ───────────────────────────────────────────────────────────────────────────── # Policy Condition # ───────────────────────────────────────────────────────────────────────────── @@ -231,6 +233,10 @@ class Policy(BaseModel): default=None, description="Optional condition for when this policy's guardrails apply.", ) + pipeline: Optional[GuardrailPipeline] = Field( + default=None, + description="Optional pipeline for ordered, conditional guardrail execution.", + ) model_config = ConfigDict(extra="forbid") diff --git a/litellm/types/proxy/policy_engine/resolver_types.py b/litellm/types/proxy/policy_engine/resolver_types.py index 0c2c7336f8a..a5a2334ae4b 100644 --- a/litellm/types/proxy/policy_engine/resolver_types.py +++ b/litellm/types/proxy/policy_engine/resolver_types.py @@ -154,6 +154,10 @@ class PolicyCreateRequest(BaseModel): default=None, description="Condition for when this policy applies.", ) + pipeline: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional guardrail pipeline for ordered execution. Contains 'mode' and 'steps'.", + ) class PolicyUpdateRequest(BaseModel): @@ -183,6 +187,10 @@ class PolicyUpdateRequest(BaseModel): default=None, description="Condition for when this policy applies.", ) + pipeline: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional guardrail pipeline for ordered execution. Contains 'mode' and 'steps'.", + ) class PolicyDBResponse(BaseModel): @@ -201,6 +209,9 @@ class PolicyDBResponse(BaseModel): condition: Optional[Dict[str, Any]] = Field( default=None, description="Policy condition." ) + pipeline: Optional[Dict[str, Any]] = Field( + default=None, description="Optional guardrail pipeline." + ) created_at: Optional[datetime] = Field( default=None, description="When the policy was created." ) @@ -291,6 +302,17 @@ class PolicyAttachmentListResponse(BaseModel): # ───────────────────────────────────────────────────────────────────────────── +class PipelineTestRequest(BaseModel): + """Request body for testing a guardrail pipeline with sample messages.""" + + pipeline: Dict[str, Any] = Field( + description="Pipeline definition with 'mode' and 'steps'.", + ) + test_messages: List[Dict[str, str]] = Field( + description="Test messages to run through the pipeline, e.g. [{'role': 'user', 'content': '...'}].", + ) + + class PolicyResolveRequest(BaseModel): """Request body for resolving effective policies/guardrails for a context.""" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e6b7cf17297..18d0f0079ba 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14835,7 +14835,9 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "tpm": 250000, + "rpm": 10 }, "gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -16323,7 +16325,9 @@ "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/audio/speech" - ] + ], + "tpm": 4000000, + "rpm": 10 }, "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, @@ -16821,7 +16825,9 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "tpm": 250000, + "rpm": 10 }, "gemini/gemini-gemma-2-9b-it": { "input_cost_per_token": 3.5e-07, @@ -16833,7 +16839,9 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "tpm": 250000, + "rpm": 10 }, "gemini/gemini-pro": { "input_cost_per_token": 3.5e-07, @@ -36495,7 +36503,9 @@ "text", "image" ], - "supports_vision": true + "supports_vision": true, + "tpm": 250000, + "rpm": 10 }, "gemini/gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, @@ -36628,7 +36638,9 @@ "audio" ], "supports_audio_input": true, - "supports_audio_output": true + "supports_audio_output": true, + "tpm": 250000, + "rpm": 10 }, "gemini/gemini-2.5-flash-native-audio-preview-09-2025": { "input_cost_per_audio_token": 1e-06, @@ -36652,7 +36664,9 @@ "audio" ], "supports_audio_input": true, - "supports_audio_output": true + "supports_audio_output": true, + "tpm": 250000, + "rpm": 10 }, "gemini/gemini-2.5-flash-native-audio-preview-12-2025": { "input_cost_per_audio_token": 1e-06, @@ -36676,7 +36690,9 @@ "audio" ], "supports_audio_input": true, - "supports_audio_output": true + "supports_audio_output": true, + "tpm": 250000, + "rpm": 10 }, "gemini-2.5-flash-preview-tts": { "input_cost_per_token": 3e-07, diff --git a/poetry.lock b/poetry.lock index d01baa854af..e30857a3b2f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "a2a-sdk" @@ -5659,6 +5659,24 @@ files = [ [package.extras] dev = ["build", "flake8", "mypy", "pytest", "twine"] +[[package]] +name = "pyroscope-io" +version = "0.8.16" +description = "Pyroscope Python integration" +optional = false +python-versions = "*" +groups = ["main"] +markers = "extra == \"proxy\" and sys_platform != \"win32\"" +files = [ + {file = "pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8"}, + {file = "pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:dc98355e27c0b7b61f27066500fe1045b70e9459bb8b9a3082bc4755cb6392b6"}, + {file = "pyroscope_io-0.8.16-py2.py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:86f0f047554ff62bd92c3e5a26bc2809ccd467d11fbacb9fef898ba299dbda59"}, + {file = "pyroscope_io-0.8.16-py2.py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b91ce5b240f8de756c16a17022ca8e25ef8a4eed461c7d074b8a0841cf7b445"}, +] + +[package.dependencies] +cffi = ">=1.6.0" + [[package]] name = "pytest" version = "7.4.4" @@ -8516,7 +8534,7 @@ extra-proxy = ["a2a-sdk", "azure-identity", "azure-keyvault-secrets", "google-cl google = ["google-cloud-aiplatform"] grpc = ["grpcio", "grpcio"] mlflow = ["mlflow"] -proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pynacl", "python-multipart", "pyyaml", "rich", "rq", "soundfile", "uvicorn", "uvloop", "websockets"] +proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pynacl", "pyroscope-io", "python-multipart", "pyyaml", "rich", "rq", "soundfile", "uvicorn", "uvloop", "websockets"] semantic-router = ["semantic-router"] utils = ["numpydoc"] diff --git a/pyproject.toml b/pyproject.toml index 6ed7618dd26..be15013267b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ boto3 = { version = "1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.36", optional = true} +litellm-proxy-extras = {version = "0.4.37", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.31", optional = true} diskcache = {version = "^5.6.1", optional = true} @@ -69,6 +69,7 @@ polars = {version = "^1.31.0", optional = true, python = ">=3.10"} semantic-router = {version = ">=0.1.12", optional = true, python = ">=3.9,<3.14"} mlflow = {version = ">3.1.4", optional = true, python = ">=3.10"} soundfile = {version = "^0.12.1", optional = true} +pyroscope-io = {version = "^0.8", optional = true, markers = "sys_platform != 'win32'"} # grpcio constraints: # - 1.62.3+ required by grpcio-status # - 1.68.0-1.68.1 has reconnect bug (https://github.com/grpc/grpc/issues/38290) @@ -104,6 +105,7 @@ proxy = [ "rich", "polars", "soundfile", + "pyroscope-io", ] extra_proxy = [ @@ -121,6 +123,8 @@ utils = [ "numpydoc", ] + + caching = ["diskcache"] semantic-router = ["semantic-router"] diff --git a/requirements.txt b/requirements.txt index f31730e20f5..18fc39dca3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ grpcio>=1.75.0; python_version >= "3.14" sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.36 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.37 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env diff --git a/schema.prisma b/schema.prisma index 2a11d0028fb..01965eaafc2 100644 --- a/schema.prisma +++ b/schema.prisma @@ -930,7 +930,7 @@ model LiteLLM_AccessGroupTable { description String? // Resource memberships - explicit arrays per type - access_model_ids String[] @default([]) + access_model_names String[] @default([]) access_mcp_server_ids String[] @default([]) access_agent_ids String[] @default([]) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index f8a082ee30c..c35cedd3c7f 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1278,3 +1278,86 @@ def test_transform_response_preserves_annotations(): assert result.usage.total_tokens == 30 print("✓ Annotations from Responses API are correctly preserved in Chat Completions format") + + +def test_convert_chat_completion_messages_to_responses_api_system_string(): + """Test that string system content is extracted into instructions.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + ] + + input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages) + + assert instructions == "You are a helpful assistant." + # System message should NOT appear in input items + for item in input_items: + assert item.get("role") != "system" + # User message should be in input items + assert len(input_items) == 1 + assert input_items[0]["role"] == "user" + + +def test_convert_chat_completion_messages_to_responses_api_system_list_content(): + """Test that list-format system content blocks are extracted into instructions. + + This happens when requests arrive via the Anthropic /v1/messages adapter, + which converts system prompts into list-format content blocks. + """ + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "You are a helpful assistant."}, + {"type": "text", "text": "Be concise."}, + ], + }, + {"role": "user", "content": "Hello"}, + ] + + input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages) + + assert instructions == "You are a helpful assistant. Be concise." + # System message should NOT appear in input items + for item in input_items: + assert item.get("role") != "system" + assert len(input_items) == 1 + assert input_items[0]["role"] == "user" + + +def test_convert_chat_completion_messages_to_responses_api_multiple_system_messages(): + """Test that multiple system messages (string and list) are concatenated.""" + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + { + "role": "system", + "content": [ + {"type": "text", "text": "Be concise."}, + ], + }, + {"role": "user", "content": "Hello"}, + ] + + input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages) + + assert instructions == "You are a helpful assistant. Be concise." + for item in input_items: + assert item.get("role") != "system" diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 9b7b7f46155..00e348b5b7c 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -422,3 +422,77 @@ async def test_return_user_api_key_auth_obj_user_spend_and_budget(): assert result.user_tpm_limit == 1000 assert result.user_rpm_limit == 100 assert result.user_email == "test@example.com" + + +def test_proxy_admin_jwt_auth_includes_identity_fields(): + """ + Test that the proxy admin early-return path in JWT auth populates + user_id, team_id, team_alias, team_metadata, org_id, and end_user_id. + + Regression test: previously the is_proxy_admin branch only set user_role + and parent_otel_span, discarding all identity fields resolved from the JWT. + This caused blank Team Name and Internal User in Request Logs UI. + """ + from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, UserAPIKeyAuth + + team_object = LiteLLM_TeamTable( + team_id="team-123", + team_alias="my-team", + metadata={"tags": ["prod"], "env": "production"}, + ) + + # Simulate the proxy admin early-return path (user_api_key_auth.py ~line 586) + result = UserAPIKeyAuth( + api_key=None, + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="user-abc", + team_id="team-123", + team_alias=( + team_object.team_alias if team_object is not None else None + ), + team_metadata=team_object.metadata if team_object is not None else None, + org_id="org-456", + end_user_id="end-user-789", + parent_otel_span=None, + ) + + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + assert result.user_id == "user-abc" + assert result.team_id == "team-123" + assert result.team_alias == "my-team" + assert result.team_metadata == {"tags": ["prod"], "env": "production"} + assert result.org_id == "org-456" + assert result.end_user_id == "end-user-789" + assert result.api_key is None + + +def test_proxy_admin_jwt_auth_handles_no_team_object(): + """ + Test that the proxy admin early-return path works correctly when + team_object is None (user has admin role but no team association). + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + team_object = None + + result = UserAPIKeyAuth( + api_key=None, + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin-user", + team_id=None, + team_alias=( + team_object.team_alias if team_object is not None else None + ), + team_metadata=team_object.metadata if team_object is not None else None, + org_id=None, + end_user_id=None, + parent_otel_span=None, + ) + + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + assert result.user_id == "admin-user" + assert result.team_id is None + assert result.team_alias is None + assert result.team_metadata is None + assert result.org_id is None + assert result.end_user_id is None diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 5f204918d08..54df8941fa5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -28,7 +28,7 @@ def _make_access_group_record( access_group_id: str = "ag-123", access_group_name: str = "test-group", description: str | None = "Test description", - access_model_ids: list | None = None, + access_model_names: list | None = None, access_mcp_server_ids: list | None = None, access_agent_ids: list | None = None, assigned_team_ids: list | None = None, @@ -41,7 +41,7 @@ def _make_access_group_record( record.access_group_id = access_group_id record.access_group_name = access_group_name record.description = description - record.access_model_ids = access_model_ids or [] + record.access_model_names = access_model_names or [] record.access_mcp_server_ids = access_mcp_server_ids or [] record.access_agent_ids = access_agent_ids or [] record.assigned_team_ids = assigned_team_ids or [] @@ -64,7 +64,7 @@ def client_and_mocks(monkeypatch): access_group_id="ag-new", access_group_name=data.get("access_group_name", "new"), description=data.get("description"), - access_model_ids=data.get("access_model_ids", []), + access_model_names=data.get("access_model_names", []), access_mcp_server_ids=data.get("access_mcp_server_ids", []), access_agent_ids=data.get("access_agent_ids", []), assigned_team_ids=data.get("assigned_team_ids", []), @@ -80,7 +80,7 @@ def client_and_mocks(monkeypatch): access_group_id=where.get("access_group_id", "ag-123"), access_group_name=data.get("access_group_name", "updated"), description=data.get("description"), - access_model_ids=data.get("access_model_ids", []), + access_model_names=data.get("access_model_names", []), access_mcp_server_ids=data.get("access_mcp_server_ids", []), access_agent_ids=data.get("access_agent_ids", []), assigned_team_ids=data.get("assigned_team_ids", []), @@ -147,7 +147,7 @@ ACCESS_GROUP_PATHS = ["/v1/access_group", "/v1/unified_access_group"] { "access_group_name": "group-b", "description": "Group B description", - "access_model_ids": ["model-1"], + "access_model_names": ["model-1"], "access_mcp_server_ids": ["mcp-1"], "assigned_team_ids": ["team-1"], }, @@ -369,7 +369,7 @@ def test_get_access_group_forbidden_non_admin(client_and_mocks, user_role): "update_payload", [ {"description": "Updated description"}, - {"access_model_ids": ["model-1", "model-2"]}, + {"access_model_names": ["model-1", "model-2"]}, {"assigned_team_ids": [], "assigned_key_ids": ["key-1"]}, ], ) @@ -431,6 +431,57 @@ def test_update_access_group_empty_body(client_and_mocks): assert call_kwargs["data"]["updated_by"] == "admin_user" +def test_update_access_group_name_success(client_and_mocks): + """Update access_group_name succeeds when new name is unique.""" + client, _, mock_table = client_and_mocks + + existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") + mock_table.find_unique = AsyncMock(return_value=existing) + + resp = client.put("/v1/access_group/ag-update", json={"access_group_name": "new-name"}) + assert resp.status_code == 200 + mock_table.update.assert_awaited_once() + call_kwargs = mock_table.update.call_args.kwargs + assert call_kwargs["data"]["access_group_name"] == "new-name" + + +def test_update_access_group_name_duplicate_conflict(client_and_mocks): + """Update access_group_name to existing name returns 409 (unique constraint).""" + client, _, mock_table = client_and_mocks + + existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") + mock_table.find_unique = AsyncMock(return_value=existing) + mock_table.update = AsyncMock( + side_effect=Exception("Unique constraint failed on the fields: (`access_group_name`)") + ) + + resp = client.put("/v1/access_group/ag-update", json={"access_group_name": "taken-name"}) + assert resp.status_code == 409 + assert "already exists" in resp.json()["detail"] + mock_table.update.assert_awaited_once() + + +@pytest.mark.parametrize( + "error_message", + [ + "Unique constraint failed on the fields: (`access_group_name`)", + "P2002: Unique constraint failed", + "unique constraint violation", + ], +) +def test_update_access_group_name_unique_constraint_returns_409(client_and_mocks, error_message): + """Update access_group_name: Prisma unique constraint surfaces as 409.""" + client, _, mock_table = client_and_mocks + + existing = _make_access_group_record(access_group_id="ag-update", access_group_name="old-name") + mock_table.find_unique = AsyncMock(return_value=existing) + mock_table.update = AsyncMock(side_effect=Exception(error_message)) + + resp = client.put("/v1/access_group/ag-update", json={"access_group_name": "race-name"}) + assert resp.status_code == 409 + assert "already exists" in resp.json()["detail"] + + # --------------------------------------------------------------------------- # DELETE # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index e50e10352e2..7ec97ddc185 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -2087,6 +2087,143 @@ async def test_add_litellm_data_to_request_adds_headers_to_metadata(): assert "headers" in result["proxy_server_request"] +@pytest.mark.asyncio +async def test_create_pass_through_route_custom_body_url_target(): + """ + Test that the URL-based endpoint_func created by create_pass_through_route + accepts a custom_body parameter and forwards it to pass_through_request, + taking precedence over the request-parsed body. + + This verifies the fix for issue #16999 where bedrock_proxy_route passes + custom_body=data to the endpoint function, which previously crashed with: + TypeError: endpoint_func() got an unexpected keyword argument 'custom_body' + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + create_pass_through_route, + ) + + unique_path = "/test/path/unique/custom_body_url" + endpoint_func = create_pass_through_route( + endpoint=unique_path, + target="https://bedrock-agent-runtime.us-east-1.amazonaws.com", + custom_headers={"Content-Type": "application/json"}, + _forward_headers=True, + ) + + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.pass_through_request" + ) as mock_pass_through, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.is_registered_pass_through_route" + ) as mock_is_registered, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.get_registered_pass_through_route" + ) as mock_get_registered, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._parse_request_data_by_content_type" + ) as mock_parse_request: + mock_pass_through.return_value = MagicMock() + mock_is_registered.return_value = True + mock_get_registered.return_value = None + # Simulate the request parser returning a different body + mock_parse_request.return_value = ( + {}, # query_params_data + {"parsed_from_request": True}, # custom_body_data (from request) + None, # file_data + False, # stream + ) + + mock_request = MagicMock(spec=Request) + mock_request.url = MagicMock() + mock_request.url.path = unique_path + mock_request.path_params = {} + mock_request.query_params = QueryParams({}) + + mock_user_api_key_dict = MagicMock() + mock_user_api_key_dict.api_key = "test-key" + + # The caller-supplied body (e.g. from bedrock_proxy_route) + bedrock_body = { + "retrievalQuery": {"text": "What is in the knowledge base?"}, + } + + # Call endpoint_func with custom_body — this is the call that + # used to crash with TypeError before the fix + await endpoint_func( + request=mock_request, + fastapi_response=MagicMock(), + user_api_key_dict=mock_user_api_key_dict, + custom_body=bedrock_body, + ) + + mock_pass_through.assert_called_once() + call_kwargs = mock_pass_through.call_args[1] + + # The critical assertion: custom_body takes precedence over + # the body parsed from the raw request + assert call_kwargs["custom_body"] == bedrock_body + + +@pytest.mark.asyncio +async def test_create_pass_through_route_no_custom_body_falls_back(): + """ + Test that the URL-based endpoint_func falls back to the request-parsed body + when custom_body is not provided. + + This ensures the default pass-through behavior is preserved — only the + Bedrock proxy route (and similar callers) supply a pre-built body. + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + create_pass_through_route, + ) + + unique_path = "/test/path/unique/no_custom_body" + endpoint_func = create_pass_through_route( + endpoint=unique_path, + target="http://example.com/api", + custom_headers={}, + ) + + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.pass_through_request" + ) as mock_pass_through, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.is_registered_pass_through_route" + ) as mock_is_registered, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.get_registered_pass_through_route" + ) as mock_get_registered, patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._parse_request_data_by_content_type" + ) as mock_parse_request: + mock_pass_through.return_value = MagicMock() + mock_is_registered.return_value = True + mock_get_registered.return_value = None + request_parsed_body = {"key": "from_request"} + mock_parse_request.return_value = ( + {}, # query_params_data + request_parsed_body, # custom_body_data + None, # file_data + False, # stream + ) + + mock_request = MagicMock(spec=Request) + mock_request.url = MagicMock() + mock_request.url.path = unique_path + mock_request.path_params = {} + mock_request.query_params = QueryParams({}) + + mock_user_api_key_dict = MagicMock() + mock_user_api_key_dict.api_key = "test-key" + + # Call without custom_body — should use the request-parsed body + await endpoint_func( + request=mock_request, + fastapi_response=MagicMock(), + user_api_key_dict=mock_user_api_key_dict, + ) + + mock_pass_through.assert_called_once() + call_kwargs = mock_pass_through.call_args[1] + + # Should fall back to the body parsed from the request + assert call_kwargs["custom_body"] == request_parsed_body + + def test_build_full_path_with_root_default(): """ Test _build_full_path_with_root with default root path (/) diff --git a/tests/test_litellm/proxy/policy_engine/test_pipeline_executor.py b/tests/test_litellm/proxy/policy_engine/test_pipeline_executor.py new file mode 100644 index 00000000000..226e88bea3e --- /dev/null +++ b/tests/test_litellm/proxy/policy_engine/test_pipeline_executor.py @@ -0,0 +1,484 @@ +""" +Tests for the pipeline executor. + +Uses mock guardrails to validate pipeline execution without external services. +""" + +from unittest.mock import MagicMock + +import pytest + +import litellm +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor +from litellm.types.proxy.policy_engine.pipeline_types import ( + GuardrailPipeline, + PipelineStep, +) + +try: + from fastapi.exceptions import HTTPException +except ImportError: + HTTPException = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Mock Guardrails +# ───────────────────────────────────────────────────────────────────────────── + + +class AlwaysFailGuardrail(CustomGuardrail): + """Mock guardrail that always raises HTTPException(400).""" + + def __init__(self, guardrail_name: str): + super().__init__( + guardrail_name=guardrail_name, + event_hook="pre_call", + default_on=True, + ) + self.calls = 0 + + def should_run_guardrail(self, data, event_type) -> bool: + return True + + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + self.calls += 1 + raise HTTPException(status_code=400, detail="Content policy violation") + + +class AlwaysPassGuardrail(CustomGuardrail): + """Mock guardrail that always passes.""" + + def __init__(self, guardrail_name: str): + super().__init__( + guardrail_name=guardrail_name, + event_hook="pre_call", + default_on=True, + ) + self.calls = 0 + + def should_run_guardrail(self, data, event_type) -> bool: + return True + + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + self.calls += 1 + return None + + +class PiiMaskingGuardrail(CustomGuardrail): + """Mock guardrail that masks PII in messages and returns modified data.""" + + def __init__(self, guardrail_name: str): + super().__init__( + guardrail_name=guardrail_name, + event_hook="pre_call", + default_on=True, + ) + self.calls = 0 + self.received_messages = None + + def should_run_guardrail(self, data, event_type) -> bool: + return True + + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + self.calls += 1 + self.received_messages = data.get("messages", []) + masked_messages = [] + for msg in data.get("messages", []): + masked_msg = dict(msg) + masked_msg["content"] = msg["content"].replace( + "John Smith", "[REDACTED]" + ) + masked_messages.append(masked_msg) + return {"messages": masked_messages} + + +class ContentCheckGuardrail(CustomGuardrail): + """Mock guardrail that records what messages it received.""" + + def __init__(self, guardrail_name: str): + super().__init__( + guardrail_name=guardrail_name, + event_hook="pre_call", + default_on=True, + ) + self.calls = 0 + self.received_messages = None + + def should_run_guardrail(self, data, event_type) -> bool: + return True + + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + self.calls += 1 + self.received_messages = data.get("messages", []) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# Tests +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed") +@pytest.mark.asyncio +async def test_escalation_step1_fails_step2_blocks(): + """ + Pipeline: simple-filter (on_fail: next) -> advanced-filter (on_fail: block) + Input: request that fails simple-filter + Expected: simple-filter fails -> escalate -> advanced-filter fails -> block + """ + simple_guard = AlwaysFailGuardrail(guardrail_name="simple-filter") + advanced_guard = AlwaysFailGuardrail(guardrail_name="advanced-filter") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="simple-filter", on_fail="next", on_pass="allow" + ), + PipelineStep( + guardrail="advanced-filter", on_fail="block", on_pass="allow" + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [simple_guard, advanced_guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "bad content"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="content-safety", + ) + + assert simple_guard.calls == 1 + assert advanced_guard.calls == 1 + assert result.terminal_action == "block" + assert len(result.step_results) == 2 + assert result.step_results[0].guardrail_name == "simple-filter" + assert result.step_results[0].outcome == "fail" + assert result.step_results[0].action_taken == "next" + assert result.step_results[1].guardrail_name == "advanced-filter" + assert result.step_results[1].outcome == "fail" + assert result.step_results[1].action_taken == "block" + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed") +@pytest.mark.asyncio +async def test_early_allow_step1_passes_step2_skipped(): + """ + Pipeline: simple-filter (on_pass: allow) -> advanced-filter + Input: clean request that passes simple-filter + Expected: simple-filter passes -> allow (advanced-filter never called) + """ + simple_guard = AlwaysPassGuardrail(guardrail_name="simple-filter") + advanced_guard = AlwaysFailGuardrail(guardrail_name="advanced-filter") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="simple-filter", on_fail="next", on_pass="allow" + ), + PipelineStep( + guardrail="advanced-filter", on_fail="block", on_pass="allow" + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [simple_guard, advanced_guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "clean content"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="content-safety", + ) + + assert simple_guard.calls == 1 + assert advanced_guard.calls == 0 + assert result.terminal_action == "allow" + assert len(result.step_results) == 1 + assert result.step_results[0].outcome == "pass" + assert result.step_results[0].action_taken == "allow" + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed") +@pytest.mark.asyncio +async def test_escalation_step1_fails_step2_passes(): + """ + Pipeline: simple-filter (on_fail: next) -> advanced-filter (on_pass: allow) + Input: request that fails simple but passes advanced + Expected: simple-filter fails -> escalate -> advanced-filter passes -> allow + """ + simple_guard = AlwaysFailGuardrail(guardrail_name="simple-filter") + advanced_guard = AlwaysPassGuardrail(guardrail_name="advanced-filter") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="simple-filter", on_fail="next", on_pass="allow" + ), + PipelineStep( + guardrail="advanced-filter", on_fail="block", on_pass="allow" + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [simple_guard, advanced_guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "borderline content"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="content-safety", + ) + + assert simple_guard.calls == 1 + assert advanced_guard.calls == 1 + assert result.terminal_action == "allow" + assert len(result.step_results) == 2 + assert result.step_results[0].outcome == "fail" + assert result.step_results[0].action_taken == "next" + assert result.step_results[1].outcome == "pass" + assert result.step_results[1].action_taken == "allow" + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed") +@pytest.mark.asyncio +async def test_data_forwarding_pii_masking(): + """ + Pipeline: pii-masker (pass_data: true, on_pass: next) -> content-check (on_pass: allow) + Input: "Hello John Smith" + Expected: pii-masker masks -> content-check receives "[REDACTED]" -> allow + """ + pii_guard = PiiMaskingGuardrail(guardrail_name="pii-masker") + content_guard = ContentCheckGuardrail(guardrail_name="content-check") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="pii-masker", + on_fail="block", + on_pass="next", + pass_data=True, + ), + PipelineStep( + guardrail="content-check", on_fail="block", on_pass="allow" + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [pii_guard, content_guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={ + "messages": [{"role": "user", "content": "Hello John Smith"}] + }, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="pii-then-safety", + ) + + assert pii_guard.calls == 1 + assert content_guard.calls == 1 + assert content_guard.received_messages[0]["content"] == "Hello [REDACTED]" + assert result.terminal_action == "allow" + assert result.modified_data is not None + assert result.modified_data["messages"][0]["content"] == "Hello [REDACTED]" + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.asyncio +async def test_guardrail_not_found_uses_on_fail(): + """ + If a guardrail is not found, treat as error and use on_fail action. + """ + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="nonexistent-guard", + on_fail="block", + on_pass="allow", + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "test"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="test-policy", + ) + + assert result.terminal_action == "block" + assert result.step_results[0].outcome == "error" + assert "not found" in result.step_results[0].error_detail + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.asyncio +async def test_guardrail_not_found_with_next_continues(): + """ + If a guardrail is not found and on_fail is 'next', continue to next step. + """ + pass_guard = AlwaysPassGuardrail(guardrail_name="fallback-guard") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep( + guardrail="nonexistent-guard", + on_fail="next", + on_pass="allow", + ), + PipelineStep( + guardrail="fallback-guard", + on_fail="block", + on_pass="allow", + ), + ], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [pass_guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "test"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="test-policy", + ) + + assert result.terminal_action == "allow" + assert len(result.step_results) == 2 + assert result.step_results[0].outcome == "error" + assert result.step_results[0].action_taken == "next" + assert result.step_results[1].outcome == "pass" + assert pass_guard.calls == 1 + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.skipif(HTTPException is None, reason="fastapi not installed") +@pytest.mark.asyncio +async def test_single_step_pipeline_block(): + """Single step pipeline that blocks.""" + guard = AlwaysFailGuardrail(guardrail_name="blocker") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[PipelineStep(guardrail="blocker", on_fail="block")], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "test"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="test", + ) + + assert result.terminal_action == "block" + assert guard.calls == 1 + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.asyncio +async def test_single_step_pipeline_allow(): + """Single step pipeline that allows.""" + guard = AlwaysPassGuardrail(guardrail_name="passer") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[PipelineStep(guardrail="passer", on_pass="allow")], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "test"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="test", + ) + + assert result.terminal_action == "allow" + assert guard.calls == 1 + finally: + litellm.callbacks = original_callbacks + + +@pytest.mark.asyncio +async def test_step_results_include_duration(): + """Step results should include timing information.""" + guard = AlwaysPassGuardrail(guardrail_name="timed") + + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[PipelineStep(guardrail="timed")], + ) + + original_callbacks = litellm.callbacks.copy() + litellm.callbacks = [guard] + + try: + result = await PipelineExecutor.execute_steps( + steps=pipeline.steps, + mode=pipeline.mode, + data={"messages": [{"role": "user", "content": "test"}]}, + user_api_key_dict=MagicMock(), + call_type="completion", + policy_name="test", + ) + + assert result.step_results[0].duration_seconds is not None + assert result.step_results[0].duration_seconds >= 0 + finally: + litellm.callbacks = original_callbacks diff --git a/tests/test_litellm/proxy/test_pyroscope.py b/tests/test_litellm/proxy/test_pyroscope.py new file mode 100644 index 00000000000..6bfdf81ec1a --- /dev/null +++ b/tests/test_litellm/proxy/test_pyroscope.py @@ -0,0 +1,138 @@ +"""Unit tests for ProxyStartupEvent._init_pyroscope (Grafana Pyroscope profiling).""" + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.proxy.proxy_server import ProxyStartupEvent + + +def _mock_pyroscope_module(): + """Return a mock module so 'import pyroscope' succeeds in _init_pyroscope.""" + m = MagicMock() + m.configure = MagicMock() + return m + + +def test_init_pyroscope_returns_cleanly_when_disabled(): + """When LITELLM_ENABLE_PYROSCOPE is false, _init_pyroscope returns without error.""" + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=False, + ): + ProxyStartupEvent._init_pyroscope() + + +def test_init_pyroscope_raises_when_enabled_but_missing_app_name(): + """When LITELLM_ENABLE_PYROSCOPE is true but PYROSCOPE_APP_NAME is not set, raises ValueError.""" + mock_pyroscope = _mock_pyroscope_module() + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=True, + ), patch.dict( + sys.modules, + {"pyroscope": mock_pyroscope}, + ), patch.dict( + os.environ, + { + "PYROSCOPE_APP_NAME": "", + "PYROSCOPE_SERVER_ADDRESS": "http://localhost:4040", + }, + clear=False, + ): + with pytest.raises(ValueError, match="PYROSCOPE_APP_NAME"): + ProxyStartupEvent._init_pyroscope() + + +def test_init_pyroscope_raises_when_enabled_but_missing_server_address(): + """When LITELLM_ENABLE_PYROSCOPE is true but PYROSCOPE_SERVER_ADDRESS is not set, raises ValueError.""" + mock_pyroscope = _mock_pyroscope_module() + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=True, + ), patch.dict( + sys.modules, + {"pyroscope": mock_pyroscope}, + ), patch.dict( + os.environ, + { + "PYROSCOPE_APP_NAME": "myapp", + "PYROSCOPE_SERVER_ADDRESS": "", + }, + clear=False, + ): + with pytest.raises(ValueError, match="PYROSCOPE_SERVER_ADDRESS"): + ProxyStartupEvent._init_pyroscope() + + +def test_init_pyroscope_raises_when_sample_rate_invalid(): + """When PYROSCOPE_SAMPLE_RATE is not a number, raises ValueError.""" + mock_pyroscope = _mock_pyroscope_module() + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=True, + ), patch.dict( + sys.modules, + {"pyroscope": mock_pyroscope}, + ), patch.dict( + os.environ, + { + "PYROSCOPE_APP_NAME": "myapp", + "PYROSCOPE_SERVER_ADDRESS": "http://localhost:4040", + "PYROSCOPE_SAMPLE_RATE": "not-a-number", + }, + clear=False, + ): + with pytest.raises(ValueError, match="PYROSCOPE_SAMPLE_RATE"): + ProxyStartupEvent._init_pyroscope() + + +def test_init_pyroscope_accepts_integer_sample_rate(): + """When enabled with valid config and integer sample rate, configures pyroscope.""" + mock_pyroscope = _mock_pyroscope_module() + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=True, + ), patch.dict( + sys.modules, + {"pyroscope": mock_pyroscope}, + ), patch.dict( + os.environ, + { + "PYROSCOPE_APP_NAME": "myapp", + "PYROSCOPE_SERVER_ADDRESS": "http://localhost:4040", + "PYROSCOPE_SAMPLE_RATE": "100", + }, + clear=False, + ): + ProxyStartupEvent._init_pyroscope() + mock_pyroscope.configure.assert_called_once() + call_kw = mock_pyroscope.configure.call_args[1] + assert call_kw["app_name"] == "myapp" + assert call_kw["server_address"] == "http://localhost:4040" + assert call_kw["sample_rate"] == 100 + + +def test_init_pyroscope_accepts_float_sample_rate_parsed_as_int(): + """PYROSCOPE_SAMPLE_RATE can be a float string; it is parsed as integer.""" + mock_pyroscope = _mock_pyroscope_module() + with patch( + "litellm.proxy.proxy_server.get_secret_bool", + return_value=True, + ), patch.dict( + sys.modules, + {"pyroscope": mock_pyroscope}, + ), patch.dict( + os.environ, + { + "PYROSCOPE_APP_NAME": "myapp", + "PYROSCOPE_SERVER_ADDRESS": "http://localhost:4040", + "PYROSCOPE_SAMPLE_RATE": "100.7", + }, + clear=False, + ): + ProxyStartupEvent._init_pyroscope() + call_kw = mock_pyroscope.configure.call_args[1] + assert call_kw["sample_rate"] == 100 diff --git a/tests/test_litellm/types/__init__.py b/tests/test_litellm/types/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/types/proxy/__init__.py b/tests/test_litellm/types/proxy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/types/proxy/policy_engine/__init__.py b/tests/test_litellm/types/proxy/policy_engine/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/types/proxy/policy_engine/test_pipeline_types.py b/tests/test_litellm/types/proxy/policy_engine/test_pipeline_types.py new file mode 100644 index 00000000000..21fecc015a3 --- /dev/null +++ b/tests/test_litellm/types/proxy/policy_engine/test_pipeline_types.py @@ -0,0 +1,152 @@ +""" +Tests for pipeline type definitions. +""" + +import pytest +from pydantic import ValidationError + +from litellm.types.proxy.policy_engine.pipeline_types import ( + GuardrailPipeline, + PipelineExecutionResult, + PipelineStep, + PipelineStepResult, +) +from litellm.types.proxy.policy_engine.policy_types import ( + Policy, + PolicyGuardrails, +) + + +def test_pipeline_step_defaults(): + step = PipelineStep(guardrail="my-guard") + assert step.on_fail == "block" + assert step.on_pass == "allow" + assert step.pass_data is False + assert step.modify_response_message is None + + +def test_pipeline_step_valid_actions(): + step = PipelineStep(guardrail="my-guard", on_fail="next", on_pass="next") + assert step.on_fail == "next" + assert step.on_pass == "next" + + +def test_pipeline_step_all_action_types(): + for action in ("allow", "block", "next", "modify_response"): + step = PipelineStep(guardrail="g", on_fail=action, on_pass=action) + assert step.on_fail == action + assert step.on_pass == action + + +def test_pipeline_step_invalid_action_rejected(): + with pytest.raises(ValidationError): + PipelineStep(guardrail="my-guard", on_fail="invalid_action") + + +def test_pipeline_step_invalid_on_pass_rejected(): + with pytest.raises(ValidationError): + PipelineStep(guardrail="my-guard", on_pass="skip") + + +def test_pipeline_requires_at_least_one_step(): + with pytest.raises(ValidationError): + GuardrailPipeline(mode="pre_call", steps=[]) + + +def test_pipeline_invalid_mode_rejected(): + with pytest.raises(ValidationError): + GuardrailPipeline( + mode="during_call", + steps=[PipelineStep(guardrail="g")], + ) + + +def test_pipeline_valid_modes(): + for mode in ("pre_call", "post_call"): + pipeline = GuardrailPipeline( + mode=mode, + steps=[PipelineStep(guardrail="g")], + ) + assert pipeline.mode == mode + + +def test_pipeline_with_multiple_steps(): + pipeline = GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep(guardrail="g1", on_fail="next", on_pass="allow"), + PipelineStep(guardrail="g2", on_fail="block", on_pass="allow"), + ], + ) + assert len(pipeline.steps) == 2 + assert pipeline.steps[0].guardrail == "g1" + assert pipeline.steps[1].guardrail == "g2" + + +def test_policy_with_pipeline_parses(): + policy = Policy( + guardrails=PolicyGuardrails(add=["g1", "g2"]), + pipeline=GuardrailPipeline( + mode="pre_call", + steps=[ + PipelineStep(guardrail="g1", on_fail="next"), + PipelineStep(guardrail="g2"), + ], + ), + ) + assert policy.pipeline is not None + assert len(policy.pipeline.steps) == 2 + + +def test_policy_without_pipeline(): + policy = Policy( + guardrails=PolicyGuardrails(add=["g1"]), + ) + assert policy.pipeline is None + + +def test_pipeline_step_result(): + result = PipelineStepResult( + guardrail_name="g1", + outcome="fail", + action_taken="next", + error_detail="Content policy violation", + duration_seconds=0.05, + ) + assert result.outcome == "fail" + assert result.action_taken == "next" + + +def test_pipeline_execution_result(): + result = PipelineExecutionResult( + terminal_action="block", + step_results=[ + PipelineStepResult( + guardrail_name="g1", + outcome="fail", + action_taken="next", + ), + PipelineStepResult( + guardrail_name="g2", + outcome="fail", + action_taken="block", + ), + ], + error_message="Content blocked", + ) + assert result.terminal_action == "block" + assert len(result.step_results) == 2 + + +def test_pipeline_step_extra_fields_rejected(): + with pytest.raises(ValidationError): + PipelineStep(guardrail="g", unknown_field="value") + + +def test_pipeline_extra_fields_rejected(): + with pytest.raises(ValidationError): + GuardrailPipeline( + mode="pre_call", + steps=[PipelineStep(guardrail="g")], + unknown="value", + ) diff --git a/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py b/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py new file mode 100644 index 00000000000..c23ed5d4319 --- /dev/null +++ b/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py @@ -0,0 +1,102 @@ +""" +Tests for pipeline field on policy CRUD types (resolver_types.py). +""" + +import pytest + +from litellm.types.proxy.policy_engine.resolver_types import ( + PolicyCreateRequest, + PolicyDBResponse, + PolicyUpdateRequest, +) + + +def test_policy_create_request_with_pipeline(): + pipeline_data = { + "mode": "pre_call", + "steps": [ + {"guardrail": "g1", "on_fail": "next", "on_pass": "allow"}, + {"guardrail": "g2", "on_fail": "block", "on_pass": "allow"}, + ], + } + req = PolicyCreateRequest( + policy_name="test-policy", + guardrails_add=["g1", "g2"], + pipeline=pipeline_data, + ) + assert req.pipeline is not None + assert req.pipeline["mode"] == "pre_call" + assert len(req.pipeline["steps"]) == 2 + + +def test_policy_create_request_without_pipeline(): + req = PolicyCreateRequest( + policy_name="test-policy", + guardrails_add=["g1"], + ) + assert req.pipeline is None + + +def test_policy_update_request_with_pipeline(): + pipeline_data = { + "mode": "pre_call", + "steps": [ + {"guardrail": "g1", "on_fail": "block", "on_pass": "allow"}, + ], + } + req = PolicyUpdateRequest(pipeline=pipeline_data) + assert req.pipeline is not None + assert req.pipeline["steps"][0]["guardrail"] == "g1" + + +def test_policy_db_response_with_pipeline(): + pipeline_data = { + "mode": "pre_call", + "steps": [ + {"guardrail": "g1", "on_fail": "next", "on_pass": "allow"}, + {"guardrail": "g2", "on_fail": "block", "on_pass": "allow"}, + ], + } + resp = PolicyDBResponse( + policy_id="test-id", + policy_name="test-policy", + guardrails_add=["g1", "g2"], + pipeline=pipeline_data, + ) + assert resp.pipeline is not None + assert resp.pipeline["mode"] == "pre_call" + dumped = resp.model_dump() + assert dumped["pipeline"]["steps"][0]["guardrail"] == "g1" + + +def test_policy_db_response_without_pipeline(): + resp = PolicyDBResponse( + policy_id="test-id", + policy_name="test-policy", + ) + assert resp.pipeline is None + dumped = resp.model_dump() + assert dumped["pipeline"] is None + + +def test_policy_create_request_roundtrip(): + pipeline_data = { + "mode": "post_call", + "steps": [ + { + "guardrail": "g1", + "on_fail": "modify_response", + "on_pass": "next", + "pass_data": True, + "modify_response_message": "custom msg", + }, + ], + } + req = PolicyCreateRequest( + policy_name="roundtrip-test", + guardrails_add=["g1"], + pipeline=pipeline_data, + ) + dumped = req.model_dump() + restored = PolicyCreateRequest(**dumped) + assert restored.pipeline == pipeline_data diff --git a/ui/litellm-dashboard/e2e_tests/globalSetup.ts b/ui/litellm-dashboard/e2e_tests/globalSetup.ts index a725c58f35b..e37d0bd718b 100644 --- a/ui/litellm-dashboard/e2e_tests/globalSetup.ts +++ b/ui/litellm-dashboard/e2e_tests/globalSetup.ts @@ -8,7 +8,7 @@ async function globalSetup() { await page.goto("http://localhost:4000/ui/login"); await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email); await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password); - const loginButton = page.getByRole("button", { name: "Login" }); + const loginButton = page.getByRole("button", { name: "Login", exact: true }); await loginButton.click(); await page.waitForSelector("text=AI Gateway"); await page.context().storageState({ path: "admin.storageState.json" }); diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts index 5ac977ff0c8..1d445944712 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts @@ -6,7 +6,7 @@ test("user can log in", async ({ page }) => { await page.goto("http://localhost:4000/ui/login"); await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email); await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password); - const loginButton = page.getByRole("button", { name: "Login" }); + const loginButton = page.getByRole("button", { name: "Login", exact: true }); await expect(loginButton).toBeEnabled(); await loginButton.click(); await expect(page.getByText("AI Gateway")).toBeVisible(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts new file mode 100644 index 00000000000..c0379b25321 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts @@ -0,0 +1,63 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchAccessGroupDetails = async ( + accessToken: string, + accessGroupId: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useAccessGroupDetails = (accessGroupId?: string) => { + const { accessToken, userRole } = useAuthorized(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: accessGroupKeys.detail(accessGroupId!), + queryFn: async () => fetchAccessGroupDetails(accessToken!, accessGroupId!), + enabled: + Boolean(accessToken && accessGroupId) && + all_admin_roles.includes(userRole || ""), + + // Seed from the list cache when available + initialData: () => { + if (!accessGroupId) return undefined; + + const groups = queryClient.getQueryData( + accessGroupKeys.list({}), + ); + + return groups?.find((g) => g.access_group_id === accessGroupId); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts new file mode 100644 index 00000000000..587064353ac --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -0,0 +1,242 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAccessGroups, AccessGroupResponse } from "./useAccessGroups"; +import * as networking from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => "http://proxy.example"), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: unknown) => (data as { detail?: string })?.detail ?? "Unknown error"), + handleError: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token-123", + userRole: "Admin", + })), +})); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = createQueryClient(); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +const mockAccessToken = "test-token-123"; +const mockAccessGroups: AccessGroupResponse[] = [ + { + access_group_id: "ag-1", + access_group_name: "Group One", + description: "First group", + access_model_ids: [], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2025-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2025-01-01T00:00:00Z", + updated_by: "user-1", + }, +]; + +const fetchMock = vi.fn(); + +describe("useAccessGroups", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(networking.getProxyBaseUrl).mockReturnValue("http://proxy.example"); + vi.mocked(networking.getGlobalLitellmHeaderName).mockReturnValue("Authorization"); + + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "Admin", + } as any); + + global.fetch = fetchMock; + }); + + it("should return hook result without errors", () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current).toHaveProperty("data"); + expect(result.current).toHaveProperty("isSuccess"); + expect(result.current).toHaveProperty("isError"); + expect(result.current).toHaveProperty("status"); + }); + + it("should return access groups when access token and admin role are present", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockAccessGroups), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://proxy.example/v1/access_group", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }), + }), + ); + expect(result.current.data).toEqual(mockAccessGroups); + }); + + it("should not fetch when access token is null", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: null, + userRole: "Admin", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when access token is empty string", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: "", + userRole: "Admin", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when user role is not an admin role", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "Viewer", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when user role is null", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: null, + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should fetch when user role is proxy_admin", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "proxy_admin", + } as any); + + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockAccessGroups), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(fetchMock).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockAccessGroups); + }); + + it("should expose error state when fetch fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ detail: "Forbidden" }), + } as Response); + vi.mocked(networking.deriveErrorMessage).mockReturnValue("Forbidden"); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect((result.current.error as Error).message).toBe("Forbidden"); + expect(result.current.data).toBeUndefined(); + expect(networking.handleError).toHaveBeenCalledWith("Forbidden"); + }); + + it("should return empty array when API returns empty list", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + + it("should propagate network errors", async () => { + const networkError = new Error("Network failure"); + fetchMock.mockRejectedValue(networkError); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(networkError); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts new file mode 100644 index 00000000000..e5d8829278d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupResponse { + access_group_id: string; + access_group_name: string; + description: string | null; + access_model_ids: string[]; + access_mcp_server_ids: string[]; + access_agent_ids: string[]; + assigned_team_ids: string[]; + assigned_key_ids: string[]; + created_at: string; + created_by: string | null; + updated_at: string; + updated_by: string | null; +} + +// ── Query keys (shared across access-group hooks) ──────────────────────────── + +export const accessGroupKeys = createQueryKeys("accessGroups"); + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchAccessGroups = async ( + accessToken: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useAccessGroups = () => { + const { accessToken, userRole } = useAuthorized(); + + return useQuery({ + queryKey: accessGroupKeys.list({}), + queryFn: async () => fetchAccessGroups(accessToken!), + enabled: + Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts new file mode 100644 index 00000000000..4d71be94455 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupCreateParams { + access_group_name: string; + description?: string | null; + access_model_ids?: string[]; + access_mcp_server_ids?: string[]; + access_agent_ids?: string[]; + assigned_team_ids?: string[]; + assigned_key_ids?: string[]; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const createAccessGroup = async ( + accessToken: string, + params: AccessGroupCreateParams, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useCreateAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return createAccessGroup(accessToken, params); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts new file mode 100644 index 00000000000..5df5960ce0a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { accessGroupKeys } from "./useAccessGroups"; + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const deleteAccessGroup = async ( + accessToken: string, + accessGroupId: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + // 204 No Content — nothing to parse +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useDeleteAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (accessGroupId) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return deleteAccessGroup(accessToken, accessGroupId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts new file mode 100644 index 00000000000..1646458c63d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -0,0 +1,77 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupUpdateParams { + access_group_name?: string; + description?: string | null; + access_model_ids?: string[]; + access_mcp_server_ids?: string[]; + access_agent_ids?: string[]; + assigned_team_ids?: string[]; + assigned_key_ids?: string[]; +} + +export interface EditAccessGroupVariables { + accessGroupId: string; + params: AccessGroupUpdateParams; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const updateAccessGroup = async ( + accessToken: string, + accessGroupId: string, + params: AccessGroupUpdateParams, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "PUT", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useEditAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ accessGroupId, params }) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return updateAccessGroup(accessToken, accessGroupId, params); + }, + onSuccess: (_data, { accessGroupId }) => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + queryClient.invalidateQueries({ + queryKey: accessGroupKeys.detail(accessGroupId), + }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 28b8d81cc56..ae3bd76e3cf 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -35,6 +35,7 @@ import TransformRequestPanel from "@/components/transform_request"; import UIThemeSettings from "@/components/ui_theme_settings"; import Usage from "@/components/usage"; import UserDashboard from "@/components/user_dashboard"; +import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage"; import VectorStoreManagement from "@/components/vector_store_management"; import SpendLogsTable from "@/components/view_logs"; import ViewUserDashboard from "@/components/view_users"; @@ -542,6 +543,8 @@ function CreateKeyPageContent() { ) : page == "claude-code-plugins" ? ( + ) : page == "access-groups" ? ( + ) : page == "vector-stores" ? ( ) : page == "new_usage" ? ( diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx new file mode 100644 index 00000000000..db9d25d886f --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx @@ -0,0 +1,384 @@ +import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; +import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"); +vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({ + AccessGroupEditModal: ({ + visible, + onCancel, + }: { + visible: boolean; + onCancel: () => void; + }) => + visible ? ( +
+ +
+ ) : null, +})); + +const mockUseAccessGroupDetails = vi.mocked(useAccessGroupDetails); + +const baseMockReturnValue = { + data: undefined, + isLoading: false, + isError: false, + error: null, + isFetching: false, + isPending: false, + isSuccess: true, + status: "success" as const, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isRefetching: false, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isStale: false, + fetchStatus: "idle" as const, + refetch: vi.fn(), +} as unknown as ReturnType; + +const createMockAccessGroup = ( + overrides: Partial = {} +): AccessGroupResponse => ({ + access_group_id: "ag-1", + access_group_name: "Test Group", + description: "A test access group", + access_model_ids: ["model-1", "model-2"], + access_mcp_server_ids: ["mcp-1"], + access_agent_ids: ["agent-1"], + assigned_team_ids: ["team-1"], + assigned_key_ids: ["key-1", "key-2"], + created_at: "2025-01-01T00:00:00Z", + created_by: null, + updated_at: "2025-01-02T00:00:00Z", + updated_by: null, + ...overrides, +}); + +describe("AccessGroupDetail", () => { + const mockOnBack = vi.fn(); + const accessGroupId = "ag-1"; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup(), + } as ReturnType); + }); + + it("should render the component", () => { + renderWithProviders( + + ); + expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument(); + }); + + it("should not show access group content when loading", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: undefined, + isLoading: true, + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.queryByRole("heading", { name: "Test Group" })).not.toBeInTheDocument(); + }); + + it("should show empty state when access group is not found", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: undefined, + isLoading: false, + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("Access group not found")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("should call onBack when back button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const buttons = screen.getAllByRole("button"); + const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit")); + await user.click(backButton!); + + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it("should display access group name and ID", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument(); + expect(screen.getByText(/ID:/)).toBeInTheDocument(); + }); + + it("should display description in Group Details", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Group Details")).toBeInTheDocument(); + expect(screen.getByText("A test access group")).toBeInTheDocument(); + }); + + it("should display em dash when description is empty", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ description: null }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("should open edit modal when Edit Access Group button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); + + const editButton = screen.getByRole("button", { name: /Edit Access Group/i }); + await user.click(editButton); + + expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument(); + }); + + it("should close edit modal when Close Modal is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: /Edit Access Group/i })); + expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close Modal" })); + expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); + }); + + it("should display attached keys", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Attached Keys")).toBeInTheDocument(); + expect(screen.getByText("key-1")).toBeInTheDocument(); + expect(screen.getByText("key-2")).toBeInTheDocument(); + }); + + it("should display attached teams", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Attached Teams")).toBeInTheDocument(); + expect(screen.getByText("team-1")).toBeInTheDocument(); + }); + + it("should show View All button for keys when more than 5", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should toggle between View All and Show Less for keys", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "View All (6)" })); + expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show Less" })); + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show View All button for teams when more than 5", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_team_ids: ["t1", "t2", "t3", "t4", "t5", "t6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show empty state when no keys attached", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_key_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No keys attached")).toBeInTheDocument(); + }); + + it("should show empty state when no teams attached", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_team_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No teams attached")).toBeInTheDocument(); + }); + + it("should display Models tab with model IDs", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument(); + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-2")).toBeInTheDocument(); + }); + + it("should display MCP Servers tab with server IDs", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i }); + expect(mcpTab).toBeInTheDocument(); + await user.click(mcpTab); + expect(screen.getByText("mcp-1")).toBeInTheDocument(); + }); + + it("should display Agents tab with agent IDs", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const agentsTab = screen.getByRole("tab", { name: /Agents/i }); + expect(agentsTab).toBeInTheDocument(); + await user.click(agentsTab); + expect(screen.getByText("agent-1")).toBeInTheDocument(); + }); + + it("should show empty state in Models tab when no models assigned", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_model_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No models assigned to this group")).toBeInTheDocument(); + }); + + it("should show empty state in MCP Servers tab when none assigned", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_mcp_server_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument(); + }); + + it("should show empty state in Agents tab when none assigned", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_agent_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("tab", { name: /Agents/i })); + expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument(); + }); + + it("should truncate long key IDs with ellipsis", () => { + const longKeyId = "a".repeat(25); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument(); + }); + + it("should display created and last updated timestamps", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Created")).toBeInTheDocument(); + expect(screen.getByText("Last Updated")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx new file mode 100644 index 00000000000..9b794959baa --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx @@ -0,0 +1,345 @@ +import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; +import { + Button, + Card, + Col, + Descriptions, + Empty, + Flex, + Layout, + List, + Row, + Spin, + Tabs, + Tag, + theme, + Typography +} from "antd"; +import { + ArrowLeftIcon, + BotIcon, + EditIcon, + KeyIcon, + LayersIcon, + ServerIcon, + UsersIcon, +} from "lucide-react"; +import { useState } from "react"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; +import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; + +const { Title, Text } = Typography; +const { Content } = Layout; + +interface AccessGroupDetailProps { + accessGroupId: string; + onBack: () => void; +} + +export function AccessGroupDetail({ + accessGroupId, + onBack, +}: AccessGroupDetailProps) { + const { data: accessGroup, isLoading } = + useAccessGroupDetails(accessGroupId); + const { token } = theme.useToken(); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [showAllKeys, setShowAllKeys] = useState(false); + const [showAllTeams, setShowAllTeams] = useState(false); + + const MAX_PREVIEW = 5; + + if (isLoading) { + return ( + + + + + + ); + } + + if (!accessGroup) { + return ( + + + + + {/* Group Details */} + + + + + {accessGroup.description || "—"} + + + {new Date(accessGroup.created_at).toLocaleString()} + {accessGroup.created_by && ( + +  {"by"}  + + + )} + + + {new Date(accessGroup.updated_at).toLocaleString()} + {accessGroup.updated_by && ( + +  {"by"}  + + + )} + + + + + + {/* Attached Keys & Teams */} + + + + + Attached Keys + {keyIds.length} + + } + extra={ + keyIds.length > MAX_PREVIEW ? ( + + ) : null + } + > + {keyIds.length > 0 ? ( + + {displayedKeys.map((id) => ( + + + {id.length > 20 + ? `${id.slice(0, 10)}...${id.slice(-6)}` + : id} + + + ))} + + ) : ( + + )} + + + + + + Attached Teams + {teamIds.length} + + } + extra={ + teamIds.length > MAX_PREVIEW ? ( + + ) : null + } + > + {teamIds.length > 0 ? ( + + {displayedTeams.map((id) => ( + + + {id} + + + ))} + + ) : ( + + )} + + + + + {/* Resources Tabs */} + + + + + {/* Edit Modal */} + setIsEditModalVisible(false)} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx new file mode 100644 index 00000000000..df60457571e --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -0,0 +1,159 @@ +import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import type { FormInstance } from "antd"; +import { Form, Input, Select, Space, Tabs } from "antd"; +import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react"; + +const { TextArea } = Input; + +export interface AccessGroupFormValues { + name: string; + description: string; + modelIds: string[]; + mcpServerIds: string[]; + agentIds: string[]; +} + +interface AccessGroupBaseFormProps { + form: FormInstance; + isNameDisabled?: boolean; +} + +export function AccessGroupBaseForm({ + form, + isNameDisabled = false, +}: AccessGroupBaseFormProps) { + const { data: agentsData } = useAgents(); + const { data: mcpServersData } = useMCPServers(); + + const agents = agentsData?.agents ?? []; + const mcpServers = mcpServersData ?? []; + const items = [ + { + key: "1", + label: ( + + + General Info + + ), + children: ( +
+ + + + +