mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge branch 'litellm_internal_staging' into litellm_lit5003_autorouter_presets
Staging landed #35705 (LIT-5133) in the same window, which added a declarative submitBlockedReason (missing tiers or an empty keyword rule) that disables the Add Auto Router button with a Tooltip explaining why. That touched the same button and validation area as this branch's async preset-availability check. Merged both: the button is disabled by submitBlockedReason (synchronous, tier/keyword completeness) and additionally shows a loading state while verifyPresetStillAvailable runs (asynchronous, preset-model-availability). One of staging's new keyword-rule tests needed the same "select Custom Configuration first" fix already applied to the team and session-affinity tests, since it never touches the template selector and collided with the required-template guard.
This commit is contained in:
commit
91513f38bc
57 changed files with 6034 additions and 631 deletions
|
|
@ -88,6 +88,36 @@ commands:
|
|||
rm -f /tmp/uv-install.sh
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
install_rust:
|
||||
description: "Install pinned rustup (1.28.2) and Rust toolchain (1.97.1) with checksum verification. Adds ~/.cargo/bin to PATH. Run this before any `uv sync` or `uv build` of the workspace: the root package builds litellm-rust through maturin, and on an image without cargo maturin fetches an unpinned rustup and a floating toolchain by itself."
|
||||
steps:
|
||||
- run:
|
||||
name: Install Rust (rustup 1.28.2, toolchain 1.97.1)
|
||||
command: |
|
||||
case "$(uname -m)" in
|
||||
x86_64)
|
||||
RUSTUP_TRIPLE=x86_64-unknown-linux-gnu
|
||||
RUSTUP_SHA256=20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c
|
||||
;;
|
||||
aarch64)
|
||||
RUSTUP_TRIPLE=aarch64-unknown-linux-gnu
|
||||
RUSTUP_SHA256=e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c
|
||||
;;
|
||||
*)
|
||||
echo "install_rust: unsupported architecture $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
curl -sSLf -o /tmp/rustup-init \
|
||||
"https://static.rust-lang.org/rustup/archive/1.28.2/${RUSTUP_TRIPLE}/rustup-init"
|
||||
echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c -
|
||||
chmod +x /tmp/rustup-init
|
||||
/tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.97.1
|
||||
rm -f /tmp/rustup-init
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$BASH_ENV"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustc --version
|
||||
cargo --version
|
||||
start_postgres:
|
||||
description: "Start a postgres-db container on port 5432 and wait until it accepts connections."
|
||||
parameters:
|
||||
|
|
@ -163,6 +193,26 @@ commands:
|
|||
done
|
||||
echo "fake OpenAI endpoint did not become ready" >&2
|
||||
exit 1
|
||||
start_cost_center_service:
|
||||
description: "Start the stand-in cost center validation service (tests/store_model_in_db_tests/cost_center_service.py) on host port 9414 and wait until healthy. The proxy's team-metadata validator (team_metadata_validator_e2e.py, impl 'http') reaches it via TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate. Run after uv deps are synced."
|
||||
steps:
|
||||
- run:
|
||||
name: Start cost center validation service
|
||||
background: true
|
||||
command: |
|
||||
uv run --no-sync python tests/store_model_in_db_tests/cost_center_service.py --host 0.0.0.0 --port 9414
|
||||
- run:
|
||||
name: Wait for cost center validation service
|
||||
command: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:9414/health >/dev/null 2>&1; then
|
||||
echo "cost center validation service is up"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "cost center validation service did not become ready" >&2
|
||||
exit 1
|
||||
setup_litellm_enterprise_pip:
|
||||
steps:
|
||||
- run:
|
||||
|
|
@ -178,6 +228,7 @@ commands:
|
|||
- checkout
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -292,6 +343,7 @@ jobs:
|
|||
- checkout
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Build the wheel
|
||||
environment:
|
||||
|
|
@ -324,6 +376,7 @@ jobs:
|
|||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -397,6 +450,7 @@ jobs:
|
|||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -471,6 +525,7 @@ jobs:
|
|||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -522,6 +577,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -588,6 +644,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -628,6 +685,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -669,6 +727,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -702,6 +761,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -752,6 +812,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -803,6 +864,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -836,6 +898,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -882,6 +945,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -928,6 +992,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -970,6 +1035,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1016,6 +1082,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1063,6 +1130,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -1103,6 +1171,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1148,6 +1217,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1192,6 +1262,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1224,6 +1295,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1267,6 +1339,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1311,6 +1384,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1355,6 +1429,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1386,6 +1461,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1432,6 +1508,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1477,6 +1554,7 @@ jobs:
|
|||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1527,6 +1605,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1551,6 +1630,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1577,6 +1657,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1678,6 +1759,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1773,6 +1855,7 @@ jobs:
|
|||
at: ~/project
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1861,6 +1944,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -1944,6 +2028,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2076,6 +2161,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2162,6 +2248,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2258,12 +2345,14 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
uv sync --frozen --all-groups --all-extras --python 3.12
|
||||
- start_postgres
|
||||
- start_fake_openai_endpoint
|
||||
- start_cost_center_service
|
||||
- attach_workspace:
|
||||
at: ~/project
|
||||
- run:
|
||||
|
|
@ -2283,11 +2372,13 @@ jobs:
|
|||
-e STORE_MODEL_IN_DB="True" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
|
||||
-e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \
|
||||
-e LITELLM_LICENSE=$LITELLM_LICENSE \
|
||||
-e LITELLM_LOG=ERROR \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name my-app \
|
||||
-v $(pwd)/litellm/proxy/example_config_yaml/store_model_db_config.yaml:/app/config.yaml \
|
||||
-v $(pwd)/litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py:/app/team_metadata_validator_e2e.py \
|
||||
litellm-docker-database:ci \
|
||||
--config /app/config.yaml \
|
||||
--port 4000
|
||||
|
|
@ -2333,6 +2424,7 @@ jobs:
|
|||
- setup_google_dns
|
||||
# Remove Docker CLI installation since it's already available in machine executor
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2414,6 +2506,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2553,6 +2646,7 @@ jobs:
|
|||
- skip_if_unrelated_changes
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -2743,6 +2837,7 @@ jobs:
|
|||
category: client
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
@ -2885,6 +2980,7 @@ jobs:
|
|||
category: client
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1975,6 +1975,7 @@
|
|||
"prompt_cache_min_tokens": 2048
|
||||
},
|
||||
"anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -2011,6 +2012,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"global.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -2047,6 +2049,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"us.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2083,6 +2086,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"eu.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2119,6 +2123,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"au.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2155,6 +2160,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
|
|||
|
|
@ -543,6 +543,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v2/team/list",
|
||||
"/organization/list",
|
||||
"/team/available",
|
||||
"/team/metadata_schema",
|
||||
"/user/info",
|
||||
"/v2/user/info",
|
||||
"/model/info",
|
||||
|
|
@ -609,6 +610,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/block",
|
||||
"/team/unblock",
|
||||
"/team/available",
|
||||
"/team/metadata_schema",
|
||||
"/team/permissions_list",
|
||||
"/team/permissions_update",
|
||||
"/team/permissions_bulk_update",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"""Example validator for `general_settings.custom_team_metadata_validate`.
|
||||
|
||||
Wire it up in the proxy config:
|
||||
|
||||
```yaml
|
||||
general_settings:
|
||||
custom_team_metadata_validate: custom_team_metadata_validate.validate_team_metadata
|
||||
team_metadata_validation_timeout: 5
|
||||
team_metadata_validation_error_message: "Validation service unavailable, contact your admin."
|
||||
```
|
||||
|
||||
Return `valid=False` with an `error_message` to reject the write with that
|
||||
message (HTTP 400). Raise any exception (for example, when the upstream
|
||||
validation service is unreachable) to fail closed with the generic
|
||||
`team_metadata_validation_error_message` (HTTP 503).
|
||||
"""
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
VALID_COST_CENTERS = frozenset({"CC-1001", "CC-1002", "CC-2001"})
|
||||
|
||||
|
||||
async def validate_team_metadata(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="Team metadata must include a cost_center. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in VALID_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
|
@ -7,4 +7,7 @@ model_list:
|
|||
|
||||
general_settings:
|
||||
store_model_in_db: true
|
||||
custom_team_metadata_validate: team_metadata_validator_e2e.validate_team_metadata
|
||||
team_metadata_validation_timeout: 5
|
||||
team_metadata_validation_error_message: "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."
|
||||
|
||||
|
|
|
|||
107
litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py
Normal file
107
litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Dispatching team metadata validator for the store_model_in_db e2e suite.
|
||||
|
||||
The suite runs one proxy with one config, so a single registered validator
|
||||
dispatches to one of three independent implementations chosen per request via
|
||||
the `_e2e_validator_impl` metadata key:
|
||||
|
||||
- `allowlist`: requires `cost_center` and checks it against a static set
|
||||
- `http`: POSTs the metadata to the cost center service at
|
||||
`TEAM_METADATA_VALIDATION_SERVICE_URL`; transport errors raise (fail closed)
|
||||
- `http_down`: like `http` but targets a closed port, proving the 503 path
|
||||
- `immutable`: requires `cost_center` and forbids changing it once set
|
||||
|
||||
A request whose metadata carries no `_e2e_validator_impl` key is accepted
|
||||
untouched, so the rest of the suite's team operations are unaffected. An
|
||||
unknown impl value raises, which the proxy converts to the fail-closed 503.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
|
||||
CLOSED_PORT_URL = "http://127.0.0.1:9/validate"
|
||||
|
||||
|
||||
async def _validate_allowlist(payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="cost_center is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in ALLOWED_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
async def _validate_via_http(payload: TeamMetadataValidationPayload, service_url: str) -> TeamMetadataValidationResult:
|
||||
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
|
||||
response = await client.post(
|
||||
service_url,
|
||||
json={ # mutable-ok: httpx serializes the request body from a plain dict
|
||||
"operation": payload.operation,
|
||||
"metadata": payload.metadata,
|
||||
},
|
||||
timeout=2.0,
|
||||
)
|
||||
body = response.json()
|
||||
if body.get("ok") is True:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=body.get("reason", "Rejected by the cost center service."),
|
||||
)
|
||||
|
||||
|
||||
class _ImmutableCostCenterValidator:
|
||||
def __init__(self, immutable_key: str = "cost_center") -> None:
|
||||
self.immutable_key = immutable_key
|
||||
|
||||
async def __call__(self, payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
|
||||
current = payload.metadata.get(self.immutable_key)
|
||||
if current is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if payload.operation == "update" and payload.existing_metadata is not None:
|
||||
prior = payload.existing_metadata.get(self.immutable_key)
|
||||
if prior is not None and prior != current:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=(
|
||||
f"{self.immutable_key} is immutable once set "
|
||||
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
|
||||
),
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
_IMMUTABLE_VALIDATOR = _ImmutableCostCenterValidator()
|
||||
|
||||
|
||||
async def validate_team_metadata(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
impl = payload.metadata.get("_e2e_validator_impl")
|
||||
if impl is None:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
if impl == "allowlist":
|
||||
return await _validate_allowlist(payload)
|
||||
if impl == "http":
|
||||
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", "http://localhost:9414/validate")
|
||||
return await _validate_via_http(payload, service_url)
|
||||
if impl == "http_down":
|
||||
return await _validate_via_http(payload, CLOSED_PORT_URL)
|
||||
if impl == "immutable":
|
||||
return await _IMMUTABLE_VALIDATOR(payload)
|
||||
raise ValueError(f"unknown _e2e_validator_impl: {impl}")
|
||||
|
|
@ -10,6 +10,18 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail") -> RubrikLogger:
|
||||
"""Create and register a RubrikLogger instance.
|
||||
|
||||
The ``mode`` field in the guardrail config controls which surfaces are
|
||||
moderated:
|
||||
- ``pre_call`` (or a mode that includes it): prompt moderation via the
|
||||
``/v1/before_prompt/openai/v1`` webhook.
|
||||
- ``post_call`` (the default when ``mode`` is omitted): response and tool
|
||||
call moderation via the ``/v1/after_completion/openai/v1`` webhook.
|
||||
|
||||
Both hooks are active when ``mode`` covers both ``pre_call`` and
|
||||
``post_call``.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
rubrik_callback = RubrikLogger(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from litellm.repositories.team_repository import TeamRepository
|
|||
from litellm.router import Router
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
STRATEGY_ROUTER_PARAM_FIELDS,
|
||||
validate_complexity_router_config_write,
|
||||
validate_strategy_router_model_write,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
|
|
@ -117,11 +118,20 @@ def _strategy_router_write_violation(
|
|||
An auto-router deployment's ``litellm_params.model`` (``auto_router/...``) is
|
||||
the discriminator the router loads it by; a write that mangles it makes the
|
||||
router drop the deployment silently under ``ignore_invalid_deployments``.
|
||||
Only writes that supply ``litellm_params.model`` are judged, against the
|
||||
merged (stored + incoming) params, so partial patches and restores of an
|
||||
already-corrupted row stay legal. Returns the violation, or None.
|
||||
Only writes that supply ``litellm_params.model`` are judged on the naming
|
||||
contract, against the merged (stored + incoming) params, so partial patches
|
||||
and restores of an already-corrupted row stay legal. A config is judged only
|
||||
when the write carries one, for the same reason: a rename must not be held
|
||||
hostage by a stored config it does not touch. Returns the violation, or None.
|
||||
"""
|
||||
if incoming_params is None or incoming_params.model is None:
|
||||
if incoming_params is None:
|
||||
return None
|
||||
config_violation = validate_complexity_router_config_write(
|
||||
complexity_router_config=incoming_params.complexity_router_config
|
||||
)
|
||||
if config_violation is not None:
|
||||
return config_violation
|
||||
if incoming_params.model is None:
|
||||
return None
|
||||
present_fields = frozenset(
|
||||
field
|
||||
|
|
|
|||
|
|
@ -113,6 +113,10 @@ from litellm.proxy.management_helpers.object_permission_utils import (
|
|||
from litellm.proxy.management_helpers.team_member_permission_checks import (
|
||||
TeamMemberPermissionChecks,
|
||||
)
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
validate_team_metadata_if_configured,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
add_new_member,
|
||||
management_endpoint_wrapper,
|
||||
|
|
@ -147,6 +151,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
|
|||
TeamListResponse,
|
||||
TeamMemberAddResult,
|
||||
TeamMemberInfoResponse,
|
||||
TeamMetadataSchemaResponse,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
)
|
||||
|
||||
|
|
@ -1287,6 +1292,18 @@ async def new_team(
|
|||
|
||||
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
|
||||
|
||||
if isinstance(data.metadata, dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
|
||||
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata=data.metadata,
|
||||
existing_metadata=None,
|
||||
team_id=data.team_id,
|
||||
team_alias=data.team_alias,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
## ADD TO MODEL TABLE
|
||||
_model_id = None
|
||||
if data.model_aliases is not None and isinstance(data.model_aliases, dict):
|
||||
|
|
@ -1301,9 +1318,6 @@ async def new_team(
|
|||
|
||||
_model_id = model_dict.id
|
||||
|
||||
## Create Team Member Budget Table
|
||||
if isinstance(data.metadata, dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
|
||||
data_json = data.json()
|
||||
|
||||
## Handle Object Permission - MCP, Vector Stores etc.
|
||||
|
|
@ -1965,6 +1979,25 @@ async def update_team(
|
|||
if isinstance(updated_kv.get("metadata"), dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(updated_kv["metadata"])
|
||||
|
||||
if "metadata" in updated_kv:
|
||||
stored_metadata = (
|
||||
{ # mutable-ok: the validator payload's isinstance guard requires a plain dict
|
||||
key: value
|
||||
for key, value in existing_team_row.metadata.items()
|
||||
if key not in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS
|
||||
}
|
||||
if isinstance(existing_team_row.metadata, dict)
|
||||
else None
|
||||
)
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="update",
|
||||
metadata=updated_kv.get("metadata"),
|
||||
existing_metadata=stored_metadata,
|
||||
team_id=data.team_id,
|
||||
team_alias=data.team_alias if data.team_alias is not None else existing_team_row.team_alias,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# Check budget_duration and budget_reset_at
|
||||
_set_budget_reset_at(data, updated_kv)
|
||||
|
||||
|
|
@ -4179,6 +4212,24 @@ async def unblock_team(
|
|||
return record
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/metadata_schema",
|
||||
tags=["team management"], # mutable-ok: fastapi's decorator signature types tags as a list
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
response_model=TeamMetadataSchemaResponse,
|
||||
)
|
||||
async def get_team_metadata_schema():
|
||||
"""
|
||||
Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
|
||||
|
||||
The UI uses this to prepopulate the team metadata form with the declared
|
||||
keys. Returns an empty ``fields`` list when no schema is configured. This
|
||||
schema is advisory; server-side enforcement stays with
|
||||
``custom_team_metadata_validate``.
|
||||
"""
|
||||
return TeamMetadataSchemaResponse(fields=TEAM_METADATA_SCHEMA_REGISTRY.get())
|
||||
|
||||
|
||||
@router.get("/team/available")
|
||||
async def list_available_teams(
|
||||
http_request: Request,
|
||||
|
|
|
|||
193
litellm/proxy/management_helpers/team_metadata_validation.py
Normal file
193
litellm/proxy/management_helpers/team_metadata_validation.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Custom validation of team metadata on team create/update.
|
||||
|
||||
Operators point `general_settings.custom_team_metadata_validate` at an async
|
||||
Python function (loaded via `get_instance_fn`, like `custom_key_generate`).
|
||||
The function receives a `TeamMetadataValidationPayload` and returns a
|
||||
`TeamMetadataValidationResult`. The proxy awaits it before committing a team
|
||||
write and fails closed: a rejected value surfaces the function's own message
|
||||
(HTTP 400), while any raised exception or timeout blocks the write with a
|
||||
generic message (HTTP 503).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
TeamMetadataFieldSchema,
|
||||
)
|
||||
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS = 5.0
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE = (
|
||||
"Team metadata validation is currently unavailable, so the team was not saved. Contact your proxy admin."
|
||||
)
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE = "Team metadata failed validation."
|
||||
|
||||
|
||||
class TeamMetadataRequester(BaseModel):
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
user_role: str | None = None
|
||||
|
||||
|
||||
class TeamMetadataValidationPayload(BaseModel):
|
||||
operation: Literal["create", "update"]
|
||||
metadata: Mapping[str, JsonValue]
|
||||
existing_metadata: Mapping[str, JsonValue] | None = None
|
||||
team_id: str | None = None
|
||||
team_alias: str | None = None
|
||||
requester: TeamMetadataRequester
|
||||
|
||||
|
||||
class TeamMetadataValidationResult(BaseModel):
|
||||
valid: bool
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
_EMPTY_METADATA: Mapping[str, JsonValue] = MappingProxyType({})
|
||||
|
||||
|
||||
class TeamMetadataValidator(Protocol):
|
||||
def __call__(self, payload: TeamMetadataValidationPayload, /) -> Awaitable[TeamMetadataValidationResult]: ...
|
||||
|
||||
|
||||
class TeamMetadataValidatorRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._validator: TeamMetadataValidator | None = None
|
||||
|
||||
def set(self, validator: TeamMetadataValidator | None) -> None:
|
||||
self._validator = validator
|
||||
|
||||
def get(self) -> TeamMetadataValidator | None:
|
||||
return self._validator
|
||||
|
||||
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY = TeamMetadataValidatorRegistry()
|
||||
|
||||
_TEAM_METADATA_SCHEMA_ADAPTER: TypeAdapter[tuple[TeamMetadataFieldSchema, ...]] = TypeAdapter(
|
||||
tuple[TeamMetadataFieldSchema, ...]
|
||||
)
|
||||
|
||||
|
||||
def parse_team_metadata_schema(raw_schema: object) -> tuple[TeamMetadataFieldSchema, ...]:
|
||||
"""Parse ``general_settings.team_metadata_schema``; raises on a malformed schema so config load fails fast."""
|
||||
if raw_schema is None:
|
||||
return ()
|
||||
fields = _TEAM_METADATA_SCHEMA_ADAPTER.validate_python(raw_schema)
|
||||
keys = tuple(field.key for field in fields)
|
||||
duplicate_keys = sorted(frozenset(key for key in keys if keys.count(key) > 1))
|
||||
if duplicate_keys:
|
||||
raise ValueError(f"team_metadata_schema contains duplicate keys: {', '.join(duplicate_keys)}")
|
||||
return fields
|
||||
|
||||
|
||||
class TeamMetadataSchemaRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._fields: tuple[TeamMetadataFieldSchema, ...] = ()
|
||||
|
||||
def set(self, fields: tuple[TeamMetadataFieldSchema, ...]) -> None:
|
||||
self._fields = fields
|
||||
|
||||
def get(self) -> tuple[TeamMetadataFieldSchema, ...]:
|
||||
return self._fields
|
||||
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY = TeamMetadataSchemaRegistry()
|
||||
|
||||
|
||||
async def run_team_metadata_validation(
|
||||
validator: TeamMetadataValidator,
|
||||
payload: TeamMetadataValidationPayload,
|
||||
premium_user: bool,
|
||||
timeout_seconds: float,
|
||||
unavailable_message: str,
|
||||
) -> None:
|
||||
if premium_user is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": f"custom_team_metadata_validate is an Enterprise feature. {CommonProxyErrors.not_premium_user.value}"
|
||||
},
|
||||
)
|
||||
if not (
|
||||
inspect.iscoroutinefunction(validator) or inspect.iscoroutinefunction(getattr(validator, "__call__", None))
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": "custom_team_metadata_validate must be an async function"
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
raw_result = await asyncio.wait_for(validator(payload), timeout=timeout_seconds)
|
||||
result = TeamMetadataValidationResult.model_validate(raw_result)
|
||||
except Exception: # noqa: BLE001 # fail closed: any validator failure must block the team write
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"error": unavailable_message}, # mutable-ok: HTTPException.detail has no immutable form
|
||||
)
|
||||
|
||||
if not result.valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": result.error_message or DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _read_timeout_seconds(general_settings: Mapping[str, object]) -> float:
|
||||
raw_timeout = general_settings.get("team_metadata_validation_timeout")
|
||||
if isinstance(raw_timeout, (int, float)) and not isinstance(raw_timeout, bool) and raw_timeout > 0:
|
||||
return float(raw_timeout)
|
||||
return DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _read_unavailable_message(general_settings: Mapping[str, object]) -> str:
|
||||
raw_message = general_settings.get("team_metadata_validation_error_message")
|
||||
if isinstance(raw_message, str) and raw_message.strip():
|
||||
return raw_message
|
||||
return DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE
|
||||
|
||||
|
||||
async def validate_team_metadata_if_configured(
|
||||
operation: Literal["create", "update"],
|
||||
metadata: Mapping[str, JsonValue] | None,
|
||||
existing_metadata: Mapping[str, JsonValue] | None,
|
||||
team_id: str | None,
|
||||
team_alias: str | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
registry: TeamMetadataValidatorRegistry = TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
) -> None:
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user
|
||||
|
||||
validator = registry.get()
|
||||
if validator is None:
|
||||
return
|
||||
|
||||
payload = TeamMetadataValidationPayload(
|
||||
operation=operation,
|
||||
metadata=metadata if isinstance(metadata, dict) else _EMPTY_METADATA,
|
||||
existing_metadata=existing_metadata if isinstance(existing_metadata, dict) else None,
|
||||
team_id=team_id,
|
||||
team_alias=team_alias,
|
||||
requester=TeamMetadataRequester(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
user_email=user_api_key_dict.user_email,
|
||||
user_role=user_api_key_dict.user_role.value if user_api_key_dict.user_role is not None else None,
|
||||
),
|
||||
)
|
||||
await run_team_metadata_validation(
|
||||
validator=validator,
|
||||
payload=payload,
|
||||
premium_user=premium_user,
|
||||
timeout_seconds=_read_timeout_seconds(general_settings),
|
||||
unavailable_message=_read_unavailable_message(general_settings),
|
||||
)
|
||||
|
|
@ -461,6 +461,11 @@ from litellm.proxy.management_helpers.audit_logs import (
|
|||
create_audit_log_for_update,
|
||||
create_object_audit_log,
|
||||
)
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
from litellm.proxy.memory.memory_endpoints import router as memory_router
|
||||
from litellm.proxy.middleware.billable_request_metrics_middleware import (
|
||||
BillableRequestMetricsMiddleware,
|
||||
|
|
@ -783,6 +788,8 @@ def cleanup_router_config_variables():
|
|||
user_custom_auth_path = None
|
||||
user_custom_key_generate = None
|
||||
user_custom_key_update = None
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
user_custom_sso = None
|
||||
user_custom_ui_sso_sign_in_handler = None
|
||||
use_background_health_checks = None
|
||||
|
|
@ -3499,6 +3506,7 @@ _DB_OVERLAY_REMOTE_MODULE_STR_FIELDS: dict[str, tuple[str, ...]] = {
|
|||
"custom_auth",
|
||||
"custom_key_generate",
|
||||
"custom_key_update",
|
||||
"custom_team_metadata_validate",
|
||||
"custom_sso",
|
||||
"custom_ui_sso_sign_in_handler",
|
||||
),
|
||||
|
|
@ -4829,6 +4837,14 @@ class ProxyConfig:
|
|||
if custom_key_update is not None:
|
||||
user_custom_key_update = get_instance_fn(value=custom_key_update, config_file_path=config_file_path)
|
||||
|
||||
custom_team_metadata_validate = general_settings.get("custom_team_metadata_validate", None)
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(
|
||||
get_instance_fn(value=custom_team_metadata_validate, config_file_path=config_file_path)
|
||||
if custom_team_metadata_validate is not None
|
||||
else None
|
||||
)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema(general_settings.get("team_metadata_schema")))
|
||||
|
||||
custom_sso = general_settings.get("custom_sso", None)
|
||||
if custom_sso is not None:
|
||||
user_custom_sso = get_instance_fn(value=custom_sso, config_file_path=config_file_path)
|
||||
|
|
|
|||
|
|
@ -62,12 +62,42 @@ def classify_strategy_router_model(model: str) -> StrategyRouterKind | None:
|
|||
return "semantic"
|
||||
|
||||
|
||||
def validate_complexity_router_config_write(complexity_router_config: Mapping[str, object] | None) -> str | None:
|
||||
"""Reject a complexity config the router would refuse to build a deployment from.
|
||||
|
||||
Parsed with the router's own ``ComplexityRouterConfig`` rather than a copy of
|
||||
its rules, so the boundary rejects exactly what the load would. Plugins are
|
||||
resolved from dotted paths only on the config.yaml path, so a written config
|
||||
reaches this function in the same shape the load hands to the same model.
|
||||
Judged on the config alone: a patch may write one without naming a model, and
|
||||
the stored model is encrypted at rest, so it cannot be classified here.
|
||||
"""
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig
|
||||
|
||||
if complexity_router_config is None:
|
||||
return None
|
||||
try:
|
||||
_ = ComplexityRouterConfig.model_validate(complexity_router_config)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
location = ".".join(str(part) for part in first.get("loc", ())) or "complexity_router_config"
|
||||
return (
|
||||
f"complexity_router_config is invalid at {location}: {first.get('msg', 'invalid value')}. "
|
||||
"The router would drop this deployment at load time, so the write is rejected instead."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def validate_strategy_router_model_write(model: str, present_fields: frozenset[str]) -> str | None:
|
||||
"""Check that writing ``model`` leaves a deployment the router can load.
|
||||
|
||||
``present_fields`` is the set of strategy-router param fields that are
|
||||
non-None on the deployment after the write (stored fields merged with the
|
||||
incoming ones). Returns a human-readable violation, or None when coherent.
|
||||
A config's contents are ``validate_complexity_router_config_write``'s to
|
||||
judge, since a write may carry one without naming a model at all.
|
||||
"""
|
||||
kind = classify_strategy_router_model(model)
|
||||
if kind is None:
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ class BedrockToolSpec(dict):
|
|||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
if supports_strict_tools and strict is not None:
|
||||
if supports_strict_tools and strict:
|
||||
tool_spec["strict"] = strict
|
||||
|
||||
super().__init__(toolSpec=tool_spec)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from litellm.proxy._types import (
|
||||
KeyManagementRoutes,
|
||||
LiteLLM_DeletedTeamTable,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
Member,
|
||||
)
|
||||
|
||||
|
|
@ -125,3 +124,22 @@ class TeamMemberInfoResponse(LiteLLM_TeamMembership):
|
|||
role: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
team_alias: Optional[str] = None
|
||||
|
||||
|
||||
class TeamMetadataFieldSchema(BaseModel):
|
||||
"""One declared team metadata field from ``general_settings.team_metadata_schema``.
|
||||
|
||||
Advisory only: the UI uses it to prepopulate the team metadata form.
|
||||
Enforcement stays with ``custom_team_metadata_validate``.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1)
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class TeamMetadataSchemaResponse(BaseModel):
|
||||
"""Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured."""
|
||||
|
||||
fields: tuple[TeamMetadataFieldSchema, ...]
|
||||
|
|
|
|||
|
|
@ -1975,6 +1975,7 @@
|
|||
"prompt_cache_min_tokens": 2048
|
||||
},
|
||||
"anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -2011,6 +2012,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"global.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.5e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
|
|
@ -2047,6 +2049,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"us.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2083,6 +2086,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"eu.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2119,6 +2123,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"au.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
@ -2155,6 +2160,7 @@
|
|||
"prompt_cache_min_tokens": 1024
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-5": {
|
||||
"bedrock_converse_supports_strict_tools": false,
|
||||
"cache_creation_input_token_cost": 2.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.4e-06,
|
||||
"cache_read_input_token_cost": 2.2e-07,
|
||||
|
|
|
|||
54
tests/store_model_in_db_tests/cost_center_service.py
Normal file
54
tests/store_model_in_db_tests/cost_center_service.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Stand-in cost center validation service for the team metadata e2e tests.
|
||||
|
||||
Accepts POST /validate with {"operation": ..., "metadata": {...}} and answers
|
||||
{"ok": true} or {"ok": false, "reason": ...} based on a static allowlist.
|
||||
GET /health answers 200 for the CI wait loop.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ALLOWED_COST_CENTERS = {"CC-1001", "CC-1002"}
|
||||
|
||||
|
||||
class CostCenterHandler(BaseHTTPRequestHandler):
|
||||
def _respond(self, status: int, body: dict) -> None:
|
||||
payload = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self._respond(200, {"status": "healthy"})
|
||||
return
|
||||
self._respond(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/validate":
|
||||
self._respond(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
cost_center = (body.get("metadata") or {}).get("cost_center")
|
||||
if cost_center is None:
|
||||
self._respond(200, {"ok": False, "reason": "cost_center missing per cost center service"})
|
||||
elif cost_center not in ALLOWED_COST_CENTERS:
|
||||
self._respond(200, {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"})
|
||||
else:
|
||||
self._respond(200, {"ok": True})
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=9414)
|
||||
args = parser.parse_args()
|
||||
print(f"cost center service listening on {args.host}:{args.port}")
|
||||
ThreadingHTTPServer((args.host, args.port), CostCenterHandler).serve_forever()
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"""E2E matrix for custom team metadata validation against the DB-backed proxy.
|
||||
|
||||
The proxy (see store_model_db_config.yaml) registers
|
||||
team_metadata_validator_e2e.validate_team_metadata, which dispatches per
|
||||
request to one of three independent implementations via the
|
||||
`_e2e_validator_impl` metadata key: a static allowlist function, an
|
||||
HTTP-backed function calling the cost center service started by CI, and an
|
||||
immutability-enforcing class instance. Metadata without the dispatch key is
|
||||
accepted untouched, so the rest of this suite is unaffected.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
PROXY_BASE_URL = os.getenv("PROXY_BASE_URL", "http://localhost:4000")
|
||||
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-1234")
|
||||
HEADERS = {"Authorization": f"Bearer {MASTER_KEY}", "Content-Type": "application/json"}
|
||||
|
||||
UNAVAILABLE_MESSAGE = "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."
|
||||
|
||||
IMPLS = ["allowlist", "http", "immutable"]
|
||||
|
||||
REQUIRED_MESSAGES = {
|
||||
"allowlist": "cost_center is required in team metadata",
|
||||
"http": "cost_center missing per cost center service",
|
||||
"immutable": "cost_center is required in team metadata",
|
||||
}
|
||||
UNKNOWN_MESSAGES = {
|
||||
"allowlist": "is not recognized",
|
||||
"http": "rejected by cost center service",
|
||||
}
|
||||
|
||||
|
||||
def _meta(impl, **fields):
|
||||
return {"_e2e_validator_impl": impl, **fields}
|
||||
|
||||
|
||||
def _create_team(metadata, team_id=None):
|
||||
body = {"team_alias": f"meta-validate-{uuid.uuid4().hex[:8]}"}
|
||||
if team_id is not None:
|
||||
body["team_id"] = team_id
|
||||
if metadata is not None:
|
||||
body["metadata"] = metadata
|
||||
return httpx.post(f"{PROXY_BASE_URL}/team/new", headers=HEADERS, json=body, timeout=30)
|
||||
|
||||
|
||||
def _patch_team(team_id, body):
|
||||
return httpx.patch(f"{PROXY_BASE_URL}/team/{team_id}", headers=HEADERS, json=body, timeout=30)
|
||||
|
||||
|
||||
def _post_update(team_id, body):
|
||||
return httpx.post(f"{PROXY_BASE_URL}/team/update", headers=HEADERS, json={"team_id": team_id, **body}, timeout=30)
|
||||
|
||||
|
||||
def _team_info(team_id):
|
||||
return httpx.get(f"{PROXY_BASE_URL}/team/info", headers=HEADERS, params={"team_id": team_id}, timeout=30)
|
||||
|
||||
|
||||
def _delete_team(team_id):
|
||||
httpx.post(f"{PROXY_BASE_URL}/team/delete", headers=HEADERS, json={"team_ids": [team_id]}, timeout=30)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_with_cost_center(request):
|
||||
impl = request.param
|
||||
team_id = f"meta-validate-{impl}-{uuid.uuid4().hex[:8]}"
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"), team_id=team_id)
|
||||
assert response.status_code == 200, response.text
|
||||
yield impl, team_id
|
||||
_delete_team(team_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_with_valid_cost_center_succeeds(impl):
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"))
|
||||
assert response.status_code == 200, response.text
|
||||
team_id = response.json()["team_id"]
|
||||
try:
|
||||
assert response.json()["metadata"]["cost_center"] == "CC-1001"
|
||||
finally:
|
||||
_delete_team(team_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_without_cost_center_is_rejected(impl):
|
||||
team_id = f"meta-validate-reject-{impl}-{uuid.uuid4().hex[:8]}"
|
||||
response = _create_team(metadata=_meta(impl), team_id=team_id)
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
info = _team_info(team_id)
|
||||
assert info.status_code == 404, "rejected create must not leave a team row behind"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_with_unknown_cost_center(impl):
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-9999"))
|
||||
if impl == "immutable":
|
||||
assert response.status_code == 200, response.text
|
||||
_delete_team(response.json()["team_id"])
|
||||
return
|
||||
assert response.status_code == 400, response.text
|
||||
assert UNKNOWN_MESSAGES[impl] in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_changing_cost_center(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"cost_center": "CC-1002"}})
|
||||
if impl == "immutable":
|
||||
assert response.status_code == 400, response.text
|
||||
assert "immutable once set" in response.text
|
||||
info = _team_info(team_id).json()["team_info"]["metadata"]
|
||||
assert info["cost_center"] == "CC-1001", "blocked update must leave stored metadata intact"
|
||||
return
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["cost_center"] == "CC-1002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_unrelated_key_validates_merged_result(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"team_notes": "hello"}})
|
||||
assert response.status_code == 200, response.text
|
||||
merged = response.json()["metadata"]
|
||||
assert merged["cost_center"] == "CC-1001"
|
||||
assert merged["team_notes"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_null_deleting_cost_center_is_rejected(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"cost_center": None}})
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
info = _team_info(team_id).json()["team_info"]["metadata"]
|
||||
assert info["cost_center"] == "CC-1001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_post_update_dropping_cost_center_is_rejected(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _post_update(team_id, {"metadata": _meta(impl, team_notes="only-notes")})
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_update_without_metadata_skips_validation(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _post_update(team_id, {"tpm_limit": 55})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def test_http_service_outage_fails_closed_with_configured_message():
|
||||
response = _create_team(metadata=_meta("http_down", cost_center="CC-1001"))
|
||||
assert response.status_code == 503, response.text
|
||||
assert UNAVAILABLE_MESSAGE in response.text
|
||||
|
||||
|
||||
def test_metadata_without_dispatch_key_is_untouched():
|
||||
response = _create_team(metadata={"any_key": "any_value"})
|
||||
assert response.status_code == 200, response.text
|
||||
team_id = response.json()["team_id"]
|
||||
try:
|
||||
update = _post_update(team_id, {"metadata": {"any_key": "changed"}})
|
||||
assert update.status_code == 200, update.text
|
||||
finally:
|
||||
_delete_team(team_id)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,11 @@ Bedrock Converse routes Claude Opus 4.7/4.8 and Claude Sonnet 4 through an
|
|||
Anthropic-compatible validator that rejects ``toolSpec.strict`` even though
|
||||
Anthropic's native API accepts ``strict`` as a top-level tool field. See
|
||||
BerriAI/litellm#31582.
|
||||
|
||||
That per-model gate only covers models whose cost-map entry carries the flag, so a
|
||||
``strict: false`` that litellm itself synthesized still broke unflagged models. Since
|
||||
``strict: false`` is the Chat Completions default, it is now dropped for every model
|
||||
rather than forwarded as a no-op the provider can reject. See BerriAI/litellm#33193.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -31,6 +36,16 @@ _STRICT_TOOL = [
|
|||
}
|
||||
]
|
||||
|
||||
_NON_STRICT_TOOL = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
**_STRICT_TOOL[0]["function"],
|
||||
"strict": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
|
|
@ -48,12 +63,17 @@ _STRICT_TOOL = [
|
|||
"bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"bedrock/apac.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
# Sonnet 5 rejects it too, verified live against Bedrock in us-east-1
|
||||
"anthropic.claude-sonnet-5",
|
||||
"bedrock/us.anthropic.claude-sonnet-5",
|
||||
"bedrock/eu.anthropic.claude-sonnet-5",
|
||||
"bedrock/jp.anthropic.claude-sonnet-5",
|
||||
],
|
||||
)
|
||||
def test_bedrock_tools_pt_strict_dropped_for_strict_unsupported_models(
|
||||
model_id: str,
|
||||
) -> None:
|
||||
"""Opus 4.7/4.8 and Sonnet 4 reject toolSpec.strict and additionalProperties."""
|
||||
"""Opus 4.7/4.8, Sonnet 4 and Sonnet 5 reject toolSpec.strict and additionalProperties."""
|
||||
result = _bedrock_tools_pt(_STRICT_TOOL, model=model_id)
|
||||
tool_spec = result[0]["toolSpec"]
|
||||
assert (
|
||||
|
|
@ -81,6 +101,55 @@ def test_bedrock_tools_pt_strict_kept_for_other_anthropic(model_id: str) -> None
|
|||
), f"strict missing for {model_id}: {result[0]['toolSpec']}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"bedrock/us.anthropic.claude-sonnet-5",
|
||||
"bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"bedrock/us.anthropic.claude-sonnet-4-6",
|
||||
"bedrock/us.anthropic.claude-opus-4-8",
|
||||
],
|
||||
)
|
||||
def test_bedrock_tools_pt_falsy_strict_always_dropped(model_id: str) -> None:
|
||||
"""``strict: false`` is the Chat Completions default, so forwarding it says nothing
|
||||
the provider does not already assume. Bedrock Converse rejects the key's presence
|
||||
for a growing set of Claude models, so it is dropped for every model, including the
|
||||
ones whose cost-map entry still allows ``strict: true`` through."""
|
||||
result = _bedrock_tools_pt(_NON_STRICT_TOOL, model=model_id)
|
||||
tool_spec = result[0]["toolSpec"]
|
||||
assert (
|
||||
"strict" not in tool_spec
|
||||
), f"no-op strict: false leaked into toolSpec for {model_id}: {tool_spec}"
|
||||
|
||||
|
||||
def test_responses_bridge_function_tool_does_not_reach_bedrock_with_strict() -> None:
|
||||
"""The Responses-to-Chat-Completions bridge stamps ``strict: false`` onto every
|
||||
function tool even when the caller never sent one, which is how Codex CLI requests
|
||||
acquired the key. Assert the fabricated value does not survive to toolSpec."""
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
}
|
||||
chat_tools, _ = (
|
||||
LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
|
||||
[responses_tool]
|
||||
)
|
||||
)
|
||||
result = _bedrock_tools_pt(chat_tools, model="bedrock/us.anthropic.claude-sonnet-5")
|
||||
assert "strict" not in result[0]["toolSpec"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
|
|
@ -129,6 +198,15 @@ def test_bedrock_converse_supports_strict_tools_helper() -> None:
|
|||
)
|
||||
is False
|
||||
)
|
||||
assert bedrock_converse_supports_strict_tools("anthropic.claude-sonnet-5") is False
|
||||
assert (
|
||||
bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-sonnet-5")
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
bedrock_converse_supports_strict_tools("bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -143,6 +221,12 @@ def test_bedrock_converse_supports_strict_tools_helper() -> None:
|
|||
"us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"eu.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"apac.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-5",
|
||||
"global.anthropic.claude-sonnet-5",
|
||||
"us.anthropic.claude-sonnet-5",
|
||||
"eu.anthropic.claude-sonnet-5",
|
||||
"au.anthropic.claude-sonnet-5",
|
||||
"jp.anthropic.claude-sonnet-5",
|
||||
],
|
||||
)
|
||||
def test_strict_tools_flag_set_in_model_cost_map(cost_map_key: str) -> None:
|
||||
|
|
|
|||
|
|
@ -3477,6 +3477,140 @@ class TestStrategyRouterWriteValidation:
|
|||
is None
|
||||
)
|
||||
|
||||
def test_create_with_empty_keyword_rule_rejected(self):
|
||||
"""LIT-5133: the router refuses to build a rule with no keyword, but only at load time.
|
||||
Without this the row is written, dropped on reload, and the caller gets a 500 plus a
|
||||
deployment that can never come back."""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
|
||||
violation = _strategy_router_write_violation(
|
||||
incoming_params=LiteLLM_Params(
|
||||
model="auto_router/complexity_router",
|
||||
complexity_router_default_model="gpt-4o-mini",
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": [], "tier": "COMPLEX"}],
|
||||
},
|
||||
),
|
||||
existing_params=None,
|
||||
)
|
||||
assert violation is not None
|
||||
assert "complexity_router_config is invalid" in violation
|
||||
assert "keyword_tier_rules" in violation
|
||||
|
||||
def test_patch_that_only_renames_does_not_judge_the_stored_config(self):
|
||||
"""Only a config the write actually carries is judged. A row stored before this validation
|
||||
existed is already unloadable, and holding its rename hostage would break the restore path
|
||||
this function documents; the repair is a write that supplies a good config."""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
from litellm.types.router import updateLiteLLMParams
|
||||
|
||||
stored_bad = LiteLLM_Params(
|
||||
model="auto_router/complexity_router",
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": [" "], "tier": "COMPLEX"}],
|
||||
},
|
||||
)
|
||||
assert (
|
||||
_strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(model="auto_router/complexity_router"),
|
||||
existing_params=stored_bad,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_incoming_config_replaces_stored_rather_than_merging(self):
|
||||
"""The field is written wholesale, so a good incoming config must clear a bad stored one."""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
from litellm.types.router import updateLiteLLMParams
|
||||
|
||||
stored_bad = LiteLLM_Params(
|
||||
model="auto_router/complexity_router",
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": [], "tier": "COMPLEX"}],
|
||||
},
|
||||
)
|
||||
assert (
|
||||
_strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(
|
||||
model="auto_router/complexity_router",
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": ["invoice"], "tier": "COMPLEX"}],
|
||||
},
|
||||
),
|
||||
existing_params=stored_bad,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_config_only_patch_is_judged_without_a_model_in_the_payload(self):
|
||||
"""A patch may carry a config and no model, which is what a caller updating only the
|
||||
routing rules sends. That path skipped the naming contract, so it has to be judged on the
|
||||
config alone against the stored model, or it overwrites a working router with one that
|
||||
cannot load and takes it out of service."""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
from litellm.types.router import updateLiteLLMParams
|
||||
|
||||
violation = _strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": [], "tier": "COMPLEX"}],
|
||||
}
|
||||
),
|
||||
existing_params=self._stored_complexity_params(),
|
||||
)
|
||||
assert violation is not None
|
||||
assert "complexity_router_config is invalid" in violation
|
||||
|
||||
def test_config_only_patch_with_a_loadable_config_is_allowed(self):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
from litellm.types.router import updateLiteLLMParams
|
||||
|
||||
assert (
|
||||
_strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(
|
||||
complexity_router_config={
|
||||
"tiers": {"SIMPLE": ["gpt-4o-mini"]},
|
||||
"keyword_tier_rules": [{"keywords": ["invoice"], "tier": "COMPLEX"}],
|
||||
}
|
||||
),
|
||||
existing_params=self._stored_complexity_params(),
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_config_only_patch_is_judged_on_the_config_alone(self):
|
||||
"""The stored model is encrypted at rest, so a patch that names no model cannot be
|
||||
classified from the row. An unloadable config is rejected on its own merits instead,
|
||||
which is also the only reading that closes the path regardless of what is stored."""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
)
|
||||
from litellm.types.router import updateLiteLLMParams
|
||||
|
||||
violation = _strategy_router_write_violation(
|
||||
incoming_params=updateLiteLLMParams(
|
||||
complexity_router_config={"keyword_tier_rules": [{"keywords": [], "tier": "COMPLEX"}]}
|
||||
),
|
||||
existing_params=LiteLLM_Params(model="c2VjcmV0-encrypted-at-rest"),
|
||||
)
|
||||
assert violation is not None
|
||||
assert "complexity_router_config is invalid" in violation
|
||||
|
||||
def test_create_semantic_router_missing_embedding_rejected(self):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_strategy_router_write_violation,
|
||||
|
|
|
|||
|
|
@ -9863,11 +9863,14 @@ async def _drive_team_write(
|
|||
raw_body=None,
|
||||
user=None,
|
||||
find_returns_none=False,
|
||||
mock_sink=None,
|
||||
):
|
||||
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
|
||||
|
||||
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
|
||||
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
|
||||
Pass a dict as ``mock_sink`` to receive the update mock even when the
|
||||
endpoint raises.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
from unittest.mock import patch as _patch
|
||||
|
|
@ -9913,6 +9916,8 @@ async def _drive_team_write(
|
|||
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
|
||||
)
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
if mock_sink is not None:
|
||||
mock_sink["update"] = pc.db.litellm_teamtable.update
|
||||
|
||||
req = Mock(spec=Request)
|
||||
if kind == "post":
|
||||
|
|
@ -10175,6 +10180,249 @@ async def test_patch_returns_full_team_object_not_wrapper():
|
|||
assert result.team_id == _PATCH_TEAM_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# custom_team_metadata_validate wiring: the configured validator must gate
|
||||
# every team write path (POST /team/new, POST /team/update, PATCH /team/{id})
|
||||
# and must see the metadata that will actually be written.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _configured_team_metadata_validator(validator):
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
|
||||
try:
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
|
||||
|
||||
def _recording_validator(recorded, valid=True, error_message=None):
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=valid, error_message=error_message)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_validator_sees_replacement_on_post_and_merged_on_patch():
|
||||
"""POST hands the validator the wholesale replacement; PATCH hands it the
|
||||
RFC 7386 merged result including preserved keys."""
|
||||
existing = {"cost_center": "OLD", "keep": 1}
|
||||
body = {"metadata": {"cost_center": "NEW"}}
|
||||
|
||||
recorded_post = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded_post)):
|
||||
await _drive_team_write("post", existing_metadata=existing, payload=body)
|
||||
|
||||
recorded_patch = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded_patch)):
|
||||
await _drive_team_write("patch", existing_metadata=existing, payload=body)
|
||||
|
||||
assert len(recorded_post) == 1
|
||||
assert recorded_post[0].operation == "update"
|
||||
assert recorded_post[0].metadata == {"cost_center": "NEW"}
|
||||
assert recorded_post[0].existing_metadata == existing
|
||||
|
||||
assert len(recorded_patch) == 1
|
||||
assert recorded_patch[0].operation == "update"
|
||||
assert recorded_patch[0].metadata == {"cost_center": "NEW", "keep": 1}
|
||||
assert recorded_patch[0].existing_metadata == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_null_delete_removes_key_from_validated_metadata():
|
||||
"""Deleting a key via PATCH null must be visible to the validator as the
|
||||
key's absence in the resulting metadata, so a required key cannot be
|
||||
silently dropped."""
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"cost_center": "OLD", "keep": 1},
|
||||
payload={"metadata": {"cost_center": None}},
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].metadata == {"keep": 1}
|
||||
assert recorded[0].existing_metadata == {"cost_center": "OLD", "keep": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_without_metadata_skips_validator(kind):
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(kind, existing_metadata={"k": "v"}, payload={"tpm_limit": 5})
|
||||
|
||||
assert recorded == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_validator_rejection_blocks_db_write(kind):
|
||||
recorded = []
|
||||
validator = _recording_validator(recorded, valid=False, error_message="cost center rejected, contact FinOps")
|
||||
sink = {}
|
||||
|
||||
with _configured_team_metadata_validator(validator):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await _drive_team_write(
|
||||
kind,
|
||||
existing_metadata={"cost_center": "OLD"},
|
||||
payload={"metadata": {"cost_center": "BAD"}},
|
||||
mock_sink=sink,
|
||||
)
|
||||
|
||||
assert str(exc_info.value.code) == "400"
|
||||
assert "cost center rejected, contact FinOps" in str(exc_info.value.message)
|
||||
assert len(recorded) == 1
|
||||
sink["update"].assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_validator_runs_without_metadata_and_rejection_blocks_create():
|
||||
"""Create always validates, even when the request carries no metadata, so a
|
||||
required-key policy can reject a team created without one."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
recorded = []
|
||||
validator = _recording_validator(recorded, valid=False, error_message="cost_center is required")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch("litellm.proxy.proxy_server._license_check") as mock_license,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
_configured_team_metadata_validator(validator),
|
||||
):
|
||||
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
||||
mock_license.is_team_count_over_limit.return_value = False
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await new_team(
|
||||
data=NewTeamRequest(team_alias="no-metadata-team"),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
|
||||
)
|
||||
|
||||
assert str(exc_info.value.code) == "400"
|
||||
assert "cost_center is required" in str(exc_info.value.message)
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].operation == "create"
|
||||
assert recorded[0].metadata == {}
|
||||
assert recorded[0].existing_metadata is None
|
||||
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_validator_accept_proceeds_to_create(mock_db_client, mock_admin_auth):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
mock_db_client.jsonify_team_object = lambda db_data: db_data
|
||||
mock_db_client.get_data = AsyncMock(return_value=None)
|
||||
mock_db_client.update_data = AsyncMock(return_value=MagicMock())
|
||||
mock_db_client.db = MagicMock()
|
||||
|
||||
team_create_result = MagicMock(team_id="team-accept-1")
|
||||
team_create_result.model_dump.return_value = {"team_id": "team-accept-1"}
|
||||
mock_db_client.db.litellm_teamtable = MagicMock()
|
||||
mock_db_client.db.litellm_teamtable.create = AsyncMock(return_value=team_create_result)
|
||||
mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=team_create_result)
|
||||
mock_db_client.db.litellm_usertable = MagicMock()
|
||||
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
|
||||
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await new_team(
|
||||
data=NewTeamRequest(team_alias="accepted-team", metadata={"cost_center": "CC-1001"}),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=mock_admin_auth,
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].operation == "create"
|
||||
assert recorded[0].metadata == {"cost_center": "CC-1001"}
|
||||
mock_db_client.db.litellm_teamtable.create.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_rejection_precedes_model_alias_write():
|
||||
"""A rejected create must not leave an orphaned LiteLLM_ModelTable row:
|
||||
validation runs before the model_aliases insert."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
validator = _recording_validator([], valid=False, error_message="cost_center is required")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch("litellm.proxy.proxy_server._license_check") as mock_license,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
_configured_team_metadata_validator(validator),
|
||||
):
|
||||
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
||||
mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
|
||||
mock_license.is_team_count_over_limit.return_value = False
|
||||
|
||||
with pytest.raises(ProxyException):
|
||||
await new_team(
|
||||
data=NewTeamRequest(
|
||||
team_alias="alias-orphan-check",
|
||||
model_aliases={"alias-model": "gpt-4o"},
|
||||
),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
|
||||
)
|
||||
|
||||
mock_prisma.db.litellm_modeltable.create.assert_not_awaited()
|
||||
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_existing_metadata_excludes_system_managed_keys(kind):
|
||||
"""The validator's existing_metadata must be symmetric with metadata:
|
||||
server-owned keys (team_member_budget_id) are stripped from both, so a
|
||||
key-preservation validator never sees them 'disappear'."""
|
||||
recorded = []
|
||||
stored = {"cost_center": "CC-1001", "team_member_budget_id": "budget-abc"}
|
||||
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(
|
||||
kind,
|
||||
existing_metadata=dict(stored),
|
||||
payload={"metadata": {"notes": "x"}},
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].existing_metadata == {"cost_center": "CC-1001"}
|
||||
assert "team_member_budget_id" not in recorded[0].metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH body is validated through PatchTeamRequest before it is handed to
|
||||
# update_team. The write below must stay byte-identical to what the untyped
|
||||
|
|
@ -10340,6 +10588,76 @@ async def test_list_available_teams_filters_joined_and_validates_rows(monkeypatc
|
|||
assert find_many_kwargs["where"] == {"team_id": {"in": ["team-open"]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_metadata_schema_returns_configured_fields():
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(
|
||||
parse_team_metadata_schema(
|
||||
[
|
||||
{"key": "cost_center", "label": "Cost Center"},
|
||||
{"key": "app_name", "label": "Application Name"},
|
||||
]
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await get_team_metadata_schema()
|
||||
finally:
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
|
||||
assert [field.key for field in result.fields] == ["cost_center", "app_name"]
|
||||
assert result.fields[0].label == "Cost Center"
|
||||
assert result.fields[1].label == "Application Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_metadata_schema_empty_when_unconfigured():
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
)
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
result = await get_team_metadata_schema()
|
||||
|
||||
assert result.fields == ()
|
||||
|
||||
|
||||
def test_get_team_metadata_schema_route_requires_auth():
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.master_key", "sk-1234"):
|
||||
response = client.get("/team/metadata_schema")
|
||||
assert response.status_code == 401
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema([{"key": "cost_center", "label": "Cost Center"}]))
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"
|
||||
)
|
||||
try:
|
||||
authed = client.get("/team/metadata_schema")
|
||||
finally:
|
||||
app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
|
||||
assert authed.status_code == 200
|
||||
assert authed.json() == {"fields": [{"key": "cost_center", "label": "Cost Center"}]}
|
||||
|
||||
|
||||
def test_team_metadata_schema_route_is_readable_by_non_admins():
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert "/team/metadata_schema" in LiteLLMRoutes.info_routes.value
|
||||
assert "/team/metadata_schema" in LiteLLMRoutes.management_routes.value
|
||||
|
||||
|
||||
def _provisioning_caller(role: LitellmUserRoles) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_id="caller-1", user_role=role)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"""Three independent `custom_team_metadata_validate` implementations.
|
||||
|
||||
Used by the matrix tests in `test_team_metadata_validation.py` and loadable
|
||||
directly from a proxy config via `get_instance_fn` for live verification:
|
||||
|
||||
- `validate_allowlist`: plain async function; requires `cost_center` and
|
||||
checks it against a static allowlist.
|
||||
- `validate_via_http`: async function that POSTs the metadata to an external
|
||||
validation service (`TEAM_METADATA_VALIDATION_SERVICE_URL`); any transport
|
||||
error or non-2xx response raises, exercising the fail-closed path.
|
||||
- `IMMUTABLE_COST_CENTER_VALIDATOR`: class instance with an async
|
||||
`__call__`; requires `cost_center` and forbids changing it once set, using
|
||||
`existing_metadata` and `operation`.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
|
||||
DEFAULT_SERVICE_URL = "http://localhost:9414/validate"
|
||||
|
||||
|
||||
async def validate_allowlist(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="cost_center is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in ALLOWED_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
async def validate_via_http(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", DEFAULT_SERVICE_URL)
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.post(
|
||||
service_url,
|
||||
json={"operation": payload.operation, "metadata": payload.metadata},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("ok") is True:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=body.get("reason", "Rejected by the cost center service."),
|
||||
)
|
||||
|
||||
|
||||
class ImmutableCostCenterValidator:
|
||||
def __init__(self, immutable_key: str = "cost_center") -> None:
|
||||
self.immutable_key = immutable_key
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
current = payload.metadata.get(self.immutable_key)
|
||||
if current is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if payload.operation == "update" and payload.existing_metadata is not None:
|
||||
prior = payload.existing_metadata.get(self.immutable_key)
|
||||
if prior is not None and prior != current:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=(
|
||||
f"{self.immutable_key} is immutable once set "
|
||||
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
|
||||
),
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
IMMUTABLE_COST_CENTER_VALIDATOR = ImmutableCostCenterValidator()
|
||||
|
|
@ -0,0 +1,704 @@
|
|||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE,
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS,
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE,
|
||||
TeamMetadataRequester,
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
TeamMetadataValidatorRegistry,
|
||||
_read_timeout_seconds,
|
||||
_read_unavailable_message,
|
||||
run_team_metadata_validation,
|
||||
validate_team_metadata_if_configured,
|
||||
)
|
||||
|
||||
|
||||
def _registry_with(validator):
|
||||
registry = TeamMetadataValidatorRegistry()
|
||||
registry.set(validator)
|
||||
return registry
|
||||
|
||||
UNAVAILABLE_MESSAGE = "validation system down, contact ops"
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
values = {
|
||||
"operation": "create",
|
||||
"metadata": {"cost_center": "CC-1001"},
|
||||
"existing_metadata": None,
|
||||
"team_id": "team-1",
|
||||
"team_alias": "alias-1",
|
||||
"requester": TeamMetadataRequester(user_id="u1"),
|
||||
}
|
||||
values.update(overrides)
|
||||
return TeamMetadataValidationPayload(**values)
|
||||
|
||||
|
||||
async def _run(validator, payload=None, premium_user=True, timeout_seconds=1.0):
|
||||
await run_team_metadata_validation(
|
||||
validator=validator,
|
||||
payload=payload or _payload(),
|
||||
premium_user=premium_user,
|
||||
timeout_seconds=timeout_seconds,
|
||||
unavailable_message=UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_result_passes():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
await _run(validator)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_raises_400_with_validator_message():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=False, error_message="cost center rejected, contact FinOps")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": "cost center rejected, contact FinOps"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_without_message_uses_default():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_shaped_return_is_accepted():
|
||||
async def validator(payload):
|
||||
return {"valid": False, "error_message": "rejected via dict"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": "rejected via dict"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_exception_fails_closed_with_generic_message():
|
||||
async def validator(payload):
|
||||
raise RuntimeError("internal validation service is down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_timeout_fails_closed():
|
||||
async def validator(payload):
|
||||
await asyncio.sleep(1.0)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator, timeout_seconds=0.01)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_return_shape_fails_closed():
|
||||
async def validator(payload):
|
||||
return "not-a-validation-result"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_premium_user_is_rejected():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator, premium_user=False)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert CommonProxyErrors.not_premium_user.value in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_coroutine_validator_is_rejected():
|
||||
def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, expected",
|
||||
[
|
||||
({}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": 2}, 2.0),
|
||||
({"team_metadata_validation_timeout": 0.5}, 0.5),
|
||||
({"team_metadata_validation_timeout": True}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": -1}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": 0}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": "3"}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
],
|
||||
)
|
||||
def test_read_timeout_seconds(general_settings, expected):
|
||||
assert _read_timeout_seconds(general_settings) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, expected",
|
||||
[
|
||||
({}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
(
|
||||
{"team_metadata_validation_error_message": "call the help desk"},
|
||||
"call the help desk",
|
||||
),
|
||||
({"team_metadata_validation_error_message": " "}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
({"team_metadata_validation_error_message": None}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
],
|
||||
)
|
||||
def test_read_unavailable_message(general_settings, expected):
|
||||
assert _read_unavailable_message(general_settings) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_is_noop_when_unconfigured():
|
||||
calls = []
|
||||
|
||||
async def validator(payload):
|
||||
calls.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata={"cost_center": "CC-1001"},
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias="alias-1",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=TeamMetadataValidatorRegistry(),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_builds_payload_and_reads_settings():
|
||||
recorded = []
|
||||
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"team_metadata_validation_timeout": 3, "team_metadata_validation_error_message": "ops msg"},
|
||||
),
|
||||
):
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="update",
|
||||
metadata={"cost_center": "CC-2001"},
|
||||
existing_metadata={"cost_center": "CC-1001", "keep": 1},
|
||||
team_id="team-9",
|
||||
team_alias="alias-9",
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="user-9",
|
||||
user_email="user-9@example.com",
|
||||
),
|
||||
registry=_registry_with(validator),
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
payload = recorded[0]
|
||||
assert payload.operation == "update"
|
||||
assert payload.metadata == {"cost_center": "CC-2001"}
|
||||
assert payload.existing_metadata == {"cost_center": "CC-1001", "keep": 1}
|
||||
assert payload.team_id == "team-9"
|
||||
assert payload.team_alias == "alias-9"
|
||||
assert payload.requester.user_id == "user-9"
|
||||
assert payload.requester.user_email == "user-9@example.com"
|
||||
assert payload.requester.user_role == LitellmUserRoles.INTERNAL_USER.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_normalizes_non_dict_metadata_to_empty_dict():
|
||||
recorded = []
|
||||
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata=None,
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias=None,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=_registry_with(validator),
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].metadata == {}
|
||||
assert recorded[0].existing_metadata is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validator implementation matrix: three independent implementations
|
||||
# (allowlist function, HTTP-service-backed function, immutability class
|
||||
# instance) driven through the real team write endpoints.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import json as _json
|
||||
import socket
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import team_metadata_validator_impls as impls
|
||||
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
class _CostCenterServiceHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = _json.loads(self.rfile.read(length) or b"{}")
|
||||
cost_center = (body.get("metadata") or {}).get("cost_center")
|
||||
if cost_center is None:
|
||||
resp = {"ok": False, "reason": "cost_center missing per cost center service"}
|
||||
elif cost_center not in impls.ALLOWED_COST_CENTERS:
|
||||
resp = {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"}
|
||||
else:
|
||||
resp = {"ok": True}
|
||||
payload = _json.dumps(resp).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cost_center_service_url():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _CostCenterServiceHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/validate"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _closed_port_url():
|
||||
probe = socket.socket()
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
return f"http://127.0.0.1:{port}/validate"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _configured(validator):
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
|
||||
try:
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
|
||||
|
||||
async def _drive_create(metadata, mock_sink=None):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as pc,
|
||||
patch("litellm.proxy.proxy_server._license_check") as lic,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
):
|
||||
team_row = MagicMock(team_id="matrix-team-1")
|
||||
team_row.model_dump.return_value = {"team_id": "matrix-team-1"}
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
pc.get_data = AsyncMock(return_value=None)
|
||||
pc.update_data = AsyncMock(return_value=MagicMock())
|
||||
pc.db.litellm_teamtable.create = AsyncMock(return_value=team_row)
|
||||
pc.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
pc.db.litellm_teamtable.update = AsyncMock(return_value=team_row)
|
||||
pc.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
|
||||
pc.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
|
||||
lic.is_team_count_over_limit.return_value = False
|
||||
if mock_sink is not None:
|
||||
mock_sink["team_create"] = pc.db.litellm_teamtable.create
|
||||
mock_sink["model_create"] = pc.db.litellm_modeltable.create
|
||||
|
||||
request_kwargs = {"team_alias": "matrix-team"}
|
||||
if metadata is not None:
|
||||
request_kwargs["metadata"] = metadata
|
||||
return await new_team(
|
||||
data=NewTeamRequest(**request_kwargs),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
|
||||
async def _drive_update(kind, existing_metadata, payload):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, PatchTeamRequest, UpdateTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import patch_team, update_team
|
||||
|
||||
team_id = "matrix-team-upd"
|
||||
existing = LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
team_alias="matrix",
|
||||
metadata=existing_metadata,
|
||||
organization_id=None,
|
||||
)
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as pc,
|
||||
patch("litellm.proxy.proxy_server.llm_router", None),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
pc.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing)
|
||||
pc.db.litellm_teamtable.update = AsyncMock(
|
||||
return_value=LiteLLM_TeamTable(team_id=team_id, team_alias="matrix")
|
||||
)
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
|
||||
req = Mock(spec=Request)
|
||||
if kind == "post":
|
||||
return await update_team(
|
||||
data=UpdateTeamRequest(team_id=team_id, **payload),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
return await patch_team(
|
||||
team_id=team_id,
|
||||
data=PatchTeamRequest.model_validate(dict(payload)),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
|
||||
OK = ("ok", None)
|
||||
|
||||
_MATRIX_IMPLS = {
|
||||
"allowlist": lambda: impls.validate_allowlist,
|
||||
"http": lambda: impls.validate_via_http,
|
||||
"immutable_class": lambda: impls.IMMUTABLE_COST_CENTER_VALIDATOR,
|
||||
}
|
||||
|
||||
# (scenario, kind, existing_metadata, request payload, {impl: expected})
|
||||
# expected is ("ok", None) or ("reject", <message substring>)
|
||||
_MATRIX_SCENARIOS = [
|
||||
(
|
||||
"create-valid-cost-center",
|
||||
"create",
|
||||
None,
|
||||
{"cost_center": "CC-1001"},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
(
|
||||
"create-missing-cost-center",
|
||||
"create",
|
||||
None,
|
||||
None,
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"create-unknown-cost-center",
|
||||
"create",
|
||||
None,
|
||||
{"cost_center": "CC-9999"},
|
||||
{
|
||||
"allowlist": ("reject", "is not recognized"),
|
||||
"http": ("reject", "rejected by cost center service"),
|
||||
"immutable_class": OK,
|
||||
},
|
||||
),
|
||||
(
|
||||
"patch-change-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"cost_center": "CC-1002"}},
|
||||
{
|
||||
"allowlist": OK,
|
||||
"http": OK,
|
||||
"immutable_class": ("reject", "immutable once set"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"patch-unrelated-key-preserves-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"notes": "hello"}},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
(
|
||||
"patch-null-deletes-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"cost_center": None}},
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"post-replace-drops-cost-center",
|
||||
"post",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"notes": "only-notes"}},
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"update-without-metadata-skips-validation",
|
||||
"post",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"tpm_limit": 5},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("impl_name", sorted(_MATRIX_IMPLS))
|
||||
@pytest.mark.parametrize(
|
||||
"scenario, kind, existing_metadata, request_payload, expectations",
|
||||
_MATRIX_SCENARIOS,
|
||||
ids=[row[0] for row in _MATRIX_SCENARIOS],
|
||||
)
|
||||
async def test_validator_implementation_matrix(
|
||||
monkeypatch,
|
||||
cost_center_service_url,
|
||||
impl_name,
|
||||
scenario,
|
||||
kind,
|
||||
existing_metadata,
|
||||
request_payload,
|
||||
expectations,
|
||||
):
|
||||
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", cost_center_service_url)
|
||||
validator = _MATRIX_IMPLS[impl_name]()
|
||||
outcome, message_part = expectations[impl_name]
|
||||
|
||||
async def drive():
|
||||
if kind == "create":
|
||||
return await _drive_create(metadata=request_payload)
|
||||
return await _drive_update(kind, existing_metadata, request_payload)
|
||||
|
||||
with _configured(validator):
|
||||
if outcome == "ok":
|
||||
await drive()
|
||||
else:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await drive()
|
||||
assert str(exc_info.value.code) == "400", f"{scenario} x {impl_name}"
|
||||
assert message_part in str(exc_info.value.message), f"{scenario} x {impl_name}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"kind, existing_metadata, request_payload",
|
||||
[
|
||||
("create", None, {"cost_center": "CC-1001"}),
|
||||
("patch", {"cost_center": "CC-1001"}, {"metadata": {"notes": "x"}}),
|
||||
],
|
||||
)
|
||||
async def test_http_validator_service_outage_fails_closed(monkeypatch, kind, existing_metadata, request_payload):
|
||||
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", _closed_port_url())
|
||||
|
||||
with _configured(impls.validate_via_http):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
if kind == "create":
|
||||
await _drive_create(metadata=request_payload)
|
||||
else:
|
||||
await _drive_update(kind, existing_metadata, request_payload)
|
||||
|
||||
assert str(exc_info.value.code) == "503"
|
||||
assert DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE in str(exc_info.value.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_class_instance_with_async_call_is_accepted():
|
||||
await _run(impls.ImmutableCostCenterValidator(), payload=_payload(metadata={"cost_center": "CC-1"}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_class_instance_with_sync_call_is_rejected():
|
||||
class SyncValidator:
|
||||
def __call__(self, payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(SyncValidator())
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataSchemaRegistry,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_schema_none_returns_empty():
|
||||
assert parse_team_metadata_schema(None) == ()
|
||||
|
||||
|
||||
def test_parse_schema_round_trips_fields_in_order():
|
||||
raw = [
|
||||
{"key": "cost_center", "label": "Cost Center"},
|
||||
{"key": "app_name"},
|
||||
]
|
||||
|
||||
fields = parse_team_metadata_schema(raw)
|
||||
|
||||
assert [field.key for field in fields] == ["cost_center", "app_name"]
|
||||
assert fields[0].label == "Cost Center"
|
||||
assert fields[1].label is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"cost_center",
|
||||
{"key": "cost_center"},
|
||||
[{"label": "missing key"}],
|
||||
[{"key": ""}],
|
||||
[{"key": "cost_center", "required": True}],
|
||||
[{"key": "cost_center", "description": "Cost center code"}],
|
||||
[{"key": "cost_center", "allowed_values": ["CC-1001"]}],
|
||||
],
|
||||
)
|
||||
def test_parse_schema_malformed_raises(raw):
|
||||
with pytest.raises(Exception):
|
||||
parse_team_metadata_schema(raw)
|
||||
|
||||
|
||||
def test_parse_schema_duplicate_keys_raise():
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
parse_team_metadata_schema([{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}])
|
||||
|
||||
|
||||
def test_schema_registry_defaults_empty_and_round_trips():
|
||||
registry = TeamMetadataSchemaRegistry()
|
||||
assert registry.get() == ()
|
||||
|
||||
fields = parse_team_metadata_schema([{"key": "cost_center"}])
|
||||
registry.set(fields)
|
||||
assert registry.get() == fields
|
||||
|
||||
registry.set(())
|
||||
assert registry.get() == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_callable_validator_is_rejected_with_clean_500():
|
||||
class NotCallable:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(NotCallable())
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
|
||||
|
||||
|
||||
def test_parse_schema_duplicate_error_lists_offending_keys():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_team_metadata_schema(
|
||||
[{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}, {"key": "app_name"}]
|
||||
)
|
||||
assert str(exc_info.value) == "team_metadata_schema contains duplicate keys: app_name, cost_center"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_applies_configured_timeout_to_slow_validator():
|
||||
async def slow_validator(payload):
|
||||
await asyncio.sleep(0.2)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
registry = TeamMetadataValidatorRegistry()
|
||||
registry.set(slow_validator)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"team_metadata_validation_timeout": 0.01},
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata={"cost_center": "CC-1001"},
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias="alias-1",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
|
|
@ -2,6 +2,7 @@ import pytest
|
|||
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
classify_strategy_router_model,
|
||||
validate_complexity_router_config_write,
|
||||
validate_strategy_router_model_write,
|
||||
)
|
||||
|
||||
|
|
@ -75,3 +76,74 @@ def test_validate_rejects_incoherent_writes(model, present_fields, expected_frag
|
|||
)
|
||||
def test_validate_accepts_coherent_writes(model, present_fields):
|
||||
assert validate_strategy_router_model_write(model=model, present_fields=present_fields) is None
|
||||
|
||||
|
||||
VALID_TIERS = {
|
||||
"SIMPLE": ["gpt-4o-mini"],
|
||||
"MEDIUM": ["gpt-4o-mini"],
|
||||
"COMPLEX": ["gpt-4o"],
|
||||
"REASONING": ["gpt-4o"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keyword_tier_rules,expected_fragment",
|
||||
[
|
||||
([{"keywords": [], "tier": "COMPLEX"}], "at least 1 item"),
|
||||
([{"keywords": [" "], "tier": "COMPLEX"}], "non-empty keyword"),
|
||||
(
|
||||
[{"keywords": ["invoice"], "tier": "MEDIUM"}, {"keywords": [], "tier": "COMPLEX"}],
|
||||
"at least 1 item",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_validate_rejects_unloadable_complexity_config(keyword_tier_rules, expected_fragment):
|
||||
"""A rule with no keyword makes ComplexityRouterConfig unbuildable, so the row must never be
|
||||
written: without this the deployment is persisted, dropped at load, and the caller gets a 500."""
|
||||
violation = validate_complexity_router_config_write(
|
||||
complexity_router_config={
|
||||
"tiers": VALID_TIERS,
|
||||
"classifier_type": "heuristic",
|
||||
"keyword_tier_rules": keyword_tier_rules,
|
||||
}
|
||||
)
|
||||
assert violation is not None
|
||||
assert "complexity_router_config is invalid" in violation
|
||||
assert expected_fragment in violation
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"complexity_router_config",
|
||||
[
|
||||
{"tiers": VALID_TIERS, "classifier_type": "heuristic"},
|
||||
{
|
||||
"tiers": VALID_TIERS,
|
||||
"classifier_type": "heuristic",
|
||||
"keyword_tier_rules": [{"keywords": ["invoice", "refund"], "tier": "MEDIUM"}],
|
||||
},
|
||||
# extra="allow" on the model, so an unrecognised key is not this gate's business
|
||||
{"tiers": VALID_TIERS, "classifier_type": "heuristic", "some_future_key": "value"},
|
||||
],
|
||||
)
|
||||
def test_validate_accepts_loadable_complexity_config(complexity_router_config):
|
||||
assert validate_complexity_router_config_write(complexity_router_config=complexity_router_config) is None
|
||||
|
||||
|
||||
def test_naming_check_ignores_the_config_entirely():
|
||||
"""The naming contract and the config's contents are separate questions with separate owners;
|
||||
a write may carry a config without naming a model, so neither can stand in for the other."""
|
||||
violation = validate_strategy_router_model_write(
|
||||
model="auto_router/complexity_router", present_fields=frozenset()
|
||||
)
|
||||
assert violation is not None
|
||||
assert "requires" in violation
|
||||
|
||||
|
||||
def test_config_check_ignores_the_model_entirely():
|
||||
assert validate_complexity_router_config_write(complexity_router_config=None) is None
|
||||
assert (
|
||||
validate_complexity_router_config_write(
|
||||
complexity_router_config={"tiers": VALID_TIERS, "keyword_tier_rules": [{"keywords": [], "tier": "COMPLEX"}]}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
|
|
|||
148
tests/test_litellm/test_circleci_rust_toolchain.py
Normal file
148
tests/test_litellm/test_circleci_rust_toolchain.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Static guardrails for how CircleCI provisions Rust.
|
||||
|
||||
The root package builds `litellm-rust` through maturin, so any job that runs
|
||||
`uv sync` or `uv build` compiles the bridge. The `cimg/python` images ship no
|
||||
Rust toolchain, and when cargo is missing maturin's `puccinialin` helper
|
||||
quietly provisions one itself: it fetches `rustup-init` from the unversioned
|
||||
`https://static.rust-lang.org/rustup/dist/<triple>/` path with no checksum and
|
||||
installs a floating `stable` toolchain. uv suppresses build-backend output on a
|
||||
successful sync, so that happens with nothing in the job log to show for it,
|
||||
and the compiler a job builds with changes whenever upstream publishes.
|
||||
|
||||
Two invariants are pinned here:
|
||||
|
||||
1. No step list (job or reusable command) reaches a `uv sync` / `uv build`
|
||||
without a Rust toolchain already provisioned ahead of it. That is the
|
||||
`install_rust` command on Linux and an inline pinned rustup install in the
|
||||
Windows job, so the check accepts either. A new job that syncs without one
|
||||
falls back to the unpinned path, which is exactly the regression a static
|
||||
check catches at PR time and a green CI run does not.
|
||||
2. `install_rust` itself pins what it downloads: an explicit rustup version in
|
||||
the URL, a verified SHA-256, and an exact toolchain version rather than a
|
||||
channel name.
|
||||
|
||||
The Windows job predates `install_rust` and provisions its toolchain inline, so
|
||||
invariant 2 is scoped to `install_rust`; invariant 1 covers both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIG = REPO_ROOT / ".circleci" / "config.yml"
|
||||
|
||||
BUILDS_WORKSPACE = re.compile(r"\buv\s+(?:sync|build)\b")
|
||||
RUSTUP_ARCHIVE_URL = re.compile(r"https://static\.rust-lang\.org/rustup/archive/\d+\.\d+\.\d+/")
|
||||
EXACT_TOOLCHAIN = re.compile(r"--default-toolchain\s+\"?\d+\.\d+\.\d+\"?")
|
||||
|
||||
|
||||
def _config() -> dict[str, object]:
|
||||
return yaml.safe_load(CONFIG.read_text())
|
||||
|
||||
|
||||
def _step_text(step: object) -> str:
|
||||
"""Flatten one step into the shell text it runs, or '' for a command reference."""
|
||||
if isinstance(step, dict):
|
||||
run = step.get("run")
|
||||
if isinstance(run, str):
|
||||
return run
|
||||
if isinstance(run, dict):
|
||||
command = run.get("command")
|
||||
return command if isinstance(command, str) else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _without_comments(text: str) -> str:
|
||||
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
|
||||
|
||||
|
||||
def _provisions_rust(step: object) -> bool:
|
||||
if step == "install_rust":
|
||||
return True
|
||||
text = _step_text(step)
|
||||
return "rustup-init" in text and ("sha256sum" in text or "SHA256" in text)
|
||||
|
||||
|
||||
def _step_lists() -> dict[str, list[object]]:
|
||||
config = _config()
|
||||
lists: dict[str, list[object]] = {}
|
||||
for kind in ("jobs", "commands"):
|
||||
section = config.get(kind)
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for name, body in section.items():
|
||||
steps = body.get("steps") if isinstance(body, dict) else None
|
||||
if isinstance(steps, list):
|
||||
lists[f"{kind[:-1]} {name}"] = steps
|
||||
return lists
|
||||
|
||||
|
||||
def _first_unprovisioned_build(steps: list[object]) -> str | None:
|
||||
"""Return the shell text of the first workspace build reached without Rust, if any."""
|
||||
rust_ready = False
|
||||
for step in steps:
|
||||
if _provisions_rust(step):
|
||||
rust_ready = True
|
||||
text = _step_text(step)
|
||||
if BUILDS_WORKSPACE.search(_without_comments(text)) and not rust_ready:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def test_step_lists_exist() -> None:
|
||||
lists = _step_lists()
|
||||
assert "command install_rust" in lists
|
||||
building = {
|
||||
name
|
||||
for name, steps in lists.items()
|
||||
if any(BUILDS_WORKSPACE.search(_without_comments(_step_text(s))) for s in steps)
|
||||
}
|
||||
assert len(building) > 10, f"expected many workspace-building step lists, found {sorted(building)}"
|
||||
|
||||
|
||||
def test_no_workspace_build_without_a_provisioned_rust_toolchain() -> None:
|
||||
offenders = {
|
||||
name: build for name, steps in _step_lists().items() if (build := _first_unprovisioned_build(steps)) is not None
|
||||
}
|
||||
assert not offenders, (
|
||||
"these CircleCI step lists run `uv sync`/`uv build` with no Rust toolchain provisioned first, "
|
||||
"so maturin will download an unpinned rustup and a floating toolchain instead: "
|
||||
f"{ {name: build.strip().splitlines()[0] for name, build in offenders.items()} }"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="install_rust_command")
|
||||
def _install_rust_command() -> str:
|
||||
steps = _step_lists()["command install_rust"]
|
||||
return "\n".join(_step_text(step) for step in steps)
|
||||
|
||||
|
||||
def test_install_rust_pins_the_rustup_version_in_the_url(install_rust_command: str) -> None:
|
||||
assert RUSTUP_ARCHIVE_URL.search(install_rust_command), (
|
||||
"install_rust must download rustup-init from a version-pinned /rustup/archive/<x.y.z>/ URL; "
|
||||
"the /rustup/dist/ path always serves whatever rustup is current"
|
||||
)
|
||||
assert "/rustup/dist/" not in install_rust_command
|
||||
|
||||
|
||||
def test_install_rust_verifies_the_installer_checksum(install_rust_command: str) -> None:
|
||||
assert "sha256sum -c" in install_rust_command
|
||||
assert re.search(r"RUSTUP_SHA256=[0-9a-f]{64}\b", install_rust_command), (
|
||||
"install_rust must compare the downloaded installer against a hardcoded SHA-256 "
|
||||
"taken from rust-lang's published .sha256 sidecar"
|
||||
)
|
||||
checksum_index = install_rust_command.index("sha256sum -c")
|
||||
execute_index = install_rust_command.index("/tmp/rustup-init -y")
|
||||
assert checksum_index < execute_index, "the checksum must be verified before the installer is executed"
|
||||
|
||||
|
||||
def test_install_rust_pins_an_exact_toolchain_version(install_rust_command: str) -> None:
|
||||
assert EXACT_TOOLCHAIN.search(install_rust_command), (
|
||||
"install_rust must pin an exact toolchain version (e.g. 1.97.1); a channel name like "
|
||||
"stable/beta/nightly makes the compiler drift with whatever upstream published that day"
|
||||
)
|
||||
|
|
@ -2862,6 +2862,16 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/common_components/MetadataKeyValueFields.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/MetadataKeyValueFields.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/ModelAliasManager.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -4142,11 +4152,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
listGuardrailSubmissions: vi.fn(),
|
||||
approveGuardrailSubmission: vi.fn(),
|
||||
rejectGuardrailSubmission: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({
|
||||
useRegisterGuardrail: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listGuardrailSubmissions } from "@/components/networking";
|
||||
|
||||
const pendingSubmission = {
|
||||
guardrail_id: "guard-1",
|
||||
guardrail_name: "test-pending-guardrail",
|
||||
status: "pending_review",
|
||||
team_id: "team-1",
|
||||
team_guardrail: true,
|
||||
litellm_params: {
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: "pre_call",
|
||||
api_base: "https://example.com/guard",
|
||||
headers: { "X-API-Key": "secret" },
|
||||
extra_headers: ["x-request-id"],
|
||||
},
|
||||
guardrail_info: {},
|
||||
submitted_at: "2026-05-09T00:00:00Z",
|
||||
};
|
||||
|
||||
const baseAuth = {
|
||||
token: "test-token",
|
||||
accessToken: "test-token",
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: null,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
describe("TeamGuardrailsTab — approve/reject role gate", () => {
|
||||
const mockUseAuthorized = vi.mocked(useAuthorized);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listGuardrailSubmissions).mockResolvedValue({
|
||||
submissions: [pendingSubmission],
|
||||
summary: { total: 1, pending_review: 1, active: 0, rejected: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons for an internal user on a pending submission", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Internal User" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons for an Admin Viewer, whom the backend rejects with 403", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin Viewer" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Approve and Reject buttons for an admin on a pending submission", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons when userRole is undefined (defaults to non-admin)", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: undefined });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables all admin-only write controls for a non-admin, including the detail panel", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Internal User" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
expect(screen.getByRole("switch")).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByText("Forward LiteLLM API Key");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
screen.getAllByRole("switch").forEach((toggle) => expect(toggle).toBeDisabled());
|
||||
expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^Remove/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("e.g. x-request-id")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps all write controls enabled for an admin in the detail panel", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...baseAuth, userRole: "Admin" });
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByText("Forward LiteLLM API Key");
|
||||
|
||||
expect(screen.getAllByRole("button", { name: /approve/i }).length).toBeGreaterThanOrEqual(2);
|
||||
screen.getAllByRole("switch").forEach((toggle) => expect(toggle).toBeEnabled());
|
||||
expect(screen.getAllByRole("button", { name: "Add" })).toHaveLength(2);
|
||||
expect(screen.getByLabelText("Remove X-API-Key")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remove x-request-id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,8 @@ import {
|
|||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
type GuardrailStatus = "active" | "pending" | "rejected";
|
||||
|
||||
|
|
@ -188,16 +190,25 @@ function StatCard({ label, value, color }: { label: string; value: number; color
|
|||
);
|
||||
}
|
||||
|
||||
function Toggle({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
|
||||
function Toggle({
|
||||
enabled,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
|
||||
enabled ? "bg-blue-500" : "bg-gray-200"
|
||||
}`}
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
|
|
@ -212,6 +223,7 @@ type GuardrailCardProps = {
|
|||
guardrail: TeamGuardrail;
|
||||
isSelected: boolean;
|
||||
isHeadersExpanded: boolean;
|
||||
isAdmin: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleForwardKey: () => void;
|
||||
onToggleHeaders: () => void;
|
||||
|
|
@ -223,6 +235,7 @@ function GuardrailCard({
|
|||
guardrail: g,
|
||||
isSelected,
|
||||
isHeadersExpanded,
|
||||
isAdmin,
|
||||
onSelect,
|
||||
onToggleForwardKey,
|
||||
onToggleHeaders,
|
||||
|
|
@ -266,7 +279,7 @@ function GuardrailCard({
|
|||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">Forward API Key</span>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
|
|
@ -276,7 +289,7 @@ function GuardrailCard({
|
|||
>
|
||||
{isSelected ? "Close" : "Review"}
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -348,6 +361,7 @@ function ConfigRow({ label, children }: { label: string; children: React.ReactNo
|
|||
|
||||
type DetailPanelProps = {
|
||||
guardrail: TeamGuardrail;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
|
|
@ -358,6 +372,7 @@ type DetailPanelProps = {
|
|||
|
||||
function DetailPanel({
|
||||
guardrail: g,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -425,7 +440,7 @@ function DetailPanel({
|
|||
<KeyIcon className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-xs font-semibold text-blue-800">Forward LiteLLM API Key</span>
|
||||
</div>
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
|
||||
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} disabled={!isAdmin} />
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
When enabled, the caller's LiteLLM API key is forwarded as an{" "}
|
||||
|
|
@ -456,28 +471,63 @@ function DetailPanel({
|
|||
<span className="text-gray-700 truncate">
|
||||
{h.key}: {h.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${h.key}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${h.key}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderKey}
|
||||
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
|
||||
placeholder="Header name (e.g. X-API-Key)"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
{isAdmin && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderKey}
|
||||
onChange={(e) => setNewStaticHeaderKey(e.target.value)}
|
||||
placeholder="Header name (e.g. X-API-Key)"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderValue}
|
||||
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
|
|
@ -485,44 +535,13 @@ function DetailPanel({
|
|||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newStaticHeaderValue}
|
||||
onChange={(e) => setNewStaticHeaderValue(e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const key = newStaticHeaderKey.trim();
|
||||
const value = newStaticHeaderValue.trim();
|
||||
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
|
||||
onUpdateCustomHeaders([...g.customHeaders, { key, value }]);
|
||||
setNewStaticHeaderKey("");
|
||||
setNewStaticHeaderValue("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
|
|
@ -546,50 +565,54 @@ function DetailPanel({
|
|||
className="flex items-center justify-between gap-2 text-xs font-mono bg-gray-50 border border-gray-200 rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<span className="text-gray-700 truncate">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-red-600 shrink-0"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExtraHeader}
|
||||
onChange={(e) => setNewExtraHeader(e.target.value)}
|
||||
placeholder="e.g. x-request-id"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExtraHeader}
|
||||
onChange={(e) => setNewExtraHeader(e.target.value)}
|
||||
placeholder="e.g. x-request-id"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const name = newExtraHeader.trim().toLowerCase();
|
||||
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
|
||||
onUpdateExtraHeaders([...g.extraHeaders, name]);
|
||||
setNewExtraHeader("");
|
||||
}
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
|
|
@ -635,7 +658,7 @@ function DetailPanel({
|
|||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
Test Endpoint
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -722,6 +745,8 @@ interface TeamGuardrailsTabProps {
|
|||
}
|
||||
|
||||
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
||||
const { userRole } = useAuthorized();
|
||||
const isAdmin = userRole ? isProxyAdminRole(userRole) : false;
|
||||
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
|
||||
const [summary, setSummary] = useState({
|
||||
total: 0,
|
||||
|
|
@ -922,6 +947,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
guardrail={g}
|
||||
isSelected={selectedId === g.id}
|
||||
isHeadersExpanded={expandedHeaders.has(g.id)}
|
||||
isAdmin={isAdmin}
|
||||
onSelect={() => setSelectedId(selectedId === g.id ? null : g.id)}
|
||||
onToggleForwardKey={() => toggleForwardKey(g.id)}
|
||||
onToggleHeaders={() => toggleHeaders(g.id)}
|
||||
|
|
@ -934,6 +960,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
{selected && (
|
||||
<DetailPanel
|
||||
guardrail={selected}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onApprove={() => setConfirmAction({ id: selected.id, action: "approve" })}
|
||||
onReject={() => setConfirmAction({ id: selected.id, action: "reject" })}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchTeamMetadataSchema } from "./useTeamMetadataSchema";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: vi.fn(() => ""),
|
||||
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
|
||||
}));
|
||||
|
||||
describe("fetchTeamMetadataSchema", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should return the declared fields from the response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ fields: [{ key: "cost_center", label: "Cost Center", required: true }] }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([
|
||||
{ key: "cost_center", label: "Cost Center", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return an empty list when the response has no fields array", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "{}" }));
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw on a non-ok response so the query can retry and fail open", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, text: async () => "" }));
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).rejects.toThrow("404");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
|
||||
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
|
||||
export interface TeamMetadataField {
|
||||
key: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const silentApiClient = createApiClient({
|
||||
getBaseUrl: getProxyBaseUrl,
|
||||
getAuthHeaderName: getGlobalLitellmHeaderName,
|
||||
});
|
||||
|
||||
export const fetchTeamMetadataSchema = async (accessToken: string): Promise<TeamMetadataField[]> => {
|
||||
const data = await silentApiClient.get<{ fields?: TeamMetadataField[] }>("/team/metadata_schema", { accessToken });
|
||||
return Array.isArray(data?.fields) ? data.fields : [];
|
||||
};
|
||||
|
||||
export const teamMetadataSchemaKeys = createQueryKeys("teamMetadataSchema");
|
||||
|
||||
export const useTeamMetadataSchema = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<TeamMetadataField[]>({
|
||||
queryKey: teamMetadataSchemaKeys.list({}),
|
||||
queryFn: async () => await fetchTeamMetadataSchema(accessToken!),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: TWENTY_FOUR_HOURS_MS,
|
||||
gcTime: TWENTY_FOUR_HOURS_MS,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
|
|
@ -130,7 +130,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
|
|
@ -197,7 +197,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
premium_user: true,
|
||||
disabled_non_admin_personal_key_creation: false,
|
||||
login_method: "username_password",
|
||||
|
|
@ -256,7 +256,7 @@ describe("useAuthorized", () => {
|
|||
key: "api-key-123",
|
||||
user_id: "user-1",
|
||||
user_email: "user@example.com",
|
||||
user_role: "app_admin",
|
||||
user_role: "proxy_admin",
|
||||
};
|
||||
|
||||
decodeTokenMock.mockReturnValue(decodedPayload);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
|
||||
import Teams from "./Teams";
|
||||
|
|
@ -33,6 +35,10 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
|||
teamsTableKeys: { all: ["teamsTable"] },
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
|
||||
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("./molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -617,6 +623,210 @@ describe("Teams - access_group_ids in team create", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Teams - metadata key-value pairs in team create", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamCreateCall).mockResolvedValue({
|
||||
team_id: "new-team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-4"],
|
||||
organization_id: null,
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
const openCreateModal = async () => {
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("renders the metadata editor in the main form without opening Additional Settings", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: /add key-value pair/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits metadata built from key-value pairs as a typed JSON object", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Key")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Key"), { target: { value: "cost_center" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "eng-42" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key")).toHaveLength(2);
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText("Key")[1], { target: { value: "tier" } });
|
||||
fireEvent.change(screen.getAllByPlaceholderText("Value")[1], { target: { value: "3" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
|
||||
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "eng-42", tier: 3 });
|
||||
});
|
||||
|
||||
it("omits metadata entirely when no pairs are added", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(teamCreateCall).mock.calls[0][1].metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - schema-declared metadata fields in team create", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamCreateCall).mockResolvedValue({
|
||||
team_id: "new-team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-4"],
|
||||
organization_id: null,
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({ data: null });
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({
|
||||
data: [{ key: "cost_center", label: "Cost Center" }],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const openCreateModal = async () => {
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("should prepopulate the declared key as an ordinary pair row and submit its value", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-1001" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
|
||||
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "CC-1001" });
|
||||
});
|
||||
|
||||
it("should toast only the validator's own message when the backend rejects the create", async () => {
|
||||
vi.mocked(teamCreateCall).mockRejectedValue(
|
||||
new Error("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
|
||||
);
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-9999" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
"Error creating the team: Cost center CC-9999 is not recognized. Contact the FinOps team.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a skeleton in the metadata section while the schema is loading", async () => {
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: undefined, isLoading: true } as any);
|
||||
await openCreateModal();
|
||||
|
||||
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should re-seed declared keys when the create modal is closed and reopened", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Remove key-value pair"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/team name/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - models dropdown options", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
|
|||
import { useTeamDetailRouting } from "@/app/(dashboard)/teams/detailNavigation";
|
||||
import { TeamsTable } from "./TeamsPage/TeamsTable";
|
||||
import AccessGroupSelector from "./common_components/AccessGroupSelector";
|
||||
import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
|
||||
import AgentSelector from "./agent_management/AgentSelector";
|
||||
import ModelAliasManager from "./common_components/ModelAliasManager";
|
||||
|
|
@ -28,6 +30,7 @@ import type { Team } from "./key_team_helpers/key_list";
|
|||
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
|
|
@ -125,6 +128,7 @@ const getOrganizationAlias = (
|
|||
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
|
||||
const { data: organizationsData } = useOrganizations();
|
||||
const organizations = organizationsData ?? null;
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all });
|
||||
const [currentOrg] = useState<Organization | null>(null);
|
||||
|
|
@ -322,25 +326,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
|
||||
NotificationsManager.info("Creating Team");
|
||||
|
||||
// Handle logging settings in metadata
|
||||
if (loggingSettings.length > 0) {
|
||||
let metadata = {};
|
||||
if (formValues.metadata) {
|
||||
try {
|
||||
metadata = JSON.parse(formValues.metadata);
|
||||
} catch (e) {
|
||||
console.warn("Invalid JSON in metadata field, starting with empty object");
|
||||
}
|
||||
}
|
||||
|
||||
// Add logging settings to metadata
|
||||
metadata = {
|
||||
...metadata,
|
||||
logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name
|
||||
};
|
||||
|
||||
formValues.metadata = JSON.stringify(metadata);
|
||||
}
|
||||
const metadataObject = {
|
||||
...metadataPairsToObject(formValues.metadata),
|
||||
...(loggingSettings.length > 0 ? { logging: loggingSettings.filter((config) => config.callback_name) } : {}),
|
||||
};
|
||||
formValues.metadata = Object.keys(metadataObject).length > 0 ? JSON.stringify(metadataObject) : undefined;
|
||||
|
||||
if (formValues.secret_manager_settings) {
|
||||
if (typeof formValues.secret_manager_settings === "string") {
|
||||
|
|
@ -451,7 +441,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating the team:", error);
|
||||
NotificationsManager.fromBackend("Error creating the team: " + error);
|
||||
NotificationsManager.fromBackend("Error creating the team: " + extractProxyErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -593,6 +583,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
footer={null}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<>
|
||||
|
|
@ -747,6 +738,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
|
||||
>
|
||||
<MetadataKeyValueFields
|
||||
form={form}
|
||||
schemaFields={teamMetadataSchemaFields}
|
||||
schemaLoading={isTeamMetadataSchemaLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Accordion
|
||||
className="mt-20 mb-8"
|
||||
|
|
@ -801,13 +802,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
name="metadata"
|
||||
help="Additional team metadata. Enter metadata as JSON object."
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Secret Manager Settings"
|
||||
name="secret_manager_settings"
|
||||
|
|
|
|||
|
|
@ -310,6 +310,26 @@ describe("ComplexityRouterConfig", () => {
|
|||
expect(newRules[0]).toMatchObject({ keywords: [], tier: "COMPLEX" });
|
||||
});
|
||||
|
||||
// The dropdown is closed, so antd has nothing for Enter to select and the word would only land
|
||||
// on blur. Submitting used to provide that blur; it no longer can while the row reads as empty.
|
||||
it("commits a typed keyword on Enter, with the dropdown closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
{...baseProps}
|
||||
keywordTierRules={[{ id: "rule-1", keywords: [], tier: "COMPLEX" }]}
|
||||
onKeywordTierRulesChange={onKeywordTierRulesChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
|
||||
const field = screen.getByText("Keywords 1").closest("div") as HTMLElement;
|
||||
await user.type(within(field).getByRole("combobox"), "invoice{enter}");
|
||||
|
||||
expect(onKeywordTierRulesChange).toHaveBeenCalledWith([{ id: "rule-1", keywords: ["invoice"], tier: "COMPLEX" }]);
|
||||
});
|
||||
|
||||
it("should render an existing keyword tier rule and remove it when the delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onKeywordTierRulesChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from "@ant-design/ic
|
|||
import { Button, Card, Empty, Select as AntdSelect, Tooltip, Typography } from "antd";
|
||||
import React from "react";
|
||||
|
||||
import { emptyKeywordTierRuleIndexes } from "./complexity_router_keywords";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
||||
|
|
@ -24,7 +26,36 @@ const TIER_OPTIONS: { value: ComplexityTier; label: string }[] = [
|
|||
{ value: "REASONING", label: "Reasoning" },
|
||||
];
|
||||
|
||||
// A row exists only because the caller asked for it, so it reports its own gap straight away
|
||||
// rather than waiting for a submit; the submit button is disabled while one is outstanding, so
|
||||
// there is no failed attempt left to surface it.
|
||||
const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange }) => {
|
||||
const emptyRuleIndexes = new Set(emptyKeywordTierRuleIndexes(rules));
|
||||
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
|
||||
|
||||
const setDraft = (id: string, text: string) => setDrafts((current) => ({ ...current, [id]: text }));
|
||||
|
||||
// The dropdown is kept closed, which leaves antd nothing for Enter to select, so a typed keyword
|
||||
// would only become a tag on blur. Submitting used to supply that blur; the button is disabled
|
||||
// while the row reads as empty, so Enter has to commit the word itself or the row cannot be filled.
|
||||
const commitDraft = (rule: KeywordTierRule) => {
|
||||
const keyword = (drafts[rule.id] ?? "").trim();
|
||||
if (!keyword) return;
|
||||
updateRule(rule.id, { keywords: [...rule.keywords, keyword] });
|
||||
setDraft(rule.id, "");
|
||||
};
|
||||
|
||||
const commitDraftOnEnter = (rule: KeywordTierRule) => (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
commitDraft(rule);
|
||||
};
|
||||
|
||||
const replaceKeywords = (rule: KeywordTierRule) => (keywords: string[]) => {
|
||||
updateRule(rule.id, { keywords });
|
||||
setDraft(rule.id, "");
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { id: `${Date.now()}`, keywords: [], tier: "COMPLEX" }]);
|
||||
};
|
||||
|
|
@ -73,14 +104,24 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange })
|
|||
<AntdSelect
|
||||
mode="tags"
|
||||
value={rule.keywords}
|
||||
onChange={(keywords: string[]) => updateRule(rule.id, { keywords })}
|
||||
onChange={replaceKeywords(rule)}
|
||||
searchValue={drafts[rule.id] ?? ""}
|
||||
onSearch={(text) => setDraft(rule.id, text)}
|
||||
onInputKeyDown={commitDraftOnEnter(rule)}
|
||||
onBlur={() => commitDraft(rule)}
|
||||
placeholder="e.g., invoice, refund, billing"
|
||||
tokenSeparators={[","]}
|
||||
open={false}
|
||||
suffixIcon={null}
|
||||
style={{ width: "100%" }}
|
||||
allowClear
|
||||
status={emptyRuleIndexes.has(index) ? "error" : undefined}
|
||||
/>
|
||||
{emptyRuleIndexes.has(index) && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
At least one keyword is required
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { renderWithProviders, screen, waitFor, testQueryClient } from "../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor, testQueryClient, within } from "../../../tests/test-utils";
|
||||
import { act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
|
|
@ -123,14 +123,22 @@ describe("AddAutoRouterTab", () => {
|
|||
mockHandleAddAutoRouterSubmit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
|
||||
// Nothing is filled in, so there is nothing to submit. The button reports that itself instead of
|
||||
// accepting a click and answering with a toast.
|
||||
it("offers no submit at all until every tier has a model", async () => {
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("still flags the router name once the config no longer blocks the submit", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
expect(await screen.findByText("Auto router name is required")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("This tier is required")).toHaveLength(4);
|
||||
expect(NotificationManager.fromBackend).toHaveBeenCalledWith("Please enter an Auto Router Name");
|
||||
});
|
||||
|
||||
|
|
@ -517,6 +525,85 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" });
|
||||
});
|
||||
|
||||
// LIT-5133: "Add keyword rule" seeds a row with no keywords, and the semantic toggle that used
|
||||
// to be the only thing checking them is off by default. The row was dropped on the way to the
|
||||
// payload, so the create succeeded and the caller's rule was gone with nothing said about it.
|
||||
it("takes the submit away while a keyword rule is left empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "keyword-router");
|
||||
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();
|
||||
// The row says so on its own; there is no failed submit left to surface it.
|
||||
expect(await screen.findByText("At least one keyword is required")).toBeInTheDocument();
|
||||
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives the submit back once that keyword rule is filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "keyword-router");
|
||||
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();
|
||||
|
||||
await user.type(
|
||||
within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"),
|
||||
"invoice{enter}",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeEnabled();
|
||||
expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks only the offending keyword row, leaving a filled one alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "keyword-router");
|
||||
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
await user.type(
|
||||
within(screen.getByText("Keywords 1").closest("div") as HTMLElement).getByRole("combobox"),
|
||||
"invoice{enter}",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
|
||||
expect(await screen.findAllByText("At least one keyword is required")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("creates the router once that keyword rule is filled in", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
openTemplateDropdown();
|
||||
fireEvent.click(optionByLabel("Custom Configuration")!);
|
||||
await user.type(screen.getByPlaceholderText(/smart_router/i), "keyword-router");
|
||||
await user.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
const keywordsField = screen.getByText("Keywords 1").closest("div") as HTMLElement;
|
||||
await user.type(within(keywordsField).getByRole("combobox"), "invoice{enter}");
|
||||
await user.click(screen.getByRole("button", { name: /add auto router/i }));
|
||||
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
complexity_router_config: { keyword_tier_rules: [{ keywords: ["invoice"], tier: "COMPLEX" }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks the submit when a team admin has not picked a team", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getMissingTiersError).mockReturnValue(null);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords";
|
|||
import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
|
||||
import {
|
||||
buildComplexityRouterConfig,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getSemanticConfigError,
|
||||
} from "./build_complexity_router_config";
|
||||
|
|
@ -214,6 +215,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return getMissingModelsInPreset(preset, freshSet).length === 0;
|
||||
};
|
||||
|
||||
// Why the submit is unavailable, or null when it is available. The button reads this to disable
|
||||
// itself and to say what is missing, so the two can never give different answers.
|
||||
const submitBlockedReason =
|
||||
getMissingTiersError(complexityRouterConfig.tiers) ?? getKeywordTierRulesError(keywordTierRules);
|
||||
|
||||
const submitRecommendedRouter = async (name: string) => {
|
||||
if (!selectedPreset) {
|
||||
setShowValidationErrors(true);
|
||||
|
|
@ -257,6 +263,13 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationManager.fromBackend(keywordRulesError);
|
||||
return;
|
||||
}
|
||||
|
||||
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
|
||||
if (semanticError) {
|
||||
setShowValidationErrors(true);
|
||||
|
|
@ -502,15 +515,18 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
Test Connection
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
loading={isSubmittingRouter}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
<Tooltip title={submitBlockedReason}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
loading={isSubmittingRouter}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
buildComplexityRouterConfig,
|
||||
getKeywordTierRulesError,
|
||||
getMissingTiersError,
|
||||
getSemanticConfigError,
|
||||
BuildComplexityRouterConfigParams,
|
||||
|
|
@ -183,7 +184,7 @@ describe("buildComplexityRouterConfig", () => {
|
|||
expect(config.keyword_tier_rules).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims keywords and drops rules left empty, so unfilled rows never 400 the backend", () => {
|
||||
it("trims keywords but keeps rules left empty, so a dropped row can never pass for a saved one", () => {
|
||||
const params: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
keywordTierRules: [
|
||||
|
|
@ -193,17 +194,13 @@ describe("buildComplexityRouterConfig", () => {
|
|||
],
|
||||
};
|
||||
const config = buildComplexityRouterConfig(params);
|
||||
// r1 keeps only its real keyword (trimmed); r2 and r3 are dropped entirely.
|
||||
expect(config.keyword_tier_rules).toEqual([{ keywords: ["deploy to k8s"], tier: "REASONING" }]);
|
||||
});
|
||||
|
||||
it("omits keyword_tier_rules entirely when every rule is empty", () => {
|
||||
const params: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
keywordTierRules: [{ id: "r1", keywords: ["", " "], tier: "COMPLEX" }],
|
||||
};
|
||||
const config = buildComplexityRouterConfig(params);
|
||||
expect(config.keyword_tier_rules).toBeUndefined();
|
||||
// getKeywordTierRulesError blocks this submit; r2 and r3 survive here so the backend rejects
|
||||
// them loudly rather than the caller's rows vanishing on a successful save.
|
||||
expect(config.keyword_tier_rules).toEqual([
|
||||
{ keywords: ["deploy to k8s"], tier: "REASONING" },
|
||||
{ keywords: [], tier: "COMPLEX" },
|
||||
{ keywords: [], tier: "SIMPLE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits adaptive fields when adaptive is disabled even if weights linger in state", () => {
|
||||
|
|
@ -318,17 +315,6 @@ describe("getSemanticConfigError", () => {
|
|||
).toMatch(/keyword tier rule/i);
|
||||
});
|
||||
|
||||
it("errors when a rule has no non-empty keywords", () => {
|
||||
const emptyRule = { id: "r2", keywords: ["", " "], tier: "SIMPLE" as const };
|
||||
expect(
|
||||
getSemanticConfigError({
|
||||
semanticMatchingEnabled: true,
|
||||
embeddingModel: "voyage-3-5",
|
||||
keywordTierRules: [emptyRule],
|
||||
}),
|
||||
).toMatch(/at least one keyword/i);
|
||||
});
|
||||
|
||||
it("returns null when enabled with both an embedding model and rules", () => {
|
||||
expect(
|
||||
getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", keywordTierRules: [rule] }),
|
||||
|
|
@ -336,6 +322,52 @@ describe("getSemanticConfigError", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getKeywordTierRulesError", () => {
|
||||
it("returns null when every rule carries a keyword", () => {
|
||||
expect(
|
||||
getKeywordTierRulesError([
|
||||
{ id: "r1", keywords: ["invoice"], tier: "MEDIUM" },
|
||||
{ id: "r2", keywords: ["deploy to k8s"], tier: "REASONING" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there are no rules at all, since the section is optional", () => {
|
||||
expect(getKeywordTierRulesError([])).toBeNull();
|
||||
});
|
||||
|
||||
// The whole point of the ticket: the semantic toggle is off by default, and an unfilled row
|
||||
// used to be discarded silently on an otherwise successful create.
|
||||
it("rejects a row left empty while semantic matching is off", () => {
|
||||
expect(getKeywordTierRulesError([{ id: "r1", keywords: [], tier: "COMPLEX" }])).toBe(
|
||||
"Add at least one keyword to keyword rule(s): 1",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["whitespace only", [" "]],
|
||||
["blank strings, as an unfilled row between filled ones leaves behind", ["", " ", ""]],
|
||||
])("treats %s as empty rather than as a keyword", (_label, keywords) => {
|
||||
expect(getKeywordTierRulesError([{ id: "r1", keywords, tier: "SIMPLE" }])).toMatch(/keyword rule\(s\): 1/);
|
||||
});
|
||||
|
||||
// Row numbers have to survive rules that are fine, or the message points at the wrong input.
|
||||
it("names each offending row by its position among all rules", () => {
|
||||
expect(
|
||||
getKeywordTierRulesError([
|
||||
{ id: "r1", keywords: ["invoice"], tier: "MEDIUM" },
|
||||
{ id: "r2", keywords: [], tier: "COMPLEX" },
|
||||
{ id: "r3", keywords: ["billing"], tier: "SIMPLE" },
|
||||
{ id: "r4", keywords: [" "], tier: "REASONING" },
|
||||
]),
|
||||
).toBe("Add at least one keyword to keyword rule(s): 2, 4");
|
||||
});
|
||||
|
||||
it("keeps a keyword whose surrounding whitespace is the only thing trimmed", () => {
|
||||
expect(getKeywordTierRulesError([{ id: "r1", keywords: [" invoice "], tier: "MEDIUM" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComplexityRouterConfig assistant turns", () => {
|
||||
const llmParams: BuildComplexityRouterConfigParams = {
|
||||
...baseParams,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import { serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
import {
|
||||
AdaptiveEligible,
|
||||
AdaptiveRouterWeights,
|
||||
|
|
@ -58,6 +58,12 @@ export const getMissingTiersError = (tiers: ComplexityTiers): string | null => {
|
|||
return `Select a model for the following tier(s): ${missing.join(", ")}`;
|
||||
};
|
||||
|
||||
export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => {
|
||||
const emptyRows = emptyKeywordTierRuleIndexes(keywordTierRules);
|
||||
if (emptyRows.length === 0) return null;
|
||||
return `Add at least one keyword to keyword rule(s): ${emptyRows.map((index) => index + 1).join(", ")}`;
|
||||
};
|
||||
|
||||
export const getSemanticConfigError = ({
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
|
|
@ -68,8 +74,6 @@ export const getSemanticConfigError = ({
|
|||
if (!semanticMatchingEnabled) return null;
|
||||
if (!embeddingModel) return "Select an embedding model to use semantic keyword matching";
|
||||
if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching";
|
||||
if (keywordTierRules.some((rule) => !rule.keywords.some((keyword) => keyword.trim())))
|
||||
return "Every keyword tier rule needs at least one keyword";
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -94,7 +98,6 @@ export const buildComplexityRouterConfig = ({
|
|||
returnRawModelName,
|
||||
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
|
||||
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
|
||||
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
|
||||
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -19,13 +19,19 @@ const asKeywords = (value: unknown): string[] =>
|
|||
: [];
|
||||
|
||||
/**
|
||||
* Drop the React-only id, trim keywords, and discard rules left empty. "Add keyword rule"
|
||||
* seeds a row with no keywords, and the backend validator rejects those with a 400.
|
||||
* Drop the React-only id and trim keywords, leaving one entry per rule. A rule left empty stays
|
||||
* empty rather than disappearing, so getKeywordTierRulesError can name the row it came from.
|
||||
*/
|
||||
export const serializeKeywordTierRules = (rules: KeywordTierRule[]): StoredKeywordTierRule[] =>
|
||||
rules
|
||||
.map((rule) => ({ keywords: asKeywords(rule.keywords).filter(Boolean), tier: rule.tier }))
|
||||
.filter((rule) => rule.keywords.length > 0);
|
||||
rules.map((rule) => ({ keywords: asKeywords(rule.keywords).filter(Boolean), tier: rule.tier }));
|
||||
|
||||
/**
|
||||
* Positions of the rules left without a keyword, as indexes into the caller's own array. The
|
||||
* submit-time message and the inline error on the row both read this, so the row the message
|
||||
* names is always the row that lights up.
|
||||
*/
|
||||
export const emptyKeywordTierRuleIndexes = (rules: KeywordTierRule[]): number[] =>
|
||||
serializeKeywordTierRules(rules).flatMap((rule, index) => (rule.keywords.length === 0 ? [index] : []));
|
||||
|
||||
export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import MetadataKeyValueFields, {
|
||||
MetadataPair,
|
||||
metadataObjectToPairs,
|
||||
metadataPairsToObject,
|
||||
} from "./MetadataKeyValueFields";
|
||||
|
||||
describe("metadataObjectToPairs", () => {
|
||||
it("returns an empty list for null or undefined metadata", () => {
|
||||
expect(metadataObjectToPairs(null)).toEqual([]);
|
||||
expect(metadataObjectToPairs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps plain string values as-is", () => {
|
||||
expect(metadataObjectToPairs({ department: "research" })).toEqual([{ key: "department", value: "research" }]);
|
||||
});
|
||||
|
||||
it("serializes non-string values as JSON", () => {
|
||||
expect(
|
||||
metadataObjectToPairs({
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
tags: ["a", "b"],
|
||||
empty: null,
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: "tier", value: "3" },
|
||||
{ key: "beta", value: "true" },
|
||||
{ key: "config", value: '{"region":"us"}' },
|
||||
{ key: "tags", value: '["a","b"]' },
|
||||
{ key: "empty", value: "null" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("quotes string values that would otherwise parse as JSON, so types round-trip", () => {
|
||||
expect(metadataObjectToPairs({ code: "42", flag: "true" })).toEqual([
|
||||
{ key: "code", value: '"42"' },
|
||||
{ key: "flag", value: '"true"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out excluded keys", () => {
|
||||
expect(
|
||||
metadataObjectToPairs({ department: "research", logging: [{ callback_name: "langfuse" }] }, new Set(["logging"])),
|
||||
).toEqual([{ key: "department", value: "research" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadataPairsToObject", () => {
|
||||
it("returns an empty object for undefined pairs", () => {
|
||||
expect(metadataPairsToObject(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps plain text values as strings", () => {
|
||||
expect(metadataPairsToObject([{ key: "department", value: "research" }])).toEqual({ department: "research" });
|
||||
});
|
||||
|
||||
it("parses JSON values into their typed form", () => {
|
||||
expect(
|
||||
metadataPairsToObject([
|
||||
{ key: "tier", value: "3" },
|
||||
{ key: "beta", value: "true" },
|
||||
{ key: "config", value: '{"region":"us"}' },
|
||||
{ key: "code", value: '"42"' },
|
||||
]),
|
||||
).toEqual({ tier: 3, beta: true, config: { region: "us" }, code: "42" });
|
||||
});
|
||||
|
||||
it("skips rows without a key and defaults a missing value to an empty string", () => {
|
||||
expect(metadataPairsToObject([{ key: "", value: "orphan" }, undefined, { key: "kept" }])).toEqual({ kept: "" });
|
||||
});
|
||||
|
||||
it("round-trips a mixed-type metadata object losslessly", () => {
|
||||
const metadata = {
|
||||
department: "research",
|
||||
code: "42",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us", replicas: 2 },
|
||||
};
|
||||
expect(metadataPairsToObject(metadataObjectToPairs(metadata))).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
onFinish: (values: { metadata?: MetadataPair[] }) => void;
|
||||
initialMetadata?: MetadataPair[];
|
||||
schemaFields?: TeamMetadataField[];
|
||||
schemaLoading?: boolean;
|
||||
}
|
||||
|
||||
const Harness: React.FC<HarnessProps> = ({ onFinish, initialMetadata, schemaFields, schemaLoading }) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish} initialValues={{ metadata: initialMetadata }}>
|
||||
<MetadataKeyValueFields form={form} schemaFields={schemaFields} schemaLoading={schemaLoading} />
|
||||
<button type="submit">Save</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
describe("MetadataKeyValueFields", () => {
|
||||
it("renders one row per existing pair", () => {
|
||||
render(
|
||||
<Harness
|
||||
onFinish={vi.fn()}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "tier", value: "3" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
expect(keyInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["department", "tier"]);
|
||||
expect(valueInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["research", "3"]);
|
||||
});
|
||||
|
||||
it("adds a row and submits the entered pair", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
|
||||
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "eng-1" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a row when its remove icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
onFinish={onFinish}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "tier", value: "3" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "tier", value: "3" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submission on duplicate keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
onFinish={onFinish}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "department", value: "sales" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Duplicate key").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission when a row is missing its key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Value"), "orphan");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Missing key")).toBeInTheDocument();
|
||||
});
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataKeyValueFields with a declared schema", () => {
|
||||
const schema: TeamMetadataField[] = [
|
||||
{ key: "cost_center", label: "Cost Center" },
|
||||
{ key: "app_name", label: "Application Name" },
|
||||
];
|
||||
|
||||
it("should prepopulate one ordinary editable pair row per declared key", async () => {
|
||||
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
screen.getAllByPlaceholderText("Key").forEach((input) => expect(input).toBeEnabled());
|
||||
expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should submit a prepopulated key with its typed value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} schemaFields={[{ key: "cost_center", label: "Cost Center" }]} />);
|
||||
|
||||
await user.type(await screen.findByPlaceholderText("Value"), "CC-1001");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "CC-1001" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("should not add a second row for keys already present in the form", async () => {
|
||||
render(
|
||||
<Harness onFinish={vi.fn()} schemaFields={schema} initialMetadata={[{ key: "cost_center", value: "CC-1001" }]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
expect(screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"CC-1001",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should let the user remove a prepopulated row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
|
||||
|
||||
await screen.findAllByPlaceholderText("Key");
|
||||
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a skeleton instead of the editor while the schema is loading", () => {
|
||||
render(<Harness onFinish={vi.fn()} schemaLoading />);
|
||||
|
||||
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed rows when the schema arrives after an initial loading state", async () => {
|
||||
const onFinish = vi.fn();
|
||||
const { rerender } = render(<Harness onFinish={onFinish} schemaLoading />);
|
||||
|
||||
rerender(<Harness onFinish={onFinish} schemaFields={schema} schemaLoading={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Form, FormInstance, Input, Skeleton, Space } from "antd";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
|
||||
export interface MetadataPair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function formatMetadataValue(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return JSON.stringify(value) ?? "";
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadataValue(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function metadataObjectToPairs(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
excludedKeys: ReadonlySet<string> = new Set(),
|
||||
): MetadataPair[] {
|
||||
return Object.entries(metadata ?? {})
|
||||
.filter(([key]) => !excludedKeys.has(key))
|
||||
.map(([key, value]) => ({ key, value: formatMetadataValue(value) }));
|
||||
}
|
||||
|
||||
export function metadataPairsToObject(
|
||||
pairs: readonly (Partial<MetadataPair> | undefined)[] | undefined,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
(pairs ?? [])
|
||||
.filter((pair): pair is Partial<MetadataPair> & { key: string } => Boolean(pair?.key))
|
||||
.map((pair) => [pair.key, parseMetadataValue(pair.value ?? "")]),
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataKeyValueFieldsProps {
|
||||
form: FormInstance;
|
||||
name?: string;
|
||||
schemaFields?: readonly TeamMetadataField[];
|
||||
schemaLoading?: boolean;
|
||||
}
|
||||
|
||||
const MetadataKeyValueFields: React.FC<MetadataKeyValueFieldsProps> = ({
|
||||
form,
|
||||
name = "metadata",
|
||||
schemaFields = [],
|
||||
schemaLoading = false,
|
||||
}) => {
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (seededRef.current || schemaLoading || schemaFields.length === 0) return;
|
||||
seededRef.current = true;
|
||||
const pairs: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
|
||||
if (!Array.isArray(pairs)) return;
|
||||
const existingKeys = new Set(pairs.map((pair) => pair?.key).filter(Boolean));
|
||||
const seeded = schemaFields
|
||||
.filter((field) => !existingKeys.has(field.key))
|
||||
.map((field) => ({ key: field.key, value: "" }));
|
||||
if (seeded.length > 0) {
|
||||
form.setFieldValue(name, [...pairs, ...seeded]);
|
||||
}
|
||||
}, [form, name, schemaFields, schemaLoading]);
|
||||
|
||||
if (schemaLoading) {
|
||||
return (
|
||||
<div data-testid="metadata-schema-skeleton">
|
||||
<Skeleton active title={false} paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name: fieldName, ...restField }) => (
|
||||
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[fieldName, "key"]}
|
||||
rules={[
|
||||
{ required: true, message: "Missing key" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const all: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
|
||||
const dupes = all.filter((entry) => entry?.key === value);
|
||||
if (dupes.length > 1) {
|
||||
return Promise.reject(new Error("Duplicate key"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="Key" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[fieldName, "value"]}>
|
||||
<Input placeholder="Value" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined
|
||||
aria-label="Remove key-value pair"
|
||||
onClick={() => remove(fieldName)}
|
||||
style={{ color: "#ef4444" }}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Add Key-Value Pair
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataKeyValueFields;
|
||||
|
|
@ -54,13 +54,16 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
|
|||
expect(result.keyword_tier_rules).toEqual([{ keywords: ["chargeback"], tier: "COMPLEX" }]);
|
||||
});
|
||||
|
||||
it("drops a rule left empty rather than shipping one the backend 400s on", () => {
|
||||
// getKeywordTierRulesError blocks this save, so the builder never runs on a real edit. Keeping
|
||||
// the rule here means that if a caller ever reaches it anyway, the stored rules are replaced by
|
||||
// something the backend rejects out loud rather than by silence that reads as a clean save.
|
||||
it("keeps a rule left empty rather than quietly dropping the caller's row", () => {
|
||||
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
|
||||
...hydratedState,
|
||||
keywordTierRules: [{ id: "new-1", keywords: [" "], tier: "SIMPLE" }],
|
||||
});
|
||||
|
||||
expect(result.keyword_tier_rules).toBeUndefined();
|
||||
expect(result.keyword_tier_rules).toEqual([{ keywords: [], tier: "SIMPLE" }]);
|
||||
});
|
||||
|
||||
it("removes the semantic trio when the toggle is turned off", () => {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,80 @@ describe("EditAutoRouterModal keyword matching", () => {
|
|||
await waitFor(() => expect(NotificationsManager.fromBackend).toHaveBeenCalled());
|
||||
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// LIT-5133, edit side. Semantic matching is off here on purpose: it used to be the only thing
|
||||
// that checked a rule for keywords, so with it on this save was already blocked and the test
|
||||
// would pass without the fix. Off, the unfilled row was dropped and the save reported success.
|
||||
it("blocks a save that adds a keyword rule and leaves it empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: {
|
||||
...STORED_CONFIG,
|
||||
semantic_keyword_matching: false,
|
||||
embedding_model: undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(/Escalation Keywords/i);
|
||||
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
|
||||
// The modal renders the same controls as the create form, so it owes the same treatment:
|
||||
// the row says what is missing and the save is not offered while it is.
|
||||
expect(await screen.findByText("At least one keyword is required")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
|
||||
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives the save back once the added keyword rule is filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<EditAutoRouterModal
|
||||
isVisible
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
modelData={{
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: {
|
||||
...STORED_CONFIG,
|
||||
semantic_keyword_matching: false,
|
||||
embedding_model: undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
accessToken="token"
|
||||
userRole="Admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(/Escalation Keywords/i);
|
||||
fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching"));
|
||||
await user.click(screen.getByRole("button", { name: /add keyword rule/i }));
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
|
||||
|
||||
await user.type(
|
||||
within(screen.getByText("Keywords 2").closest("div") as HTMLElement).getByRole("combobox"),
|
||||
"chargeback{enter}",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeEnabled();
|
||||
expect(screen.queryByText("At least one keyword is required")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal classifier context window", () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Form, Button, Select as AntdSelect } from "antd";
|
||||
import { Modal, Form, Button, Select as AntdSelect, Tooltip } from "antd";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
|
||||
import { normalizeTierModels } from "../add_model/complexity_router_tiers";
|
||||
import { isComplexityRouter } from "../add_model/auto_router_strategies";
|
||||
import { getSemanticConfigError } from "../add_model/build_complexity_router_config";
|
||||
import { getKeywordTierRulesError, getSemanticConfigError } from "../add_model/build_complexity_router_config";
|
||||
import { KeywordTierRule } from "../add_model/KeywordTierRules";
|
||||
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
|
||||
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
|
||||
|
|
@ -118,8 +118,8 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
}),
|
||||
...(value.return_raw_model_name && { return_raw_model_name: true }),
|
||||
...(keywordMatching && {
|
||||
// Mirrors buildComplexityRouterConfig: rules only when non-empty (the backend rejects
|
||||
// an empty rule with a 400), escalation keywords always, semantic trio only when on.
|
||||
// Mirrors buildComplexityRouterConfig: the key only when there is a rule to write,
|
||||
// escalation keywords always, semantic trio only when on.
|
||||
...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
|
||||
escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean),
|
||||
...(keywordMatching.semanticMatchingEnabled && {
|
||||
|
|
@ -145,6 +145,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [showCustomDefaultModel, setShowCustomDefaultModel] = useState<boolean>(false);
|
||||
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
|
||||
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
|
||||
|
|
@ -158,6 +159,15 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
});
|
||||
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
|
||||
|
||||
// Mirrors the create form: the button says why it is unavailable and disables on the same
|
||||
// answer. Tiers use this modal's own rule, which allows a partly filled router, so an edit that
|
||||
// is legal today stays legal.
|
||||
const submitBlockedReason = !isComplexityRouterModel
|
||||
? null
|
||||
: (Object.values(complexityRouterConfig.tiers).every((models) => models.length === 0)
|
||||
? "Please select at least one model for a complexity tier"
|
||||
: null) ?? getKeywordTierRulesError(keywordTierRules);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modelData) {
|
||||
initializeForm();
|
||||
|
|
@ -295,24 +305,29 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
if (isComplexityRouterModel) {
|
||||
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
|
||||
if (Object.values(tiers).every((models) => models.length === 0)) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationsManager.fromBackend("Please select at least one model for a complexity tier");
|
||||
return;
|
||||
}
|
||||
if (classifier_type === "llm" && !classifier_llm_config?.model) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationsManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
|
||||
return;
|
||||
}
|
||||
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
|
||||
// semantic_keyword_matching without an embedding model or keyword rules
|
||||
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
|
||||
// an inline message.
|
||||
// Same guards the create form applies (add_auto_router_tab.tsx). The backend rejects a
|
||||
// keyword rule with no keyword, and semantic_keyword_matching without an embedding model
|
||||
// or keyword rules (complexity_router/config.py), so without these a save fails as a raw
|
||||
// 400 instead of an inline message.
|
||||
const keywordRulesError = getKeywordTierRulesError(keywordTierRules);
|
||||
if (keywordRulesError) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationsManager.fromBackend(keywordRulesError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
|
||||
// semantic_keyword_matching without an embedding model or keyword rules
|
||||
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
|
||||
// an inline message.
|
||||
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
|
||||
if (semanticError) {
|
||||
setShowValidationErrors(true);
|
||||
NotificationsManager.fromBackend(semanticError);
|
||||
return;
|
||||
}
|
||||
|
|
@ -410,9 +425,11 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
<Button key="cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" loading={loading} onClick={handleSubmit}>
|
||||
Save Changes
|
||||
</Button>,
|
||||
<Tooltip key="submit" title={submitBlockedReason}>
|
||||
<Button loading={loading} disabled={submitBlockedReason !== null} onClick={handleSubmit}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
]}
|
||||
width={1000}
|
||||
destroyOnHidden
|
||||
|
|
@ -436,6 +453,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<ComplexityRouterConfig
|
||||
showValidationErrors={showValidationErrors}
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={(config) => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import type {
|
|||
CoordinationRedisTestResponse,
|
||||
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
|
||||
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
|
||||
import { createApiClient, deriveErrorMessage } from "@/lib/http/client";
|
||||
import { createApiClient, deriveErrorMessage, unwrapProxyErrorMessage } from "@/lib/http/client";
|
||||
import { resolveApiBase } from "@/lib/http/resolveApiBase";
|
||||
import {
|
||||
registerAuthHeaderNameGetter,
|
||||
|
|
@ -2643,7 +2643,7 @@ export const teamUpdateCall = async (
|
|||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
NotificationsManager.fromBackend("Failed to update team settings: " + errorData);
|
||||
NotificationsManager.fromBackend("Failed to update team settings: " + unwrapProxyErrorMessage(errorData));
|
||||
throw new Error(errorData);
|
||||
}
|
||||
const data = (await response.json()) as { data: Team; team_id: string };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import * as networking from "@/components/networking";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
|
@ -26,6 +27,10 @@ vi.mock("@/components/utils/dataUtils", () => ({
|
|||
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
|
||||
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useAllProxyModels: vi.fn(),
|
||||
}));
|
||||
|
|
@ -220,6 +225,7 @@ describe("TeamInfoView", () => {
|
|||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
|
||||
|
||||
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
|
||||
|
|
@ -893,6 +899,137 @@ describe("TeamInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("metadata key-value editing", () => {
|
||||
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
const teamNameElements = screen.queryAllByText("Test Team");
|
||||
expect(teamNameElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("prefills pairs from team metadata, hides UI-managed keys, and round-trips typed values on save", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
metadata: {
|
||||
department: "research",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
|
||||
guardrails: ["g1"],
|
||||
disable_global_guardrails: false,
|
||||
model_tpm_limit: { "gpt-4": 100 },
|
||||
},
|
||||
models: ["gpt-4"],
|
||||
}),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
const keyValues = screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value);
|
||||
expect(keyValues).toEqual(["department", "tier", "beta", "config"]);
|
||||
const valueValues = screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value);
|
||||
expect(valueValues).toEqual(["research", "3", "true", '{"region":"us"}']);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const updateArg = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
|
||||
expect(updateArg.metadata).toMatchObject({
|
||||
department: "research",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
|
||||
});
|
||||
expect(updateArg.metadata).not.toHaveProperty("model_tpm_limit");
|
||||
expect(updateArg.model_tpm_limit).toEqual({ "gpt-4": 100 });
|
||||
});
|
||||
|
||||
it("includes a newly added pair in the team update", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"] }));
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
|
||||
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({ cost_center: "eng-1" });
|
||||
});
|
||||
|
||||
it("should keep declared keys as ordinary prefilled rows and submit the edited value", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({
|
||||
data: [
|
||||
{ key: "cost_center", label: "Cost Center" },
|
||||
{ key: "app_name", label: "Application Name" },
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
metadata: { cost_center: "CC-OLD", department: "research" },
|
||||
models: ["gpt-4"],
|
||||
}),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"department",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
expect(screen.getAllByPlaceholderText("Value")[0]).toHaveValue("CC-OLD");
|
||||
|
||||
await user.clear(screen.getAllByPlaceholderText("Value")[0]);
|
||||
await user.type(screen.getAllByPlaceholderText("Value")[0], "CC-NEW");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({
|
||||
cost_center: "CC-NEW",
|
||||
department: "research",
|
||||
app_name: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("model aliases", () => {
|
||||
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import { CheckIcon, CopyIcon } from "lucide-react";
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import MetadataKeyValueFields, {
|
||||
metadataObjectToPairs,
|
||||
metadataPairsToObject,
|
||||
} from "../common_components/MetadataKeyValueFields";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import ModelAliasManager from "../common_components/ModelAliasManager";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
|
|
@ -66,6 +71,18 @@ import {
|
|||
import TeamMembersComponent from "./TeamMemberTab";
|
||||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
|
||||
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
|
||||
"logging",
|
||||
"secret_manager_settings",
|
||||
"soft_budget_alerting_emails",
|
||||
"model_tpm_limit",
|
||||
"model_rpm_limit",
|
||||
"allowed_passthrough_routes",
|
||||
"guardrails",
|
||||
"opted_out_global_guardrails",
|
||||
"disable_global_guardrails",
|
||||
]);
|
||||
|
||||
export interface TeamMembership {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
|
|
@ -203,6 +220,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const { userRole, userId } = useAuthorized();
|
||||
const { data: userOrganizations = [] } = useOrganizations();
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Check if user is org admin for this team's organization
|
||||
|
|
@ -461,16 +479,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
if (!accessToken) return;
|
||||
setIsTeamSaving(true);
|
||||
|
||||
let parsedMetadata = {};
|
||||
try {
|
||||
const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {};
|
||||
// Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately
|
||||
const { soft_budget_alerting_emails, ...rest } = rawMetadata;
|
||||
parsedMetadata = rest;
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend("Invalid JSON in metadata field");
|
||||
return;
|
||||
}
|
||||
const parsedMetadata = metadataPairsToObject(values.metadata);
|
||||
|
||||
let secretManagerSettings: Record<string, any> | undefined;
|
||||
if (typeof values.secret_manager_settings === "string") {
|
||||
|
|
@ -980,21 +989,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
|
||||
? info.metadata.soft_budget_alerting_emails.join(", ")
|
||||
: "",
|
||||
metadata: info.metadata
|
||||
? JSON.stringify(
|
||||
(({
|
||||
logging,
|
||||
secret_manager_settings,
|
||||
soft_budget_alerting_emails,
|
||||
model_tpm_limit,
|
||||
model_rpm_limit,
|
||||
allowed_passthrough_routes,
|
||||
...rest
|
||||
}) => rest)(info.metadata),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
: "",
|
||||
metadata: metadataObjectToPairs(info.metadata, UI_MANAGED_METADATA_KEYS),
|
||||
logging_settings: info.metadata?.logging || [],
|
||||
secret_manager_settings: info.metadata?.secret_manager_settings
|
||||
? JSON.stringify(info.metadata.secret_manager_settings, null, 2)
|
||||
|
|
@ -1170,6 +1165,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
|
||||
>
|
||||
<MetadataKeyValueFields
|
||||
form={form}
|
||||
schemaFields={teamMetadataSchemaFields}
|
||||
schemaLoading={isTeamMetadataSchemaLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Model-Specific Rate Limits"
|
||||
tooltip="Set per-model TPM/RPM limits that apply across the whole team."
|
||||
|
|
@ -1493,10 +1499,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={10} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="sticky z-10 bg-white p-4 pr-0 border-t border-gray-200 -bottom-6 -inset-x-6">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<Button onClick={() => setIsEditing(false)} disabled={isTeamSaving}>
|
||||
|
|
|
|||
|
|
@ -106,8 +106,6 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin_viewer":
|
||||
|
|
|
|||
|
|
@ -76,6 +76,126 @@ describe("PrettyMessagesView", () => {
|
|||
expect(modelElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders a Responses API log, whose body uses input/output instead of messages/choices", () => {
|
||||
const request = {
|
||||
model: "gpt-5.6",
|
||||
input: [{ role: "user", content: "Reply with exactly: hello from responses api" }],
|
||||
};
|
||||
const response = {
|
||||
output: [
|
||||
{
|
||||
id: "msg_070989277645d4ae",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
status: "completed",
|
||||
content: [{ text: "hello from responses api", type: "output_text", annotations: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("Reply with exactly: hello from responses api")).toBeInTheDocument();
|
||||
expect(screen.getByText("hello from responses api")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Responses API tool call, whose output item is a function_call", () => {
|
||||
const request = {
|
||||
model: "gpt-5.6",
|
||||
input: [{ role: "user", content: "What is the weather in San Francisco? Use the tool." }],
|
||||
};
|
||||
const response = {
|
||||
output: [
|
||||
{
|
||||
id: "fc_08edf6c2312f1485",
|
||||
name: "get_weather",
|
||||
type: "function_call",
|
||||
status: "completed",
|
||||
call_id: "call_AtO0J9eNy5jgECXzBicMJM8W",
|
||||
arguments: '{"city":"San Francisco"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("What is the weather in San Francisco? Use the tool.")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_weather")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders instructions as the system turn and a bare string input", () => {
|
||||
const request = { model: "gpt-5.6", instructions: "You are terse.", input: "Say A" };
|
||||
const response = {
|
||||
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "A" }] }],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("You are terse.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Say A")).toBeInTheDocument();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips reasoning output items rather than rendering them as empty turns", () => {
|
||||
const request = { input: [{ role: "user", content: "Think then answer" }] };
|
||||
const response = {
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", summary: [] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "answered" }] },
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("answered")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Responses API follow-up turn carrying a prior function_call and its output", () => {
|
||||
const request = {
|
||||
input: [
|
||||
{ role: "user", content: "What is the weather in San Francisco? Use the tool." },
|
||||
{
|
||||
type: "function_call",
|
||||
name: "get_weather",
|
||||
call_id: "call_AtO0J9eNy5jgECXzBicMJM8W",
|
||||
arguments: '{"city":"San Francisco"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_AtO0J9eNy5jgECXzBicMJM8W", output: '{"temp":18}' },
|
||||
],
|
||||
};
|
||||
const response = {
|
||||
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "It is 18 degrees." }] }],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("It is 18 degrees.")).toBeInTheDocument();
|
||||
expect(screen.getByText('{"temp":18}')).toBeInTheDocument();
|
||||
expect(screen.getByText("TOOL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps the developer and legacy function roles onto the roles the drawer renders", () => {
|
||||
const request = {
|
||||
messages: [
|
||||
{ role: "developer", content: "Stay terse." },
|
||||
{ role: "user", content: "Weather?" },
|
||||
{ role: "function", name: "get_weather", content: '{"temp":18}' },
|
||||
],
|
||||
};
|
||||
const response = { choices: [{ message: { role: "assistant", content: "18 degrees." } }] };
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("Stay terse.")).toBeInTheDocument();
|
||||
expect(screen.getByText("TOOL")).toBeInTheDocument();
|
||||
expect(screen.queryByText("FUNCTION")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still reports missing output when a Responses API log has an empty output array", () => {
|
||||
const request = { input: [{ role: "user", content: "Hello" }] };
|
||||
|
||||
render(<PrettyMessagesView request={request} response={{ output: [] }} />);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
expect(screen.getByText("No response data available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render standard view when response has results but no realtime events", () => {
|
||||
const request = {
|
||||
messages: [{ role: "user", content: "Test" }],
|
||||
|
|
|
|||
|
|
@ -2,17 +2,29 @@
|
|||
* Type definitions for pretty messages view
|
||||
*/
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export interface ParsedMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
export type RequestPayload =
|
||||
| { kind: "chat"; messages: readonly unknown[] }
|
||||
| { kind: "responses"; instructions: string; input: string | readonly unknown[] }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export type ResponsePayload =
|
||||
| { kind: "chat"; choices: readonly unknown[] }
|
||||
| { kind: "responses"; output: readonly unknown[] }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParsedMessages {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
* Utility functions for parsing and formatting messages for pretty view
|
||||
*/
|
||||
|
||||
import { ParsedMessage, ParsedMessages, RoleStyle } from "./prettyMessagesTypes";
|
||||
import {
|
||||
MessageRole,
|
||||
ParsedMessage,
|
||||
ParsedMessages,
|
||||
RequestPayload,
|
||||
ResponsePayload,
|
||||
RoleStyle,
|
||||
ToolCall,
|
||||
} from "./prettyMessagesTypes";
|
||||
|
||||
/**
|
||||
* Role color styles for message cards - minimal, professional design
|
||||
|
|
@ -35,102 +43,188 @@ export const ROLE_STYLES: Record<string, RoleStyle> = {
|
|||
},
|
||||
};
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is UnknownRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const ROLES: readonly MessageRole[] = ["system", "user", "assistant", "tool"];
|
||||
|
||||
const toRole = (value: unknown, fallback: MessageRole): MessageRole => {
|
||||
if (value === "developer") return "system";
|
||||
if (value === "function") return "tool";
|
||||
return ROLES.includes(value as MessageRole) ? (value as MessageRole) : fallback;
|
||||
};
|
||||
|
||||
const classifyRequest = (request: unknown): RequestPayload => {
|
||||
if (Array.isArray(request)) return { kind: "chat", messages: request };
|
||||
if (!isRecord(request)) return { kind: "unknown" };
|
||||
if (Array.isArray(request.messages)) return { kind: "chat", messages: request.messages };
|
||||
const { input } = request;
|
||||
if (typeof input === "string" || Array.isArray(input)) {
|
||||
return { kind: "responses", instructions: asString(request.instructions), input };
|
||||
}
|
||||
return { kind: "unknown" };
|
||||
};
|
||||
|
||||
const classifyResponse = (response: unknown): ResponsePayload => {
|
||||
if (!isRecord(response)) return { kind: "unknown" };
|
||||
if (Array.isArray(response.choices)) return { kind: "chat", choices: response.choices };
|
||||
if (Array.isArray(response.output)) return { kind: "responses", output: response.output };
|
||||
return { kind: "unknown" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse request messages and response message from log data
|
||||
*/
|
||||
export const parseMessages = (request: any, response: any): ParsedMessages => {
|
||||
// Parse request messages. `request` is either the raw request body
|
||||
// ({ messages: [...] }) or, when prompts come from cold storage, the bare
|
||||
// messages array itself.
|
||||
const requestMessages: ParsedMessage[] = [];
|
||||
export const parseMessages = (request: unknown, response: unknown): ParsedMessages => ({
|
||||
requestMessages: parseRequestMessages(classifyRequest(request)),
|
||||
responseMessage: parseResponseMessage(classifyResponse(response)),
|
||||
});
|
||||
|
||||
const requestMessageList = Array.isArray(request)
|
||||
? request
|
||||
: Array.isArray(request?.messages)
|
||||
? request.messages
|
||||
: [];
|
||||
|
||||
requestMessageList.forEach((msg: any) => {
|
||||
requestMessages.push({
|
||||
role: msg.role || "user",
|
||||
content: parseMessageContent(msg.content),
|
||||
toolCallId: msg.tool_call_id,
|
||||
});
|
||||
});
|
||||
|
||||
// Parse response message
|
||||
let responseMessage: ParsedMessage | null = null;
|
||||
const responseMsg = response?.choices?.[0]?.message;
|
||||
|
||||
if (responseMsg) {
|
||||
responseMessage = {
|
||||
role: responseMsg.role || "assistant",
|
||||
content: responseMsg.content || "",
|
||||
toolCalls: parseToolCalls(responseMsg.tool_calls),
|
||||
};
|
||||
const parseRequestMessages = (payload: RequestPayload): ParsedMessage[] => {
|
||||
switch (payload.kind) {
|
||||
case "chat":
|
||||
return payload.messages.map(parseChatMessage);
|
||||
case "responses": {
|
||||
const instructions: ParsedMessage[] = payload.instructions
|
||||
? [{ role: "system", content: payload.instructions }]
|
||||
: [];
|
||||
const input: ParsedMessage[] =
|
||||
typeof payload.input === "string"
|
||||
? [{ role: "user", content: payload.input }]
|
||||
: payload.input.flatMap(parseResponsesInputItem);
|
||||
return [...instructions, ...input];
|
||||
}
|
||||
case "unknown":
|
||||
return [];
|
||||
}
|
||||
|
||||
return { requestMessages, responseMessage };
|
||||
};
|
||||
|
||||
const parseResponseMessage = (payload: ResponsePayload): ParsedMessage | null => {
|
||||
switch (payload.kind) {
|
||||
case "chat": {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord(choice) ? choice.message : undefined;
|
||||
if (!isRecord(message)) return null;
|
||||
return {
|
||||
role: toRole(message.role, "assistant"),
|
||||
content: parseMessageContent(message.content),
|
||||
toolCalls: parseChatToolCalls(message.tool_calls),
|
||||
};
|
||||
}
|
||||
case "responses": {
|
||||
const content = payload.output
|
||||
.filter((item): item is UnknownRecord => isRecord(item) && item.type === "message")
|
||||
.map((item) => parseMessageContent(item.content))
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n");
|
||||
const toolCalls = payload.output.filter(isResponsesFunctionCall).map(parseResponsesFunctionCall);
|
||||
if (content.length === 0 && toolCalls.length === 0) return null;
|
||||
return { role: "assistant", content, toolCalls: toolCalls.length > 0 ? toolCalls : undefined };
|
||||
}
|
||||
case "unknown":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseChatMessage = (message: unknown): ParsedMessage => {
|
||||
if (!isRecord(message)) return { role: "user", content: parseMessageContent(message) };
|
||||
return {
|
||||
role: toRole(message.role, "user"),
|
||||
content: parseMessageContent(message.content),
|
||||
toolCalls: parseChatToolCalls(message.tool_calls),
|
||||
toolCallId: typeof message.tool_call_id === "string" ? message.tool_call_id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseResponsesInputItem = (item: unknown): ParsedMessage[] => {
|
||||
if (typeof item === "string") return [{ role: "user", content: item }];
|
||||
if (!isRecord(item)) return [];
|
||||
if (item.type === "function_call") {
|
||||
return [{ role: "assistant", content: "", toolCalls: [parseResponsesFunctionCall(item)] }];
|
||||
}
|
||||
if (item.type === "function_call_output") {
|
||||
return [{ role: "tool", content: parseMessageContent(item.output), toolCallId: asString(item.call_id) }];
|
||||
}
|
||||
if (item.type === "reasoning") return [];
|
||||
if ("role" in item || "content" in item) {
|
||||
return [{ role: toRole(item.role, "user"), content: parseMessageContent(item.content) }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isResponsesFunctionCall = (item: unknown): item is UnknownRecord =>
|
||||
isRecord(item) && item.type === "function_call";
|
||||
|
||||
const parseResponsesFunctionCall = (item: UnknownRecord): ToolCall => ({
|
||||
id: asString(item.call_id) || asString(item.id),
|
||||
name: asString(item.name) || "unknown",
|
||||
arguments: parseToolArguments(item.arguments),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse message content - handle strings and content arrays (for vision, etc.)
|
||||
*/
|
||||
const parseMessageContent = (content: any): string => {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
// Handle content arrays (vision API format)
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item.type === "text") return item.text;
|
||||
if (item.type === "image_url") return "[Image]";
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Fallback to JSON string for complex content
|
||||
const parseMessageContent = (content: unknown): string => {
|
||||
if (typeof content === "string") return content;
|
||||
if (content === null || content === undefined) return "";
|
||||
if (Array.isArray(content)) return content.map(parseContentPart).join("\n");
|
||||
return JSON.stringify(content);
|
||||
};
|
||||
|
||||
const parseContentPart = (part: unknown): string => {
|
||||
if (typeof part === "string") return part;
|
||||
if (!isRecord(part)) return JSON.stringify(part);
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
case "input_text":
|
||||
case "output_text":
|
||||
return asString(part.text);
|
||||
case "refusal":
|
||||
return asString(part.refusal);
|
||||
case "image_url":
|
||||
case "input_image":
|
||||
return "[Image]";
|
||||
case "input_file":
|
||||
return "[File]";
|
||||
case "input_audio":
|
||||
return "[Audio]";
|
||||
default:
|
||||
return JSON.stringify(part);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool calls from response message
|
||||
*/
|
||||
const parseToolCalls = (
|
||||
toolCalls: any[],
|
||||
):
|
||||
| Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}>
|
||||
| undefined => {
|
||||
if (!toolCalls || !Array.isArray(toolCalls)) return undefined;
|
||||
|
||||
return toolCalls.map((tc) => ({
|
||||
id: tc.id || "",
|
||||
name: tc.function?.name || "unknown",
|
||||
arguments: parseToolArguments(tc.function?.arguments),
|
||||
}));
|
||||
const parseChatToolCalls = (toolCalls: unknown): ToolCall[] | undefined => {
|
||||
if (!Array.isArray(toolCalls)) return undefined;
|
||||
return toolCalls.map((toolCall) => {
|
||||
const call = isRecord(toolCall) ? toolCall : {};
|
||||
const fn = isRecord(call.function) ? call.function : {};
|
||||
return {
|
||||
id: asString(call.id),
|
||||
name: asString(fn.name) || "unknown",
|
||||
arguments: parseToolArguments(fn.arguments),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool arguments - handle both string and object formats
|
||||
*/
|
||||
const parseToolArguments = (args: any): Record<string, any> => {
|
||||
const parseToolArguments = (args: unknown): Record<string, unknown> => {
|
||||
if (!args) return {};
|
||||
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
return isRecord(parsed) ? parsed : { raw: args };
|
||||
} catch {
|
||||
return { raw: args };
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
return isRecord(args) ? args : {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createApiClient, ApiError, deriveErrorMessage } from "./client";
|
||||
import {
|
||||
createApiClient,
|
||||
ApiError,
|
||||
deriveErrorMessage,
|
||||
extractProxyErrorMessage,
|
||||
unwrapProxyErrorMessage,
|
||||
} from "./client";
|
||||
|
||||
const okResponse = (data: unknown): Response =>
|
||||
({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response;
|
||||
|
|
@ -126,3 +132,41 @@ describe("deriveErrorMessage", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapProxyErrorMessage", () => {
|
||||
it("should unwrap the proxy's stringified python dict message", () => {
|
||||
expect(
|
||||
unwrapProxyErrorMessage("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
|
||||
).toBe("Cost center CC-9999 is not recognized. Contact the FinOps team.");
|
||||
});
|
||||
|
||||
it("should unwrap the full JSON error envelope down to the inner message", () => {
|
||||
const envelope = JSON.stringify({
|
||||
error: {
|
||||
message: "{'error': 'cost_center is required in team metadata. Contact the FinOps team.'}",
|
||||
type: "internal_server_error",
|
||||
param: "None",
|
||||
code: "400",
|
||||
},
|
||||
});
|
||||
expect(unwrapProxyErrorMessage(envelope)).toBe(
|
||||
"cost_center is required in team metadata. Contact the FinOps team.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return plain messages and unparseable input unchanged", () => {
|
||||
expect(unwrapProxyErrorMessage("Failed to fetch")).toBe("Failed to fetch");
|
||||
expect(unwrapProxyErrorMessage("{}")).toBe("{}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractProxyErrorMessage", () => {
|
||||
it("should unwrap an Error's message without the error name prefix", () => {
|
||||
const error = new ApiError("{'error': 'Cost center CC-9999 is not recognized.'}", 400, {});
|
||||
expect(extractProxyErrorMessage(error)).toBe("Cost center CC-9999 is not recognized.");
|
||||
});
|
||||
|
||||
it("should stringify non-Error inputs", () => {
|
||||
expect(extractProxyErrorMessage("plain failure")).toBe("plain failure");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,38 @@ export const deriveErrorMessage = (errorData: any): string => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The proxy serializes HTTPException details as the string form of a Python dict,
|
||||
* so a rejection reaches the UI as "{'error': 'actual message'}" (or that string
|
||||
* nested inside the JSON error envelope). Unwraps to the actual message; returns
|
||||
* the input unchanged when it does not match a known wrapper shape.
|
||||
*/
|
||||
export const unwrapProxyErrorMessage = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const derived = deriveErrorMessage(parsed);
|
||||
if (typeof derived === "string" && derived !== trimmed) {
|
||||
return unwrapProxyErrorMessage(derived);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const pythonDictMatch = trimmed.match(/^\{'error':\s*(['"])([\s\S]*)\1\}$/);
|
||||
if (pythonDictMatch) {
|
||||
return pythonDictMatch[2];
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export const extractProxyErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return unwrapProxyErrorMessage(error.message);
|
||||
}
|
||||
return unwrapProxyErrorMessage(String(error));
|
||||
};
|
||||
|
||||
export interface ApiClientConfig {
|
||||
/** Resolves the API origin at call time (it can change at runtime). */
|
||||
getBaseUrl: () => string;
|
||||
|
|
|
|||
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -13640,6 +13640,31 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/metadata_schema": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Team Metadata Schema
|
||||
* @description Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
|
||||
*
|
||||
* The UI uses this to prepopulate the team metadata form with the declared
|
||||
* keys. Returns an empty ``fields`` list when no schema is configured. This
|
||||
* schema is advisory; server-side enforcement stays with
|
||||
* ``custom_team_metadata_validate``.
|
||||
*/
|
||||
get: operations["get_team_metadata_schema_team_metadata_schema_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/model/add": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -32024,6 +32049,27 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* TeamMetadataFieldSchema
|
||||
* @description One declared team metadata field from ``general_settings.team_metadata_schema``.
|
||||
*
|
||||
* Advisory only: the UI uses it to prepopulate the team metadata form.
|
||||
* Enforcement stays with ``custom_team_metadata_validate``.
|
||||
*/
|
||||
TeamMetadataFieldSchema: {
|
||||
/** Key */
|
||||
key: string;
|
||||
/** Label */
|
||||
label?: string | null;
|
||||
};
|
||||
/**
|
||||
* TeamMetadataSchemaResponse
|
||||
* @description Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured.
|
||||
*/
|
||||
TeamMetadataSchemaResponse: {
|
||||
/** Fields */
|
||||
fields: components["schemas"]["TeamMetadataFieldSchema"][];
|
||||
};
|
||||
/**
|
||||
* TeamModelAddRequest
|
||||
* @description Request to add models to a team
|
||||
|
|
@ -51069,6 +51115,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_team_metadata_schema_team_metadata_schema_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TeamMetadataSchemaResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
team_model_add_team_model_add_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ export const formatUserRole = (userRole: string): string => {
|
|||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin":
|
||||
return "Admin";
|
||||
case "proxy_admin_viewer":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue