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:
Tin Chi Lo 2026-08-03 19:29:25 -07:00
commit 91513f38bc
57 changed files with 6034 additions and 631 deletions

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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)

View file

@ -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."

View 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}")

View file

@ -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(

View file

@ -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

View file

@ -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,

View 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),
)

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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, ...]

View file

@ -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,

View 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()

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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
)

View 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"
)

View file

@ -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

View file

@ -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();
});
});

View file

@ -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&apos;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" })}

View file

@ -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");
});
});

View file

@ -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,
});
};

View file

@ -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);

View file

@ -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();

View file

@ -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"

View file

@ -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();

View file

@ -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 }}>

View file

@ -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);

View file

@ -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>

View file

@ -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,

View file

@ -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 {

View file

@ -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 [];

View file

@ -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",
]);
});
});
});

View file

@ -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;

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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) => {

View file

@ -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 };

View file

@ -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(() => {

View file

@ -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}>

View file

@ -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":

View file

@ -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" }],

View file

@ -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 {

View file

@ -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 : {};
};

View file

@ -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");
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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":