mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(team): custom metadata validation hook for team create and update (#33353)
* feat(team): custom metadata validation hook for team create and update
Operators can point general_settings.custom_team_metadata_validate at an
async Python function that validates team metadata before /team/new,
POST /team/update, and PATCH /team/{team_id} commit their writes. The
hook receives the metadata that will actually be written (the merged
result on PATCH) plus the stored metadata and requester context, and
fails closed: a rejected value returns the function's own message as a
400 while any exception or timeout blocks the write with a configurable
generic message as a 503. Premium-gated like enforced_params.
* fix(team): validate metadata before model alias writes and strip system keys from validator input
Review follow-ups on the team metadata validation hook: run the validator
before the model_aliases table insert so a rejected create leaves no
orphaned model rows, strip system-managed keys from existing_metadata so
the validator sees symmetric input on both fields, and accept class
instances exposing an async __call__ as validators. Adds a three-way
validator implementation matrix (allowlist function, HTTP-service-backed
function, immutability-enforcing class instance) driven through the real
create, update, and patch endpoints, including an HTTP stub service and
outage coverage.
* test(team): run the metadata validation matrix against the DB-backed proxy in CI
Adds the validator matrix to the proxy_store_model_in_db_tests CircleCI
job so every scenario runs full e2e against a Postgres-backed proxy. The
proxy config registers a dispatching validator that routes each request
to one of the three implementations via a metadata key and accepts
anything that does not opt in, keeping the rest of the suite unaffected.
CI starts a stand-in cost center service on the host for the HTTP-backed
implementation, reached from the container via host.docker.internal, and
the outage path targets a closed port to prove the fail-closed 503
without stopping services.
* feat(ui): edit team metadata as key-value pairs in team create and edit forms
The team create and edit forms asked for metadata as a raw JSON blob in a
textarea buried under Additional Settings. Both forms now render a key-value
pair editor directly under the TPM/RPM limit fields, backed by a shared
MetadataKeyValueFields component. Values round-trip losslessly: non-string
values display as JSON and parse back to their typed form on save, and
JSON-ambiguous strings are quoted so their type survives the trip. The edit
form hides UI-managed keys (logging, guardrails, model rate limits, etc.)
that dedicated controls already own and re-add on save.
* fix(ui): explain typed JSON parsing in the team metadata help text
* feat(team): schema-driven metadata fields from team_metadata_schema config
* refactor(team): render schema metadata fields as locked key-value rows, drop allowed_values
* refactor(team): schema fields reduce to key and label, tag-rendered keys, clean rejection toasts
* refactor(ui): prepopulate declared metadata keys as ordinary key-value rows
* fix(team): let non-admin dashboard users read the team metadata schema
* test(proxy): pin timeout wiring, boundary, and error-message contracts for team metadata validation
* fix(proxy): use pooled async httpx client in the e2e team metadata validator example
* refactor(team): satisfy staging lint ratchets inherited by the merge
This commit is contained in:
parent
d4d0bf0acc
commit
cd87fee9c5
27 changed files with 2844 additions and 64 deletions
|
|
@ -163,6 +163,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:
|
||||
|
|
@ -2264,6 +2284,7 @@ jobs:
|
|||
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 +2304,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
|
||||
|
|
|
|||
|
|
@ -543,6 +543,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v2/team/list",
|
||||
"/organization/list",
|
||||
"/team/available",
|
||||
"/team/metadata_schema",
|
||||
"/user/info",
|
||||
"/v2/user/info",
|
||||
"/model/info",
|
||||
|
|
@ -609,6 +610,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/block",
|
||||
"/team/unblock",
|
||||
"/team/available",
|
||||
"/team/metadata_schema",
|
||||
"/team/permissions_list",
|
||||
"/team/permissions_update",
|
||||
"/team/permissions_bulk_update",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"""Example validator for `general_settings.custom_team_metadata_validate`.
|
||||
|
||||
Wire it up in the proxy config:
|
||||
|
||||
```yaml
|
||||
general_settings:
|
||||
custom_team_metadata_validate: custom_team_metadata_validate.validate_team_metadata
|
||||
team_metadata_validation_timeout: 5
|
||||
team_metadata_validation_error_message: "Validation service unavailable, contact your admin."
|
||||
```
|
||||
|
||||
Return `valid=False` with an `error_message` to reject the write with that
|
||||
message (HTTP 400). Raise any exception (for example, when the upstream
|
||||
validation service is unreachable) to fail closed with the generic
|
||||
`team_metadata_validation_error_message` (HTTP 503).
|
||||
"""
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
VALID_COST_CENTERS = frozenset({"CC-1001", "CC-1002", "CC-2001"})
|
||||
|
||||
|
||||
async def validate_team_metadata(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="Team metadata must include a cost_center. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in VALID_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
|
@ -7,4 +7,7 @@ model_list:
|
|||
|
||||
general_settings:
|
||||
store_model_in_db: true
|
||||
custom_team_metadata_validate: team_metadata_validator_e2e.validate_team_metadata
|
||||
team_metadata_validation_timeout: 5
|
||||
team_metadata_validation_error_message: "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."
|
||||
|
||||
|
|
|
|||
107
litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py
Normal file
107
litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Dispatching team metadata validator for the store_model_in_db e2e suite.
|
||||
|
||||
The suite runs one proxy with one config, so a single registered validator
|
||||
dispatches to one of three independent implementations chosen per request via
|
||||
the `_e2e_validator_impl` metadata key:
|
||||
|
||||
- `allowlist`: requires `cost_center` and checks it against a static set
|
||||
- `http`: POSTs the metadata to the cost center service at
|
||||
`TEAM_METADATA_VALIDATION_SERVICE_URL`; transport errors raise (fail closed)
|
||||
- `http_down`: like `http` but targets a closed port, proving the 503 path
|
||||
- `immutable`: requires `cost_center` and forbids changing it once set
|
||||
|
||||
A request whose metadata carries no `_e2e_validator_impl` key is accepted
|
||||
untouched, so the rest of the suite's team operations are unaffected. An
|
||||
unknown impl value raises, which the proxy converts to the fail-closed 503.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
|
||||
CLOSED_PORT_URL = "http://127.0.0.1:9/validate"
|
||||
|
||||
|
||||
async def _validate_allowlist(payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="cost_center is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in ALLOWED_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
async def _validate_via_http(payload: TeamMetadataValidationPayload, service_url: str) -> TeamMetadataValidationResult:
|
||||
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
|
||||
response = await client.post(
|
||||
service_url,
|
||||
json={ # mutable-ok: httpx serializes the request body from a plain dict
|
||||
"operation": payload.operation,
|
||||
"metadata": payload.metadata,
|
||||
},
|
||||
timeout=2.0,
|
||||
)
|
||||
body = response.json()
|
||||
if body.get("ok") is True:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=body.get("reason", "Rejected by the cost center service."),
|
||||
)
|
||||
|
||||
|
||||
class _ImmutableCostCenterValidator:
|
||||
def __init__(self, immutable_key: str = "cost_center") -> None:
|
||||
self.immutable_key = immutable_key
|
||||
|
||||
async def __call__(self, payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
|
||||
current = payload.metadata.get(self.immutable_key)
|
||||
if current is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if payload.operation == "update" and payload.existing_metadata is not None:
|
||||
prior = payload.existing_metadata.get(self.immutable_key)
|
||||
if prior is not None and prior != current:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=(
|
||||
f"{self.immutable_key} is immutable once set "
|
||||
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
|
||||
),
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
_IMMUTABLE_VALIDATOR = _ImmutableCostCenterValidator()
|
||||
|
||||
|
||||
async def validate_team_metadata(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
impl = payload.metadata.get("_e2e_validator_impl")
|
||||
if impl is None:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
if impl == "allowlist":
|
||||
return await _validate_allowlist(payload)
|
||||
if impl == "http":
|
||||
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", "http://localhost:9414/validate")
|
||||
return await _validate_via_http(payload, service_url)
|
||||
if impl == "http_down":
|
||||
return await _validate_via_http(payload, CLOSED_PORT_URL)
|
||||
if impl == "immutable":
|
||||
return await _IMMUTABLE_VALIDATOR(payload)
|
||||
raise ValueError(f"unknown _e2e_validator_impl: {impl}")
|
||||
|
|
@ -113,6 +113,10 @@ from litellm.proxy.management_helpers.object_permission_utils import (
|
|||
from litellm.proxy.management_helpers.team_member_permission_checks import (
|
||||
TeamMemberPermissionChecks,
|
||||
)
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
validate_team_metadata_if_configured,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
add_new_member,
|
||||
management_endpoint_wrapper,
|
||||
|
|
@ -147,6 +151,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
|
|||
TeamListResponse,
|
||||
TeamMemberAddResult,
|
||||
TeamMemberInfoResponse,
|
||||
TeamMetadataSchemaResponse,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
)
|
||||
|
||||
|
|
@ -1287,6 +1292,18 @@ async def new_team(
|
|||
|
||||
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
|
||||
|
||||
if isinstance(data.metadata, dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
|
||||
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata=data.metadata,
|
||||
existing_metadata=None,
|
||||
team_id=data.team_id,
|
||||
team_alias=data.team_alias,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
## ADD TO MODEL TABLE
|
||||
_model_id = None
|
||||
if data.model_aliases is not None and isinstance(data.model_aliases, dict):
|
||||
|
|
@ -1301,9 +1318,6 @@ async def new_team(
|
|||
|
||||
_model_id = model_dict.id
|
||||
|
||||
## Create Team Member Budget Table
|
||||
if isinstance(data.metadata, dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
|
||||
data_json = data.json()
|
||||
|
||||
## Handle Object Permission - MCP, Vector Stores etc.
|
||||
|
|
@ -1965,6 +1979,25 @@ async def update_team(
|
|||
if isinstance(updated_kv.get("metadata"), dict):
|
||||
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(updated_kv["metadata"])
|
||||
|
||||
if "metadata" in updated_kv:
|
||||
stored_metadata = (
|
||||
{ # mutable-ok: the validator payload's isinstance guard requires a plain dict
|
||||
key: value
|
||||
for key, value in existing_team_row.metadata.items()
|
||||
if key not in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS
|
||||
}
|
||||
if isinstance(existing_team_row.metadata, dict)
|
||||
else None
|
||||
)
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="update",
|
||||
metadata=updated_kv.get("metadata"),
|
||||
existing_metadata=stored_metadata,
|
||||
team_id=data.team_id,
|
||||
team_alias=data.team_alias if data.team_alias is not None else existing_team_row.team_alias,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# Check budget_duration and budget_reset_at
|
||||
_set_budget_reset_at(data, updated_kv)
|
||||
|
||||
|
|
@ -4179,6 +4212,24 @@ async def unblock_team(
|
|||
return record
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/metadata_schema",
|
||||
tags=["team management"], # mutable-ok: fastapi's decorator signature types tags as a list
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
response_model=TeamMetadataSchemaResponse,
|
||||
)
|
||||
async def get_team_metadata_schema():
|
||||
"""
|
||||
Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
|
||||
|
||||
The UI uses this to prepopulate the team metadata form with the declared
|
||||
keys. Returns an empty ``fields`` list when no schema is configured. This
|
||||
schema is advisory; server-side enforcement stays with
|
||||
``custom_team_metadata_validate``.
|
||||
"""
|
||||
return TeamMetadataSchemaResponse(fields=TEAM_METADATA_SCHEMA_REGISTRY.get())
|
||||
|
||||
|
||||
@router.get("/team/available")
|
||||
async def list_available_teams(
|
||||
http_request: Request,
|
||||
|
|
|
|||
193
litellm/proxy/management_helpers/team_metadata_validation.py
Normal file
193
litellm/proxy/management_helpers/team_metadata_validation.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Custom validation of team metadata on team create/update.
|
||||
|
||||
Operators point `general_settings.custom_team_metadata_validate` at an async
|
||||
Python function (loaded via `get_instance_fn`, like `custom_key_generate`).
|
||||
The function receives a `TeamMetadataValidationPayload` and returns a
|
||||
`TeamMetadataValidationResult`. The proxy awaits it before committing a team
|
||||
write and fails closed: a rejected value surfaces the function's own message
|
||||
(HTTP 400), while any raised exception or timeout blocks the write with a
|
||||
generic message (HTTP 503).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
TeamMetadataFieldSchema,
|
||||
)
|
||||
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS = 5.0
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE = (
|
||||
"Team metadata validation is currently unavailable, so the team was not saved. Contact your proxy admin."
|
||||
)
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE = "Team metadata failed validation."
|
||||
|
||||
|
||||
class TeamMetadataRequester(BaseModel):
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
user_role: str | None = None
|
||||
|
||||
|
||||
class TeamMetadataValidationPayload(BaseModel):
|
||||
operation: Literal["create", "update"]
|
||||
metadata: Mapping[str, JsonValue]
|
||||
existing_metadata: Mapping[str, JsonValue] | None = None
|
||||
team_id: str | None = None
|
||||
team_alias: str | None = None
|
||||
requester: TeamMetadataRequester
|
||||
|
||||
|
||||
class TeamMetadataValidationResult(BaseModel):
|
||||
valid: bool
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
_EMPTY_METADATA: Mapping[str, JsonValue] = MappingProxyType({})
|
||||
|
||||
|
||||
class TeamMetadataValidator(Protocol):
|
||||
def __call__(self, payload: TeamMetadataValidationPayload, /) -> Awaitable[TeamMetadataValidationResult]: ...
|
||||
|
||||
|
||||
class TeamMetadataValidatorRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._validator: TeamMetadataValidator | None = None
|
||||
|
||||
def set(self, validator: TeamMetadataValidator | None) -> None:
|
||||
self._validator = validator
|
||||
|
||||
def get(self) -> TeamMetadataValidator | None:
|
||||
return self._validator
|
||||
|
||||
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY = TeamMetadataValidatorRegistry()
|
||||
|
||||
_TEAM_METADATA_SCHEMA_ADAPTER: TypeAdapter[tuple[TeamMetadataFieldSchema, ...]] = TypeAdapter(
|
||||
tuple[TeamMetadataFieldSchema, ...]
|
||||
)
|
||||
|
||||
|
||||
def parse_team_metadata_schema(raw_schema: object) -> tuple[TeamMetadataFieldSchema, ...]:
|
||||
"""Parse ``general_settings.team_metadata_schema``; raises on a malformed schema so config load fails fast."""
|
||||
if raw_schema is None:
|
||||
return ()
|
||||
fields = _TEAM_METADATA_SCHEMA_ADAPTER.validate_python(raw_schema)
|
||||
keys = tuple(field.key for field in fields)
|
||||
duplicate_keys = sorted(frozenset(key for key in keys if keys.count(key) > 1))
|
||||
if duplicate_keys:
|
||||
raise ValueError(f"team_metadata_schema contains duplicate keys: {', '.join(duplicate_keys)}")
|
||||
return fields
|
||||
|
||||
|
||||
class TeamMetadataSchemaRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._fields: tuple[TeamMetadataFieldSchema, ...] = ()
|
||||
|
||||
def set(self, fields: tuple[TeamMetadataFieldSchema, ...]) -> None:
|
||||
self._fields = fields
|
||||
|
||||
def get(self) -> tuple[TeamMetadataFieldSchema, ...]:
|
||||
return self._fields
|
||||
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY = TeamMetadataSchemaRegistry()
|
||||
|
||||
|
||||
async def run_team_metadata_validation(
|
||||
validator: TeamMetadataValidator,
|
||||
payload: TeamMetadataValidationPayload,
|
||||
premium_user: bool,
|
||||
timeout_seconds: float,
|
||||
unavailable_message: str,
|
||||
) -> None:
|
||||
if premium_user is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": f"custom_team_metadata_validate is an Enterprise feature. {CommonProxyErrors.not_premium_user.value}"
|
||||
},
|
||||
)
|
||||
if not (
|
||||
inspect.iscoroutinefunction(validator) or inspect.iscoroutinefunction(getattr(validator, "__call__", None))
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": "custom_team_metadata_validate must be an async function"
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
raw_result = await asyncio.wait_for(validator(payload), timeout=timeout_seconds)
|
||||
result = TeamMetadataValidationResult.model_validate(raw_result)
|
||||
except Exception: # noqa: BLE001 # fail closed: any validator failure must block the team write
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"error": unavailable_message}, # mutable-ok: HTTPException.detail has no immutable form
|
||||
)
|
||||
|
||||
if not result.valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={ # mutable-ok: HTTPException.detail has no immutable form
|
||||
"error": result.error_message or DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _read_timeout_seconds(general_settings: Mapping[str, object]) -> float:
|
||||
raw_timeout = general_settings.get("team_metadata_validation_timeout")
|
||||
if isinstance(raw_timeout, (int, float)) and not isinstance(raw_timeout, bool) and raw_timeout > 0:
|
||||
return float(raw_timeout)
|
||||
return DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _read_unavailable_message(general_settings: Mapping[str, object]) -> str:
|
||||
raw_message = general_settings.get("team_metadata_validation_error_message")
|
||||
if isinstance(raw_message, str) and raw_message.strip():
|
||||
return raw_message
|
||||
return DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE
|
||||
|
||||
|
||||
async def validate_team_metadata_if_configured(
|
||||
operation: Literal["create", "update"],
|
||||
metadata: Mapping[str, JsonValue] | None,
|
||||
existing_metadata: Mapping[str, JsonValue] | None,
|
||||
team_id: str | None,
|
||||
team_alias: str | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
registry: TeamMetadataValidatorRegistry = TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
) -> None:
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user
|
||||
|
||||
validator = registry.get()
|
||||
if validator is None:
|
||||
return
|
||||
|
||||
payload = TeamMetadataValidationPayload(
|
||||
operation=operation,
|
||||
metadata=metadata if isinstance(metadata, dict) else _EMPTY_METADATA,
|
||||
existing_metadata=existing_metadata if isinstance(existing_metadata, dict) else None,
|
||||
team_id=team_id,
|
||||
team_alias=team_alias,
|
||||
requester=TeamMetadataRequester(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
user_email=user_api_key_dict.user_email,
|
||||
user_role=user_api_key_dict.user_role.value if user_api_key_dict.user_role is not None else None,
|
||||
),
|
||||
)
|
||||
await run_team_metadata_validation(
|
||||
validator=validator,
|
||||
payload=payload,
|
||||
premium_user=premium_user,
|
||||
timeout_seconds=_read_timeout_seconds(general_settings),
|
||||
unavailable_message=_read_unavailable_message(general_settings),
|
||||
)
|
||||
|
|
@ -461,6 +461,11 @@ from litellm.proxy.management_helpers.audit_logs import (
|
|||
create_audit_log_for_update,
|
||||
create_object_audit_log,
|
||||
)
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
from litellm.proxy.memory.memory_endpoints import router as memory_router
|
||||
from litellm.proxy.middleware.billable_request_metrics_middleware import (
|
||||
BillableRequestMetricsMiddleware,
|
||||
|
|
@ -783,6 +788,8 @@ def cleanup_router_config_variables():
|
|||
user_custom_auth_path = None
|
||||
user_custom_key_generate = None
|
||||
user_custom_key_update = None
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
user_custom_sso = None
|
||||
user_custom_ui_sso_sign_in_handler = None
|
||||
use_background_health_checks = None
|
||||
|
|
@ -3499,6 +3506,7 @@ _DB_OVERLAY_REMOTE_MODULE_STR_FIELDS: dict[str, tuple[str, ...]] = {
|
|||
"custom_auth",
|
||||
"custom_key_generate",
|
||||
"custom_key_update",
|
||||
"custom_team_metadata_validate",
|
||||
"custom_sso",
|
||||
"custom_ui_sso_sign_in_handler",
|
||||
),
|
||||
|
|
@ -4829,6 +4837,14 @@ class ProxyConfig:
|
|||
if custom_key_update is not None:
|
||||
user_custom_key_update = get_instance_fn(value=custom_key_update, config_file_path=config_file_path)
|
||||
|
||||
custom_team_metadata_validate = general_settings.get("custom_team_metadata_validate", None)
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(
|
||||
get_instance_fn(value=custom_team_metadata_validate, config_file_path=config_file_path)
|
||||
if custom_team_metadata_validate is not None
|
||||
else None
|
||||
)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema(general_settings.get("team_metadata_schema")))
|
||||
|
||||
custom_sso = general_settings.get("custom_sso", None)
|
||||
if custom_sso is not None:
|
||||
user_custom_sso = get_instance_fn(value=custom_sso, config_file_path=config_file_path)
|
||||
|
|
|
|||
|
|
@ -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, ...]
|
||||
|
|
|
|||
54
tests/store_model_in_db_tests/cost_center_service.py
Normal file
54
tests/store_model_in_db_tests/cost_center_service.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Stand-in cost center validation service for the team metadata e2e tests.
|
||||
|
||||
Accepts POST /validate with {"operation": ..., "metadata": {...}} and answers
|
||||
{"ok": true} or {"ok": false, "reason": ...} based on a static allowlist.
|
||||
GET /health answers 200 for the CI wait loop.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ALLOWED_COST_CENTERS = {"CC-1001", "CC-1002"}
|
||||
|
||||
|
||||
class CostCenterHandler(BaseHTTPRequestHandler):
|
||||
def _respond(self, status: int, body: dict) -> None:
|
||||
payload = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self._respond(200, {"status": "healthy"})
|
||||
return
|
||||
self._respond(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/validate":
|
||||
self._respond(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
cost_center = (body.get("metadata") or {}).get("cost_center")
|
||||
if cost_center is None:
|
||||
self._respond(200, {"ok": False, "reason": "cost_center missing per cost center service"})
|
||||
elif cost_center not in ALLOWED_COST_CENTERS:
|
||||
self._respond(200, {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"})
|
||||
else:
|
||||
self._respond(200, {"ok": True})
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=9414)
|
||||
args = parser.parse_args()
|
||||
print(f"cost center service listening on {args.host}:{args.port}")
|
||||
ThreadingHTTPServer((args.host, args.port), CostCenterHandler).serve_forever()
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"""E2E matrix for custom team metadata validation against the DB-backed proxy.
|
||||
|
||||
The proxy (see store_model_db_config.yaml) registers
|
||||
team_metadata_validator_e2e.validate_team_metadata, which dispatches per
|
||||
request to one of three independent implementations via the
|
||||
`_e2e_validator_impl` metadata key: a static allowlist function, an
|
||||
HTTP-backed function calling the cost center service started by CI, and an
|
||||
immutability-enforcing class instance. Metadata without the dispatch key is
|
||||
accepted untouched, so the rest of this suite is unaffected.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
PROXY_BASE_URL = os.getenv("PROXY_BASE_URL", "http://localhost:4000")
|
||||
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-1234")
|
||||
HEADERS = {"Authorization": f"Bearer {MASTER_KEY}", "Content-Type": "application/json"}
|
||||
|
||||
UNAVAILABLE_MESSAGE = "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."
|
||||
|
||||
IMPLS = ["allowlist", "http", "immutable"]
|
||||
|
||||
REQUIRED_MESSAGES = {
|
||||
"allowlist": "cost_center is required in team metadata",
|
||||
"http": "cost_center missing per cost center service",
|
||||
"immutable": "cost_center is required in team metadata",
|
||||
}
|
||||
UNKNOWN_MESSAGES = {
|
||||
"allowlist": "is not recognized",
|
||||
"http": "rejected by cost center service",
|
||||
}
|
||||
|
||||
|
||||
def _meta(impl, **fields):
|
||||
return {"_e2e_validator_impl": impl, **fields}
|
||||
|
||||
|
||||
def _create_team(metadata, team_id=None):
|
||||
body = {"team_alias": f"meta-validate-{uuid.uuid4().hex[:8]}"}
|
||||
if team_id is not None:
|
||||
body["team_id"] = team_id
|
||||
if metadata is not None:
|
||||
body["metadata"] = metadata
|
||||
return httpx.post(f"{PROXY_BASE_URL}/team/new", headers=HEADERS, json=body, timeout=30)
|
||||
|
||||
|
||||
def _patch_team(team_id, body):
|
||||
return httpx.patch(f"{PROXY_BASE_URL}/team/{team_id}", headers=HEADERS, json=body, timeout=30)
|
||||
|
||||
|
||||
def _post_update(team_id, body):
|
||||
return httpx.post(f"{PROXY_BASE_URL}/team/update", headers=HEADERS, json={"team_id": team_id, **body}, timeout=30)
|
||||
|
||||
|
||||
def _team_info(team_id):
|
||||
return httpx.get(f"{PROXY_BASE_URL}/team/info", headers=HEADERS, params={"team_id": team_id}, timeout=30)
|
||||
|
||||
|
||||
def _delete_team(team_id):
|
||||
httpx.post(f"{PROXY_BASE_URL}/team/delete", headers=HEADERS, json={"team_ids": [team_id]}, timeout=30)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_with_cost_center(request):
|
||||
impl = request.param
|
||||
team_id = f"meta-validate-{impl}-{uuid.uuid4().hex[:8]}"
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"), team_id=team_id)
|
||||
assert response.status_code == 200, response.text
|
||||
yield impl, team_id
|
||||
_delete_team(team_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_with_valid_cost_center_succeeds(impl):
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"))
|
||||
assert response.status_code == 200, response.text
|
||||
team_id = response.json()["team_id"]
|
||||
try:
|
||||
assert response.json()["metadata"]["cost_center"] == "CC-1001"
|
||||
finally:
|
||||
_delete_team(team_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_without_cost_center_is_rejected(impl):
|
||||
team_id = f"meta-validate-reject-{impl}-{uuid.uuid4().hex[:8]}"
|
||||
response = _create_team(metadata=_meta(impl), team_id=team_id)
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
info = _team_info(team_id)
|
||||
assert info.status_code == 404, "rejected create must not leave a team row behind"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("impl", IMPLS)
|
||||
def test_create_with_unknown_cost_center(impl):
|
||||
response = _create_team(metadata=_meta(impl, cost_center="CC-9999"))
|
||||
if impl == "immutable":
|
||||
assert response.status_code == 200, response.text
|
||||
_delete_team(response.json()["team_id"])
|
||||
return
|
||||
assert response.status_code == 400, response.text
|
||||
assert UNKNOWN_MESSAGES[impl] in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_changing_cost_center(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"cost_center": "CC-1002"}})
|
||||
if impl == "immutable":
|
||||
assert response.status_code == 400, response.text
|
||||
assert "immutable once set" in response.text
|
||||
info = _team_info(team_id).json()["team_info"]["metadata"]
|
||||
assert info["cost_center"] == "CC-1001", "blocked update must leave stored metadata intact"
|
||||
return
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["cost_center"] == "CC-1002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_unrelated_key_validates_merged_result(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"team_notes": "hello"}})
|
||||
assert response.status_code == 200, response.text
|
||||
merged = response.json()["metadata"]
|
||||
assert merged["cost_center"] == "CC-1001"
|
||||
assert merged["team_notes"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_patch_null_deleting_cost_center_is_rejected(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _patch_team(team_id, {"metadata": {"cost_center": None}})
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
info = _team_info(team_id).json()["team_info"]["metadata"]
|
||||
assert info["cost_center"] == "CC-1001"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_post_update_dropping_cost_center_is_rejected(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _post_update(team_id, {"metadata": _meta(impl, team_notes="only-notes")})
|
||||
assert response.status_code == 400, response.text
|
||||
assert REQUIRED_MESSAGES[impl] in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
|
||||
def test_update_without_metadata_skips_validation(team_with_cost_center):
|
||||
impl, team_id = team_with_cost_center
|
||||
response = _post_update(team_id, {"tpm_limit": 55})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def test_http_service_outage_fails_closed_with_configured_message():
|
||||
response = _create_team(metadata=_meta("http_down", cost_center="CC-1001"))
|
||||
assert response.status_code == 503, response.text
|
||||
assert UNAVAILABLE_MESSAGE in response.text
|
||||
|
||||
|
||||
def test_metadata_without_dispatch_key_is_untouched():
|
||||
response = _create_team(metadata={"any_key": "any_value"})
|
||||
assert response.status_code == 200, response.text
|
||||
team_id = response.json()["team_id"]
|
||||
try:
|
||||
update = _post_update(team_id, {"metadata": {"any_key": "changed"}})
|
||||
assert update.status_code == 200, update.text
|
||||
finally:
|
||||
_delete_team(team_id)
|
||||
|
|
@ -9863,11 +9863,14 @@ async def _drive_team_write(
|
|||
raw_body=None,
|
||||
user=None,
|
||||
find_returns_none=False,
|
||||
mock_sink=None,
|
||||
):
|
||||
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
|
||||
|
||||
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
|
||||
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
|
||||
Pass a dict as ``mock_sink`` to receive the update mock even when the
|
||||
endpoint raises.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
from unittest.mock import patch as _patch
|
||||
|
|
@ -9913,6 +9916,8 @@ async def _drive_team_write(
|
|||
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
|
||||
)
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
if mock_sink is not None:
|
||||
mock_sink["update"] = pc.db.litellm_teamtable.update
|
||||
|
||||
req = Mock(spec=Request)
|
||||
if kind == "post":
|
||||
|
|
@ -10175,6 +10180,249 @@ async def test_patch_returns_full_team_object_not_wrapper():
|
|||
assert result.team_id == _PATCH_TEAM_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# custom_team_metadata_validate wiring: the configured validator must gate
|
||||
# every team write path (POST /team/new, POST /team/update, PATCH /team/{id})
|
||||
# and must see the metadata that will actually be written.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _configured_team_metadata_validator(validator):
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
|
||||
try:
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
|
||||
|
||||
def _recording_validator(recorded, valid=True, error_message=None):
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=valid, error_message=error_message)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_validator_sees_replacement_on_post_and_merged_on_patch():
|
||||
"""POST hands the validator the wholesale replacement; PATCH hands it the
|
||||
RFC 7386 merged result including preserved keys."""
|
||||
existing = {"cost_center": "OLD", "keep": 1}
|
||||
body = {"metadata": {"cost_center": "NEW"}}
|
||||
|
||||
recorded_post = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded_post)):
|
||||
await _drive_team_write("post", existing_metadata=existing, payload=body)
|
||||
|
||||
recorded_patch = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded_patch)):
|
||||
await _drive_team_write("patch", existing_metadata=existing, payload=body)
|
||||
|
||||
assert len(recorded_post) == 1
|
||||
assert recorded_post[0].operation == "update"
|
||||
assert recorded_post[0].metadata == {"cost_center": "NEW"}
|
||||
assert recorded_post[0].existing_metadata == existing
|
||||
|
||||
assert len(recorded_patch) == 1
|
||||
assert recorded_patch[0].operation == "update"
|
||||
assert recorded_patch[0].metadata == {"cost_center": "NEW", "keep": 1}
|
||||
assert recorded_patch[0].existing_metadata == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_null_delete_removes_key_from_validated_metadata():
|
||||
"""Deleting a key via PATCH null must be visible to the validator as the
|
||||
key's absence in the resulting metadata, so a required key cannot be
|
||||
silently dropped."""
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(
|
||||
"patch",
|
||||
existing_metadata={"cost_center": "OLD", "keep": 1},
|
||||
payload={"metadata": {"cost_center": None}},
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].metadata == {"keep": 1}
|
||||
assert recorded[0].existing_metadata == {"cost_center": "OLD", "keep": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_without_metadata_skips_validator(kind):
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(kind, existing_metadata={"k": "v"}, payload={"tpm_limit": 5})
|
||||
|
||||
assert recorded == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_validator_rejection_blocks_db_write(kind):
|
||||
recorded = []
|
||||
validator = _recording_validator(recorded, valid=False, error_message="cost center rejected, contact FinOps")
|
||||
sink = {}
|
||||
|
||||
with _configured_team_metadata_validator(validator):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await _drive_team_write(
|
||||
kind,
|
||||
existing_metadata={"cost_center": "OLD"},
|
||||
payload={"metadata": {"cost_center": "BAD"}},
|
||||
mock_sink=sink,
|
||||
)
|
||||
|
||||
assert str(exc_info.value.code) == "400"
|
||||
assert "cost center rejected, contact FinOps" in str(exc_info.value.message)
|
||||
assert len(recorded) == 1
|
||||
sink["update"].assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_validator_runs_without_metadata_and_rejection_blocks_create():
|
||||
"""Create always validates, even when the request carries no metadata, so a
|
||||
required-key policy can reject a team created without one."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
recorded = []
|
||||
validator = _recording_validator(recorded, valid=False, error_message="cost_center is required")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch("litellm.proxy.proxy_server._license_check") as mock_license,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
_configured_team_metadata_validator(validator),
|
||||
):
|
||||
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
||||
mock_license.is_team_count_over_limit.return_value = False
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await new_team(
|
||||
data=NewTeamRequest(team_alias="no-metadata-team"),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
|
||||
)
|
||||
|
||||
assert str(exc_info.value.code) == "400"
|
||||
assert "cost_center is required" in str(exc_info.value.message)
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].operation == "create"
|
||||
assert recorded[0].metadata == {}
|
||||
assert recorded[0].existing_metadata is None
|
||||
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_validator_accept_proceeds_to_create(mock_db_client, mock_admin_auth):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
mock_db_client.jsonify_team_object = lambda db_data: db_data
|
||||
mock_db_client.get_data = AsyncMock(return_value=None)
|
||||
mock_db_client.update_data = AsyncMock(return_value=MagicMock())
|
||||
mock_db_client.db = MagicMock()
|
||||
|
||||
team_create_result = MagicMock(team_id="team-accept-1")
|
||||
team_create_result.model_dump.return_value = {"team_id": "team-accept-1"}
|
||||
mock_db_client.db.litellm_teamtable = MagicMock()
|
||||
mock_db_client.db.litellm_teamtable.create = AsyncMock(return_value=team_create_result)
|
||||
mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=team_create_result)
|
||||
mock_db_client.db.litellm_usertable = MagicMock()
|
||||
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
|
||||
|
||||
recorded = []
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await new_team(
|
||||
data=NewTeamRequest(team_alias="accepted-team", metadata={"cost_center": "CC-1001"}),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=mock_admin_auth,
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].operation == "create"
|
||||
assert recorded[0].metadata == {"cost_center": "CC-1001"}
|
||||
mock_db_client.db.litellm_teamtable.create.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_rejection_precedes_model_alias_write():
|
||||
"""A rejected create must not leave an orphaned LiteLLM_ModelTable row:
|
||||
validation runs before the model_aliases insert."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
validator = _recording_validator([], valid=False, error_message="cost_center is required")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch("litellm.proxy.proxy_server._license_check") as mock_license,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
_configured_team_metadata_validator(validator),
|
||||
):
|
||||
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
mock_prisma.db.litellm_teamtable.create = AsyncMock()
|
||||
mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
|
||||
mock_license.is_team_count_over_limit.return_value = False
|
||||
|
||||
with pytest.raises(ProxyException):
|
||||
await new_team(
|
||||
data=NewTeamRequest(
|
||||
team_alias="alias-orphan-check",
|
||||
model_aliases={"alias-model": "gpt-4o"},
|
||||
),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
|
||||
)
|
||||
|
||||
mock_prisma.db.litellm_modeltable.create.assert_not_awaited()
|
||||
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["post", "patch"])
|
||||
async def test_update_existing_metadata_excludes_system_managed_keys(kind):
|
||||
"""The validator's existing_metadata must be symmetric with metadata:
|
||||
server-owned keys (team_member_budget_id) are stripped from both, so a
|
||||
key-preservation validator never sees them 'disappear'."""
|
||||
recorded = []
|
||||
stored = {"cost_center": "CC-1001", "team_member_budget_id": "budget-abc"}
|
||||
|
||||
with _configured_team_metadata_validator(_recording_validator(recorded)):
|
||||
await _drive_team_write(
|
||||
kind,
|
||||
existing_metadata=dict(stored),
|
||||
payload={"metadata": {"notes": "x"}},
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].existing_metadata == {"cost_center": "CC-1001"}
|
||||
assert "team_member_budget_id" not in recorded[0].metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH body is validated through PatchTeamRequest before it is handed to
|
||||
# update_team. The write below must stay byte-identical to what the untyped
|
||||
|
|
@ -10340,6 +10588,76 @@ async def test_list_available_teams_filters_joined_and_validates_rows(monkeypatc
|
|||
assert find_many_kwargs["where"] == {"team_id": {"in": ["team-open"]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_metadata_schema_returns_configured_fields():
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(
|
||||
parse_team_metadata_schema(
|
||||
[
|
||||
{"key": "cost_center", "label": "Cost Center"},
|
||||
{"key": "app_name", "label": "Application Name"},
|
||||
]
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await get_team_metadata_schema()
|
||||
finally:
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
|
||||
assert [field.key for field in result.fields] == ["cost_center", "app_name"]
|
||||
assert result.fields[0].label == "Cost Center"
|
||||
assert result.fields[1].label == "Application Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_metadata_schema_empty_when_unconfigured():
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
)
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
result = await get_team_metadata_schema()
|
||||
|
||||
assert result.fields == ()
|
||||
|
||||
|
||||
def test_get_team_metadata_schema_route_requires_auth():
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_SCHEMA_REGISTRY,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.master_key", "sk-1234"):
|
||||
response = client.get("/team/metadata_schema")
|
||||
assert response.status_code == 401
|
||||
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema([{"key": "cost_center", "label": "Cost Center"}]))
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"
|
||||
)
|
||||
try:
|
||||
authed = client.get("/team/metadata_schema")
|
||||
finally:
|
||||
app.dependency_overrides.pop(user_api_key_auth, None)
|
||||
TEAM_METADATA_SCHEMA_REGISTRY.set(())
|
||||
|
||||
assert authed.status_code == 200
|
||||
assert authed.json() == {"fields": [{"key": "cost_center", "label": "Cost Center"}]}
|
||||
|
||||
|
||||
def test_team_metadata_schema_route_is_readable_by_non_admins():
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert "/team/metadata_schema" in LiteLLMRoutes.info_routes.value
|
||||
assert "/team/metadata_schema" in LiteLLMRoutes.management_routes.value
|
||||
|
||||
|
||||
def _provisioning_caller(role: LitellmUserRoles) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_id="caller-1", user_role=role)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"""Three independent `custom_team_metadata_validate` implementations.
|
||||
|
||||
Used by the matrix tests in `test_team_metadata_validation.py` and loadable
|
||||
directly from a proxy config via `get_instance_fn` for live verification:
|
||||
|
||||
- `validate_allowlist`: plain async function; requires `cost_center` and
|
||||
checks it against a static allowlist.
|
||||
- `validate_via_http`: async function that POSTs the metadata to an external
|
||||
validation service (`TEAM_METADATA_VALIDATION_SERVICE_URL`); any transport
|
||||
error or non-2xx response raises, exercising the fail-closed path.
|
||||
- `IMMUTABLE_COST_CENTER_VALIDATOR`: class instance with an async
|
||||
`__call__`; requires `cost_center` and forbids changing it once set, using
|
||||
`existing_metadata` and `operation`.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
)
|
||||
|
||||
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
|
||||
DEFAULT_SERVICE_URL = "http://localhost:9414/validate"
|
||||
|
||||
|
||||
async def validate_allowlist(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
cost_center = payload.metadata.get("cost_center")
|
||||
if cost_center is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message="cost_center is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if cost_center not in ALLOWED_COST_CENTERS:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
async def validate_via_http(
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", DEFAULT_SERVICE_URL)
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.post(
|
||||
service_url,
|
||||
json={"operation": payload.operation, "metadata": payload.metadata},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("ok") is True:
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=body.get("reason", "Rejected by the cost center service."),
|
||||
)
|
||||
|
||||
|
||||
class ImmutableCostCenterValidator:
|
||||
def __init__(self, immutable_key: str = "cost_center") -> None:
|
||||
self.immutable_key = immutable_key
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
payload: TeamMetadataValidationPayload,
|
||||
) -> TeamMetadataValidationResult:
|
||||
current = payload.metadata.get(self.immutable_key)
|
||||
if current is None:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
|
||||
)
|
||||
if payload.operation == "update" and payload.existing_metadata is not None:
|
||||
prior = payload.existing_metadata.get(self.immutable_key)
|
||||
if prior is not None and prior != current:
|
||||
return TeamMetadataValidationResult(
|
||||
valid=False,
|
||||
error_message=(
|
||||
f"{self.immutable_key} is immutable once set "
|
||||
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
|
||||
),
|
||||
)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
|
||||
IMMUTABLE_COST_CENTER_VALIDATOR = ImmutableCostCenterValidator()
|
||||
|
|
@ -0,0 +1,704 @@
|
|||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE,
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS,
|
||||
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE,
|
||||
TeamMetadataRequester,
|
||||
TeamMetadataValidationPayload,
|
||||
TeamMetadataValidationResult,
|
||||
TeamMetadataValidatorRegistry,
|
||||
_read_timeout_seconds,
|
||||
_read_unavailable_message,
|
||||
run_team_metadata_validation,
|
||||
validate_team_metadata_if_configured,
|
||||
)
|
||||
|
||||
|
||||
def _registry_with(validator):
|
||||
registry = TeamMetadataValidatorRegistry()
|
||||
registry.set(validator)
|
||||
return registry
|
||||
|
||||
UNAVAILABLE_MESSAGE = "validation system down, contact ops"
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
values = {
|
||||
"operation": "create",
|
||||
"metadata": {"cost_center": "CC-1001"},
|
||||
"existing_metadata": None,
|
||||
"team_id": "team-1",
|
||||
"team_alias": "alias-1",
|
||||
"requester": TeamMetadataRequester(user_id="u1"),
|
||||
}
|
||||
values.update(overrides)
|
||||
return TeamMetadataValidationPayload(**values)
|
||||
|
||||
|
||||
async def _run(validator, payload=None, premium_user=True, timeout_seconds=1.0):
|
||||
await run_team_metadata_validation(
|
||||
validator=validator,
|
||||
payload=payload or _payload(),
|
||||
premium_user=premium_user,
|
||||
timeout_seconds=timeout_seconds,
|
||||
unavailable_message=UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_result_passes():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
await _run(validator)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_raises_400_with_validator_message():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=False, error_message="cost center rejected, contact FinOps")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": "cost center rejected, contact FinOps"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_without_message_uses_default():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_shaped_return_is_accepted():
|
||||
async def validator(payload):
|
||||
return {"valid": False, "error_message": "rejected via dict"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": "rejected via dict"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_exception_fails_closed_with_generic_message():
|
||||
async def validator(payload):
|
||||
raise RuntimeError("internal validation service is down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_timeout_fails_closed():
|
||||
async def validator(payload):
|
||||
await asyncio.sleep(1.0)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator, timeout_seconds=0.01)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_return_shape_fails_closed():
|
||||
async def validator(payload):
|
||||
return "not-a-validation-result"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_premium_user_is_rejected():
|
||||
async def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator, premium_user=False)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert CommonProxyErrors.not_premium_user.value in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_coroutine_validator_is_rejected():
|
||||
def validator(payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(validator)
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, expected",
|
||||
[
|
||||
({}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": 2}, 2.0),
|
||||
({"team_metadata_validation_timeout": 0.5}, 0.5),
|
||||
({"team_metadata_validation_timeout": True}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": -1}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": 0}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
({"team_metadata_validation_timeout": "3"}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
|
||||
],
|
||||
)
|
||||
def test_read_timeout_seconds(general_settings, expected):
|
||||
assert _read_timeout_seconds(general_settings) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, expected",
|
||||
[
|
||||
({}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
(
|
||||
{"team_metadata_validation_error_message": "call the help desk"},
|
||||
"call the help desk",
|
||||
),
|
||||
({"team_metadata_validation_error_message": " "}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
({"team_metadata_validation_error_message": None}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
|
||||
],
|
||||
)
|
||||
def test_read_unavailable_message(general_settings, expected):
|
||||
assert _read_unavailable_message(general_settings) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_is_noop_when_unconfigured():
|
||||
calls = []
|
||||
|
||||
async def validator(payload):
|
||||
calls.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata={"cost_center": "CC-1001"},
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias="alias-1",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=TeamMetadataValidatorRegistry(),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_builds_payload_and_reads_settings():
|
||||
recorded = []
|
||||
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"team_metadata_validation_timeout": 3, "team_metadata_validation_error_message": "ops msg"},
|
||||
),
|
||||
):
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="update",
|
||||
metadata={"cost_center": "CC-2001"},
|
||||
existing_metadata={"cost_center": "CC-1001", "keep": 1},
|
||||
team_id="team-9",
|
||||
team_alias="alias-9",
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="user-9",
|
||||
user_email="user-9@example.com",
|
||||
),
|
||||
registry=_registry_with(validator),
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
payload = recorded[0]
|
||||
assert payload.operation == "update"
|
||||
assert payload.metadata == {"cost_center": "CC-2001"}
|
||||
assert payload.existing_metadata == {"cost_center": "CC-1001", "keep": 1}
|
||||
assert payload.team_id == "team-9"
|
||||
assert payload.team_alias == "alias-9"
|
||||
assert payload.requester.user_id == "user-9"
|
||||
assert payload.requester.user_email == "user-9@example.com"
|
||||
assert payload.requester.user_role == LitellmUserRoles.INTERNAL_USER.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_normalizes_non_dict_metadata_to_empty_dict():
|
||||
recorded = []
|
||||
|
||||
async def validator(payload):
|
||||
recorded.append(payload)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata=None,
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias=None,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=_registry_with(validator),
|
||||
)
|
||||
|
||||
assert len(recorded) == 1
|
||||
assert recorded[0].metadata == {}
|
||||
assert recorded[0].existing_metadata is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validator implementation matrix: three independent implementations
|
||||
# (allowlist function, HTTP-service-backed function, immutability class
|
||||
# instance) driven through the real team write endpoints.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import json as _json
|
||||
import socket
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import team_metadata_validator_impls as impls
|
||||
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
class _CostCenterServiceHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = _json.loads(self.rfile.read(length) or b"{}")
|
||||
cost_center = (body.get("metadata") or {}).get("cost_center")
|
||||
if cost_center is None:
|
||||
resp = {"ok": False, "reason": "cost_center missing per cost center service"}
|
||||
elif cost_center not in impls.ALLOWED_COST_CENTERS:
|
||||
resp = {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"}
|
||||
else:
|
||||
resp = {"ok": True}
|
||||
payload = _json.dumps(resp).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cost_center_service_url():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _CostCenterServiceHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/validate"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _closed_port_url():
|
||||
probe = socket.socket()
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
return f"http://127.0.0.1:{port}/validate"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _configured(validator):
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
|
||||
try:
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
|
||||
|
||||
|
||||
async def _drive_create(metadata, mock_sink=None):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, NewTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as pc,
|
||||
patch("litellm.proxy.proxy_server._license_check") as lic,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
):
|
||||
team_row = MagicMock(team_id="matrix-team-1")
|
||||
team_row.model_dump.return_value = {"team_id": "matrix-team-1"}
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
pc.get_data = AsyncMock(return_value=None)
|
||||
pc.update_data = AsyncMock(return_value=MagicMock())
|
||||
pc.db.litellm_teamtable.create = AsyncMock(return_value=team_row)
|
||||
pc.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
pc.db.litellm_teamtable.update = AsyncMock(return_value=team_row)
|
||||
pc.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
|
||||
pc.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
|
||||
lic.is_team_count_over_limit.return_value = False
|
||||
if mock_sink is not None:
|
||||
mock_sink["team_create"] = pc.db.litellm_teamtable.create
|
||||
mock_sink["model_create"] = pc.db.litellm_modeltable.create
|
||||
|
||||
request_kwargs = {"team_alias": "matrix-team"}
|
||||
if metadata is not None:
|
||||
request_kwargs["metadata"] = metadata
|
||||
return await new_team(
|
||||
data=NewTeamRequest(**request_kwargs),
|
||||
http_request=MagicMock(spec=Request),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
|
||||
async def _drive_update(kind, existing_metadata, payload):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, PatchTeamRequest, UpdateTeamRequest
|
||||
from litellm.proxy.management_endpoints.team_endpoints import patch_team, update_team
|
||||
|
||||
team_id = "matrix-team-upd"
|
||||
existing = LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
team_alias="matrix",
|
||||
metadata=existing_metadata,
|
||||
organization_id=None,
|
||||
)
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as pc,
|
||||
patch("litellm.proxy.proxy_server.llm_router", None),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
pc.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing)
|
||||
pc.db.litellm_teamtable.update = AsyncMock(
|
||||
return_value=LiteLLM_TeamTable(team_id=team_id, team_alias="matrix")
|
||||
)
|
||||
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
|
||||
|
||||
req = Mock(spec=Request)
|
||||
if kind == "post":
|
||||
return await update_team(
|
||||
data=UpdateTeamRequest(team_id=team_id, **payload),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
return await patch_team(
|
||||
team_id=team_id,
|
||||
data=PatchTeamRequest.model_validate(dict(payload)),
|
||||
http_request=req,
|
||||
user_api_key_dict=auth,
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
|
||||
OK = ("ok", None)
|
||||
|
||||
_MATRIX_IMPLS = {
|
||||
"allowlist": lambda: impls.validate_allowlist,
|
||||
"http": lambda: impls.validate_via_http,
|
||||
"immutable_class": lambda: impls.IMMUTABLE_COST_CENTER_VALIDATOR,
|
||||
}
|
||||
|
||||
# (scenario, kind, existing_metadata, request payload, {impl: expected})
|
||||
# expected is ("ok", None) or ("reject", <message substring>)
|
||||
_MATRIX_SCENARIOS = [
|
||||
(
|
||||
"create-valid-cost-center",
|
||||
"create",
|
||||
None,
|
||||
{"cost_center": "CC-1001"},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
(
|
||||
"create-missing-cost-center",
|
||||
"create",
|
||||
None,
|
||||
None,
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"create-unknown-cost-center",
|
||||
"create",
|
||||
None,
|
||||
{"cost_center": "CC-9999"},
|
||||
{
|
||||
"allowlist": ("reject", "is not recognized"),
|
||||
"http": ("reject", "rejected by cost center service"),
|
||||
"immutable_class": OK,
|
||||
},
|
||||
),
|
||||
(
|
||||
"patch-change-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"cost_center": "CC-1002"}},
|
||||
{
|
||||
"allowlist": OK,
|
||||
"http": OK,
|
||||
"immutable_class": ("reject", "immutable once set"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"patch-unrelated-key-preserves-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"notes": "hello"}},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
(
|
||||
"patch-null-deletes-cost-center",
|
||||
"patch",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"cost_center": None}},
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"post-replace-drops-cost-center",
|
||||
"post",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"metadata": {"notes": "only-notes"}},
|
||||
{
|
||||
"allowlist": ("reject", "cost_center is required"),
|
||||
"http": ("reject", "cost_center missing per cost center service"),
|
||||
"immutable_class": ("reject", "cost_center is required"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"update-without-metadata-skips-validation",
|
||||
"post",
|
||||
{"cost_center": "CC-1001"},
|
||||
{"tpm_limit": 5},
|
||||
{"allowlist": OK, "http": OK, "immutable_class": OK},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("impl_name", sorted(_MATRIX_IMPLS))
|
||||
@pytest.mark.parametrize(
|
||||
"scenario, kind, existing_metadata, request_payload, expectations",
|
||||
_MATRIX_SCENARIOS,
|
||||
ids=[row[0] for row in _MATRIX_SCENARIOS],
|
||||
)
|
||||
async def test_validator_implementation_matrix(
|
||||
monkeypatch,
|
||||
cost_center_service_url,
|
||||
impl_name,
|
||||
scenario,
|
||||
kind,
|
||||
existing_metadata,
|
||||
request_payload,
|
||||
expectations,
|
||||
):
|
||||
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", cost_center_service_url)
|
||||
validator = _MATRIX_IMPLS[impl_name]()
|
||||
outcome, message_part = expectations[impl_name]
|
||||
|
||||
async def drive():
|
||||
if kind == "create":
|
||||
return await _drive_create(metadata=request_payload)
|
||||
return await _drive_update(kind, existing_metadata, request_payload)
|
||||
|
||||
with _configured(validator):
|
||||
if outcome == "ok":
|
||||
await drive()
|
||||
else:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await drive()
|
||||
assert str(exc_info.value.code) == "400", f"{scenario} x {impl_name}"
|
||||
assert message_part in str(exc_info.value.message), f"{scenario} x {impl_name}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"kind, existing_metadata, request_payload",
|
||||
[
|
||||
("create", None, {"cost_center": "CC-1001"}),
|
||||
("patch", {"cost_center": "CC-1001"}, {"metadata": {"notes": "x"}}),
|
||||
],
|
||||
)
|
||||
async def test_http_validator_service_outage_fails_closed(monkeypatch, kind, existing_metadata, request_payload):
|
||||
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", _closed_port_url())
|
||||
|
||||
with _configured(impls.validate_via_http):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
if kind == "create":
|
||||
await _drive_create(metadata=request_payload)
|
||||
else:
|
||||
await _drive_update(kind, existing_metadata, request_payload)
|
||||
|
||||
assert str(exc_info.value.code) == "503"
|
||||
assert DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE in str(exc_info.value.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_class_instance_with_async_call_is_accepted():
|
||||
await _run(impls.ImmutableCostCenterValidator(), payload=_payload(metadata={"cost_center": "CC-1"}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_class_instance_with_sync_call_is_rejected():
|
||||
class SyncValidator:
|
||||
def __call__(self, payload):
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(SyncValidator())
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
from litellm.proxy.management_helpers.team_metadata_validation import (
|
||||
TeamMetadataSchemaRegistry,
|
||||
parse_team_metadata_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_schema_none_returns_empty():
|
||||
assert parse_team_metadata_schema(None) == ()
|
||||
|
||||
|
||||
def test_parse_schema_round_trips_fields_in_order():
|
||||
raw = [
|
||||
{"key": "cost_center", "label": "Cost Center"},
|
||||
{"key": "app_name"},
|
||||
]
|
||||
|
||||
fields = parse_team_metadata_schema(raw)
|
||||
|
||||
assert [field.key for field in fields] == ["cost_center", "app_name"]
|
||||
assert fields[0].label == "Cost Center"
|
||||
assert fields[1].label is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"cost_center",
|
||||
{"key": "cost_center"},
|
||||
[{"label": "missing key"}],
|
||||
[{"key": ""}],
|
||||
[{"key": "cost_center", "required": True}],
|
||||
[{"key": "cost_center", "description": "Cost center code"}],
|
||||
[{"key": "cost_center", "allowed_values": ["CC-1001"]}],
|
||||
],
|
||||
)
|
||||
def test_parse_schema_malformed_raises(raw):
|
||||
with pytest.raises(Exception):
|
||||
parse_team_metadata_schema(raw)
|
||||
|
||||
|
||||
def test_parse_schema_duplicate_keys_raise():
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
parse_team_metadata_schema([{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}])
|
||||
|
||||
|
||||
def test_schema_registry_defaults_empty_and_round_trips():
|
||||
registry = TeamMetadataSchemaRegistry()
|
||||
assert registry.get() == ()
|
||||
|
||||
fields = parse_team_metadata_schema([{"key": "cost_center"}])
|
||||
registry.set(fields)
|
||||
assert registry.get() == fields
|
||||
|
||||
registry.set(())
|
||||
assert registry.get() == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_callable_validator_is_rejected_with_clean_500():
|
||||
class NotCallable:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(NotCallable())
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
|
||||
|
||||
|
||||
def test_parse_schema_duplicate_error_lists_offending_keys():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_team_metadata_schema(
|
||||
[{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}, {"key": "app_name"}]
|
||||
)
|
||||
assert str(exc_info.value) == "team_metadata_schema contains duplicate keys: app_name, cost_center"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_applies_configured_timeout_to_slow_validator():
|
||||
async def slow_validator(payload):
|
||||
await asyncio.sleep(0.2)
|
||||
return TeamMetadataValidationResult(valid=True)
|
||||
|
||||
registry = TeamMetadataValidatorRegistry()
|
||||
registry.set(slow_validator)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"team_metadata_validation_timeout": 0.01},
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_team_metadata_if_configured(
|
||||
operation="create",
|
||||
metadata={"cost_center": "CC-1001"},
|
||||
existing_metadata=None,
|
||||
team_id="team-1",
|
||||
team_alias="alias-1",
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchTeamMetadataSchema } from "./useTeamMetadataSchema";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: vi.fn(() => ""),
|
||||
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
|
||||
}));
|
||||
|
||||
describe("fetchTeamMetadataSchema", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should return the declared fields from the response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ fields: [{ key: "cost_center", label: "Cost Center", required: true }] }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([
|
||||
{ key: "cost_center", label: "Cost Center", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return an empty list when the response has no fields array", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "{}" }));
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw on a non-ok response so the query can retry and fail open", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, text: async () => "" }));
|
||||
|
||||
await expect(fetchTeamMetadataSchema("sk-test")).rejects.toThrow("404");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
|
||||
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
|
||||
export interface TeamMetadataField {
|
||||
key: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const silentApiClient = createApiClient({
|
||||
getBaseUrl: getProxyBaseUrl,
|
||||
getAuthHeaderName: getGlobalLitellmHeaderName,
|
||||
});
|
||||
|
||||
export const fetchTeamMetadataSchema = async (accessToken: string): Promise<TeamMetadataField[]> => {
|
||||
const data = await silentApiClient.get<{ fields?: TeamMetadataField[] }>("/team/metadata_schema", { accessToken });
|
||||
return Array.isArray(data?.fields) ? data.fields : [];
|
||||
};
|
||||
|
||||
export const teamMetadataSchemaKeys = createQueryKeys("teamMetadataSchema");
|
||||
|
||||
export const useTeamMetadataSchema = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<TeamMetadataField[]>({
|
||||
queryKey: teamMetadataSchemaKeys.list({}),
|
||||
queryFn: async () => await fetchTeamMetadataSchema(accessToken!),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: TWENTY_FOUR_HOURS_MS,
|
||||
gcTime: TWENTY_FOUR_HOURS_MS,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
|
|
@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
|
||||
import Teams from "./Teams";
|
||||
|
|
@ -33,6 +35,10 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
|||
teamsTableKeys: { all: ["teamsTable"] },
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
|
||||
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("./molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -617,6 +623,210 @@ describe("Teams - access_group_ids in team create", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Teams - metadata key-value pairs in team create", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamCreateCall).mockResolvedValue({
|
||||
team_id: "new-team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-4"],
|
||||
organization_id: null,
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
const openCreateModal = async () => {
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("renders the metadata editor in the main form without opening Additional Settings", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: /add key-value pair/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits metadata built from key-value pairs as a typed JSON object", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Key")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Key"), { target: { value: "cost_center" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "eng-42" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key")).toHaveLength(2);
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText("Key")[1], { target: { value: "tier" } });
|
||||
fireEvent.change(screen.getAllByPlaceholderText("Value")[1], { target: { value: "3" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
|
||||
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "eng-42", tier: 3 });
|
||||
});
|
||||
|
||||
it("omits metadata entirely when no pairs are added", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(teamCreateCall).mock.calls[0][1].metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - schema-declared metadata fields in team create", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTeamInfoView.mockClear();
|
||||
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
|
||||
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(teamCreateCall).mockResolvedValue({
|
||||
team_id: "new-team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-4"],
|
||||
organization_id: null,
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({ data: null });
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({
|
||||
data: [{ key: "cost_center", label: "Cost Center" }],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const openCreateModal = async () => {
|
||||
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("should prepopulate the declared key as an ordinary pair row and submit its value", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-1001" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teamCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
|
||||
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "CC-1001" });
|
||||
});
|
||||
|
||||
it("should toast only the validator's own message when the backend rejects the create", async () => {
|
||||
vi.mocked(teamCreateCall).mockRejectedValue(
|
||||
new Error("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
|
||||
);
|
||||
await openCreateModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
|
||||
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-9999" } });
|
||||
|
||||
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
|
||||
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
"Error creating the team: Cost center CC-9999 is not recognized. Contact the FinOps team.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a skeleton in the metadata section while the schema is loading", async () => {
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: undefined, isLoading: true } as any);
|
||||
await openCreateModal();
|
||||
|
||||
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should re-seed declared keys when the create modal is closed and reopened", async () => {
|
||||
await openCreateModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Remove key-value pair"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/team name/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
|
||||
act(() => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teams - models dropdown options", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
|
|||
import { useTeamDetailRouting } from "@/app/(dashboard)/teams/detailNavigation";
|
||||
import { TeamsTable } from "./TeamsPage/TeamsTable";
|
||||
import AccessGroupSelector from "./common_components/AccessGroupSelector";
|
||||
import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
|
||||
import AgentSelector from "./agent_management/AgentSelector";
|
||||
import ModelAliasManager from "./common_components/ModelAliasManager";
|
||||
|
|
@ -28,6 +30,7 @@ import type { Team } from "./key_team_helpers/key_list";
|
|||
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
|
|
@ -125,6 +128,7 @@ const getOrganizationAlias = (
|
|||
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
|
||||
const { data: organizationsData } = useOrganizations();
|
||||
const organizations = organizationsData ?? null;
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all });
|
||||
const [currentOrg] = useState<Organization | null>(null);
|
||||
|
|
@ -322,25 +326,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
|
||||
NotificationsManager.info("Creating Team");
|
||||
|
||||
// Handle logging settings in metadata
|
||||
if (loggingSettings.length > 0) {
|
||||
let metadata = {};
|
||||
if (formValues.metadata) {
|
||||
try {
|
||||
metadata = JSON.parse(formValues.metadata);
|
||||
} catch (e) {
|
||||
console.warn("Invalid JSON in metadata field, starting with empty object");
|
||||
}
|
||||
}
|
||||
|
||||
// Add logging settings to metadata
|
||||
metadata = {
|
||||
...metadata,
|
||||
logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name
|
||||
};
|
||||
|
||||
formValues.metadata = JSON.stringify(metadata);
|
||||
}
|
||||
const metadataObject = {
|
||||
...metadataPairsToObject(formValues.metadata),
|
||||
...(loggingSettings.length > 0 ? { logging: loggingSettings.filter((config) => config.callback_name) } : {}),
|
||||
};
|
||||
formValues.metadata = Object.keys(metadataObject).length > 0 ? JSON.stringify(metadataObject) : undefined;
|
||||
|
||||
if (formValues.secret_manager_settings) {
|
||||
if (typeof formValues.secret_manager_settings === "string") {
|
||||
|
|
@ -451,7 +441,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating the team:", error);
|
||||
NotificationsManager.fromBackend("Error creating the team: " + error);
|
||||
NotificationsManager.fromBackend("Error creating the team: " + extractProxyErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -593,6 +583,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
footer={null}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<>
|
||||
|
|
@ -747,6 +738,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
|
||||
>
|
||||
<MetadataKeyValueFields
|
||||
form={form}
|
||||
schemaFields={teamMetadataSchemaFields}
|
||||
schemaLoading={isTeamMetadataSchemaLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Accordion
|
||||
className="mt-20 mb-8"
|
||||
|
|
@ -801,13 +802,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
name="metadata"
|
||||
help="Additional team metadata. Enter metadata as JSON object."
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Secret Manager Settings"
|
||||
name="secret_manager_settings"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import MetadataKeyValueFields, {
|
||||
MetadataPair,
|
||||
metadataObjectToPairs,
|
||||
metadataPairsToObject,
|
||||
} from "./MetadataKeyValueFields";
|
||||
|
||||
describe("metadataObjectToPairs", () => {
|
||||
it("returns an empty list for null or undefined metadata", () => {
|
||||
expect(metadataObjectToPairs(null)).toEqual([]);
|
||||
expect(metadataObjectToPairs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps plain string values as-is", () => {
|
||||
expect(metadataObjectToPairs({ department: "research" })).toEqual([{ key: "department", value: "research" }]);
|
||||
});
|
||||
|
||||
it("serializes non-string values as JSON", () => {
|
||||
expect(
|
||||
metadataObjectToPairs({
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
tags: ["a", "b"],
|
||||
empty: null,
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: "tier", value: "3" },
|
||||
{ key: "beta", value: "true" },
|
||||
{ key: "config", value: '{"region":"us"}' },
|
||||
{ key: "tags", value: '["a","b"]' },
|
||||
{ key: "empty", value: "null" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("quotes string values that would otherwise parse as JSON, so types round-trip", () => {
|
||||
expect(metadataObjectToPairs({ code: "42", flag: "true" })).toEqual([
|
||||
{ key: "code", value: '"42"' },
|
||||
{ key: "flag", value: '"true"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out excluded keys", () => {
|
||||
expect(
|
||||
metadataObjectToPairs({ department: "research", logging: [{ callback_name: "langfuse" }] }, new Set(["logging"])),
|
||||
).toEqual([{ key: "department", value: "research" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadataPairsToObject", () => {
|
||||
it("returns an empty object for undefined pairs", () => {
|
||||
expect(metadataPairsToObject(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps plain text values as strings", () => {
|
||||
expect(metadataPairsToObject([{ key: "department", value: "research" }])).toEqual({ department: "research" });
|
||||
});
|
||||
|
||||
it("parses JSON values into their typed form", () => {
|
||||
expect(
|
||||
metadataPairsToObject([
|
||||
{ key: "tier", value: "3" },
|
||||
{ key: "beta", value: "true" },
|
||||
{ key: "config", value: '{"region":"us"}' },
|
||||
{ key: "code", value: '"42"' },
|
||||
]),
|
||||
).toEqual({ tier: 3, beta: true, config: { region: "us" }, code: "42" });
|
||||
});
|
||||
|
||||
it("skips rows without a key and defaults a missing value to an empty string", () => {
|
||||
expect(metadataPairsToObject([{ key: "", value: "orphan" }, undefined, { key: "kept" }])).toEqual({ kept: "" });
|
||||
});
|
||||
|
||||
it("round-trips a mixed-type metadata object losslessly", () => {
|
||||
const metadata = {
|
||||
department: "research",
|
||||
code: "42",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us", replicas: 2 },
|
||||
};
|
||||
expect(metadataPairsToObject(metadataObjectToPairs(metadata))).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
onFinish: (values: { metadata?: MetadataPair[] }) => void;
|
||||
initialMetadata?: MetadataPair[];
|
||||
schemaFields?: TeamMetadataField[];
|
||||
schemaLoading?: boolean;
|
||||
}
|
||||
|
||||
const Harness: React.FC<HarnessProps> = ({ onFinish, initialMetadata, schemaFields, schemaLoading }) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish} initialValues={{ metadata: initialMetadata }}>
|
||||
<MetadataKeyValueFields form={form} schemaFields={schemaFields} schemaLoading={schemaLoading} />
|
||||
<button type="submit">Save</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
describe("MetadataKeyValueFields", () => {
|
||||
it("renders one row per existing pair", () => {
|
||||
render(
|
||||
<Harness
|
||||
onFinish={vi.fn()}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "tier", value: "3" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
expect(keyInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["department", "tier"]);
|
||||
expect(valueInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["research", "3"]);
|
||||
});
|
||||
|
||||
it("adds a row and submits the entered pair", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
|
||||
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "eng-1" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a row when its remove icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
onFinish={onFinish}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "tier", value: "3" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "tier", value: "3" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submission on duplicate keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
onFinish={onFinish}
|
||||
initialMetadata={[
|
||||
{ key: "department", value: "research" },
|
||||
{ key: "department", value: "sales" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Duplicate key").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission when a row is missing its key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Value"), "orphan");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Missing key")).toBeInTheDocument();
|
||||
});
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataKeyValueFields with a declared schema", () => {
|
||||
const schema: TeamMetadataField[] = [
|
||||
{ key: "cost_center", label: "Cost Center" },
|
||||
{ key: "app_name", label: "Application Name" },
|
||||
];
|
||||
|
||||
it("should prepopulate one ordinary editable pair row per declared key", async () => {
|
||||
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
screen.getAllByPlaceholderText("Key").forEach((input) => expect(input).toBeEnabled());
|
||||
expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should submit a prepopulated key with its typed value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
render(<Harness onFinish={onFinish} schemaFields={[{ key: "cost_center", label: "Cost Center" }]} />);
|
||||
|
||||
await user.type(await screen.findByPlaceholderText("Value"), "CC-1001");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "CC-1001" }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("should not add a second row for keys already present in the form", async () => {
|
||||
render(
|
||||
<Harness onFinish={vi.fn()} schemaFields={schema} initialMetadata={[{ key: "cost_center", value: "CC-1001" }]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
expect(screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"CC-1001",
|
||||
"",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should let the user remove a prepopulated row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
|
||||
|
||||
await screen.findAllByPlaceholderText("Key");
|
||||
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a skeleton instead of the editor while the schema is loading", () => {
|
||||
render(<Harness onFinish={vi.fn()} schemaLoading />);
|
||||
|
||||
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed rows when the schema arrives after an initial loading state", async () => {
|
||||
const onFinish = vi.fn();
|
||||
const { rerender } = render(<Harness onFinish={onFinish} schemaLoading />);
|
||||
|
||||
rerender(<Harness onFinish={onFinish} schemaFields={schema} schemaLoading={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Form, FormInstance, Input, Skeleton, Space } from "antd";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
|
||||
export interface MetadataPair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function formatMetadataValue(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return JSON.stringify(value) ?? "";
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadataValue(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function metadataObjectToPairs(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
excludedKeys: ReadonlySet<string> = new Set(),
|
||||
): MetadataPair[] {
|
||||
return Object.entries(metadata ?? {})
|
||||
.filter(([key]) => !excludedKeys.has(key))
|
||||
.map(([key, value]) => ({ key, value: formatMetadataValue(value) }));
|
||||
}
|
||||
|
||||
export function metadataPairsToObject(
|
||||
pairs: readonly (Partial<MetadataPair> | undefined)[] | undefined,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
(pairs ?? [])
|
||||
.filter((pair): pair is Partial<MetadataPair> & { key: string } => Boolean(pair?.key))
|
||||
.map((pair) => [pair.key, parseMetadataValue(pair.value ?? "")]),
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataKeyValueFieldsProps {
|
||||
form: FormInstance;
|
||||
name?: string;
|
||||
schemaFields?: readonly TeamMetadataField[];
|
||||
schemaLoading?: boolean;
|
||||
}
|
||||
|
||||
const MetadataKeyValueFields: React.FC<MetadataKeyValueFieldsProps> = ({
|
||||
form,
|
||||
name = "metadata",
|
||||
schemaFields = [],
|
||||
schemaLoading = false,
|
||||
}) => {
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (seededRef.current || schemaLoading || schemaFields.length === 0) return;
|
||||
seededRef.current = true;
|
||||
const pairs: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
|
||||
if (!Array.isArray(pairs)) return;
|
||||
const existingKeys = new Set(pairs.map((pair) => pair?.key).filter(Boolean));
|
||||
const seeded = schemaFields
|
||||
.filter((field) => !existingKeys.has(field.key))
|
||||
.map((field) => ({ key: field.key, value: "" }));
|
||||
if (seeded.length > 0) {
|
||||
form.setFieldValue(name, [...pairs, ...seeded]);
|
||||
}
|
||||
}, [form, name, schemaFields, schemaLoading]);
|
||||
|
||||
if (schemaLoading) {
|
||||
return (
|
||||
<div data-testid="metadata-schema-skeleton">
|
||||
<Skeleton active title={false} paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name: fieldName, ...restField }) => (
|
||||
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[fieldName, "key"]}
|
||||
rules={[
|
||||
{ required: true, message: "Missing key" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const all: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
|
||||
const dupes = all.filter((entry) => entry?.key === value);
|
||||
if (dupes.length > 1) {
|
||||
return Promise.reject(new Error("Duplicate key"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="Key" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[fieldName, "value"]}>
|
||||
<Input placeholder="Value" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined
|
||||
aria-label="Remove key-value pair"
|
||||
onClick={() => remove(fieldName)}
|
||||
style={{ color: "#ef4444" }}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Add Key-Value Pair
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataKeyValueFields;
|
||||
|
|
@ -38,7 +38,7 @@ import type {
|
|||
CoordinationRedisTestResponse,
|
||||
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
|
||||
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
|
||||
import { createApiClient, deriveErrorMessage } from "@/lib/http/client";
|
||||
import { createApiClient, deriveErrorMessage, unwrapProxyErrorMessage } from "@/lib/http/client";
|
||||
import { resolveApiBase } from "@/lib/http/resolveApiBase";
|
||||
import {
|
||||
registerAuthHeaderNameGetter,
|
||||
|
|
@ -2643,7 +2643,7 @@ export const teamUpdateCall = async (
|
|||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
NotificationsManager.fromBackend("Failed to update team settings: " + errorData);
|
||||
NotificationsManager.fromBackend("Failed to update team settings: " + unwrapProxyErrorMessage(errorData));
|
||||
throw new Error(errorData);
|
||||
}
|
||||
const data = (await response.json()) as { data: Team; team_id: string };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import * as networking from "@/components/networking";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
|
@ -26,6 +27,10 @@ vi.mock("@/components/utils/dataUtils", () => ({
|
|||
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
|
||||
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useAllProxyModels: vi.fn(),
|
||||
}));
|
||||
|
|
@ -220,6 +225,7 @@ describe("TeamInfoView", () => {
|
|||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
|
||||
|
||||
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
|
||||
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
|
||||
|
|
@ -893,6 +899,137 @@ describe("TeamInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("metadata key-value editing", () => {
|
||||
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
const teamNameElements = screen.queryAllByText("Test Team");
|
||||
expect(teamNameElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it("prefills pairs from team metadata, hides UI-managed keys, and round-trips typed values on save", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
metadata: {
|
||||
department: "research",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
|
||||
guardrails: ["g1"],
|
||||
disable_global_guardrails: false,
|
||||
model_tpm_limit: { "gpt-4": 100 },
|
||||
},
|
||||
models: ["gpt-4"],
|
||||
}),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
const keyValues = screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value);
|
||||
expect(keyValues).toEqual(["department", "tier", "beta", "config"]);
|
||||
const valueValues = screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value);
|
||||
expect(valueValues).toEqual(["research", "3", "true", '{"region":"us"}']);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const updateArg = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
|
||||
expect(updateArg.metadata).toMatchObject({
|
||||
department: "research",
|
||||
tier: 3,
|
||||
beta: true,
|
||||
config: { region: "us" },
|
||||
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
|
||||
});
|
||||
expect(updateArg.metadata).not.toHaveProperty("model_tpm_limit");
|
||||
expect(updateArg.model_tpm_limit).toEqual({ "gpt-4": 100 });
|
||||
});
|
||||
|
||||
it("includes a newly added pair in the team update", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"] }));
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
|
||||
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
|
||||
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({ cost_center: "eng-1" });
|
||||
});
|
||||
|
||||
it("should keep declared keys as ordinary prefilled rows and submit the edited value", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(useTeamMetadataSchema).mockReturnValue({
|
||||
data: [
|
||||
{ key: "cost_center", label: "Cost Center" },
|
||||
{ key: "app_name", label: "Application Name" },
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
metadata: { cost_center: "CC-OLD", department: "research" },
|
||||
models: ["gpt-4"],
|
||||
}),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
await openSettingsEditor(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
"cost_center",
|
||||
"department",
|
||||
"app_name",
|
||||
]);
|
||||
});
|
||||
expect(screen.getAllByPlaceholderText("Value")[0]).toHaveValue("CC-OLD");
|
||||
|
||||
await user.clear(screen.getAllByPlaceholderText("Value")[0]);
|
||||
await user.type(screen.getAllByPlaceholderText("Value")[0], "CC-NEW");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({
|
||||
cost_center: "CC-NEW",
|
||||
department: "research",
|
||||
app_name: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("model aliases", () => {
|
||||
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import { CheckIcon, CopyIcon } from "lucide-react";
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import MetadataKeyValueFields, {
|
||||
metadataObjectToPairs,
|
||||
metadataPairsToObject,
|
||||
} from "../common_components/MetadataKeyValueFields";
|
||||
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import ModelAliasManager from "../common_components/ModelAliasManager";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
|
|
@ -66,6 +71,18 @@ import {
|
|||
import TeamMembersComponent from "./TeamMemberTab";
|
||||
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
|
||||
|
||||
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
|
||||
"logging",
|
||||
"secret_manager_settings",
|
||||
"soft_budget_alerting_emails",
|
||||
"model_tpm_limit",
|
||||
"model_rpm_limit",
|
||||
"allowed_passthrough_routes",
|
||||
"guardrails",
|
||||
"opted_out_global_guardrails",
|
||||
"disable_global_guardrails",
|
||||
]);
|
||||
|
||||
export interface TeamMembership {
|
||||
user_id: string;
|
||||
team_id: string;
|
||||
|
|
@ -203,6 +220,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const { userRole, userId } = useAuthorized();
|
||||
const { data: userOrganizations = [] } = useOrganizations();
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Check if user is org admin for this team's organization
|
||||
|
|
@ -461,16 +479,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
if (!accessToken) return;
|
||||
setIsTeamSaving(true);
|
||||
|
||||
let parsedMetadata = {};
|
||||
try {
|
||||
const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {};
|
||||
// Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately
|
||||
const { soft_budget_alerting_emails, ...rest } = rawMetadata;
|
||||
parsedMetadata = rest;
|
||||
} catch (e) {
|
||||
NotificationsManager.fromBackend("Invalid JSON in metadata field");
|
||||
return;
|
||||
}
|
||||
const parsedMetadata = metadataPairsToObject(values.metadata);
|
||||
|
||||
let secretManagerSettings: Record<string, any> | undefined;
|
||||
if (typeof values.secret_manager_settings === "string") {
|
||||
|
|
@ -980,21 +989,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
|
||||
? info.metadata.soft_budget_alerting_emails.join(", ")
|
||||
: "",
|
||||
metadata: info.metadata
|
||||
? JSON.stringify(
|
||||
(({
|
||||
logging,
|
||||
secret_manager_settings,
|
||||
soft_budget_alerting_emails,
|
||||
model_tpm_limit,
|
||||
model_rpm_limit,
|
||||
allowed_passthrough_routes,
|
||||
...rest
|
||||
}) => rest)(info.metadata),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
: "",
|
||||
metadata: metadataObjectToPairs(info.metadata, UI_MANAGED_METADATA_KEYS),
|
||||
logging_settings: info.metadata?.logging || [],
|
||||
secret_manager_settings: info.metadata?.secret_manager_settings
|
||||
? JSON.stringify(info.metadata.secret_manager_settings, null, 2)
|
||||
|
|
@ -1170,6 +1165,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Metadata"
|
||||
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
|
||||
>
|
||||
<MetadataKeyValueFields
|
||||
form={form}
|
||||
schemaFields={teamMetadataSchemaFields}
|
||||
schemaLoading={isTeamMetadataSchemaLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Model-Specific Rate Limits"
|
||||
tooltip="Set per-model TPM/RPM limits that apply across the whole team."
|
||||
|
|
@ -1493,10 +1499,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={10} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="sticky z-10 bg-white p-4 pr-0 border-t border-gray-200 -bottom-6 -inset-x-6">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<Button onClick={() => setIsEditing(false)} disabled={isTeamSaving}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createApiClient, ApiError, deriveErrorMessage } from "./client";
|
||||
import {
|
||||
createApiClient,
|
||||
ApiError,
|
||||
deriveErrorMessage,
|
||||
extractProxyErrorMessage,
|
||||
unwrapProxyErrorMessage,
|
||||
} from "./client";
|
||||
|
||||
const okResponse = (data: unknown): Response =>
|
||||
({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response;
|
||||
|
|
@ -126,3 +132,41 @@ describe("deriveErrorMessage", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapProxyErrorMessage", () => {
|
||||
it("should unwrap the proxy's stringified python dict message", () => {
|
||||
expect(
|
||||
unwrapProxyErrorMessage("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
|
||||
).toBe("Cost center CC-9999 is not recognized. Contact the FinOps team.");
|
||||
});
|
||||
|
||||
it("should unwrap the full JSON error envelope down to the inner message", () => {
|
||||
const envelope = JSON.stringify({
|
||||
error: {
|
||||
message: "{'error': 'cost_center is required in team metadata. Contact the FinOps team.'}",
|
||||
type: "internal_server_error",
|
||||
param: "None",
|
||||
code: "400",
|
||||
},
|
||||
});
|
||||
expect(unwrapProxyErrorMessage(envelope)).toBe(
|
||||
"cost_center is required in team metadata. Contact the FinOps team.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return plain messages and unparseable input unchanged", () => {
|
||||
expect(unwrapProxyErrorMessage("Failed to fetch")).toBe("Failed to fetch");
|
||||
expect(unwrapProxyErrorMessage("{}")).toBe("{}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractProxyErrorMessage", () => {
|
||||
it("should unwrap an Error's message without the error name prefix", () => {
|
||||
const error = new ApiError("{'error': 'Cost center CC-9999 is not recognized.'}", 400, {});
|
||||
expect(extractProxyErrorMessage(error)).toBe("Cost center CC-9999 is not recognized.");
|
||||
});
|
||||
|
||||
it("should stringify non-Error inputs", () => {
|
||||
expect(extractProxyErrorMessage("plain failure")).toBe("plain failure");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,38 @@ export const deriveErrorMessage = (errorData: any): string => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The proxy serializes HTTPException details as the string form of a Python dict,
|
||||
* so a rejection reaches the UI as "{'error': 'actual message'}" (or that string
|
||||
* nested inside the JSON error envelope). Unwraps to the actual message; returns
|
||||
* the input unchanged when it does not match a known wrapper shape.
|
||||
*/
|
||||
export const unwrapProxyErrorMessage = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const derived = deriveErrorMessage(parsed);
|
||||
if (typeof derived === "string" && derived !== trimmed) {
|
||||
return unwrapProxyErrorMessage(derived);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const pythonDictMatch = trimmed.match(/^\{'error':\s*(['"])([\s\S]*)\1\}$/);
|
||||
if (pythonDictMatch) {
|
||||
return pythonDictMatch[2];
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export const extractProxyErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return unwrapProxyErrorMessage(error.message);
|
||||
}
|
||||
return unwrapProxyErrorMessage(String(error));
|
||||
};
|
||||
|
||||
export interface ApiClientConfig {
|
||||
/** Resolves the API origin at call time (it can change at runtime). */
|
||||
getBaseUrl: () => string;
|
||||
|
|
|
|||
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
66
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -13640,6 +13640,31 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/metadata_schema": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Team Metadata Schema
|
||||
* @description Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
|
||||
*
|
||||
* The UI uses this to prepopulate the team metadata form with the declared
|
||||
* keys. Returns an empty ``fields`` list when no schema is configured. This
|
||||
* schema is advisory; server-side enforcement stays with
|
||||
* ``custom_team_metadata_validate``.
|
||||
*/
|
||||
get: operations["get_team_metadata_schema_team_metadata_schema_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/model/add": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -32024,6 +32049,27 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* TeamMetadataFieldSchema
|
||||
* @description One declared team metadata field from ``general_settings.team_metadata_schema``.
|
||||
*
|
||||
* Advisory only: the UI uses it to prepopulate the team metadata form.
|
||||
* Enforcement stays with ``custom_team_metadata_validate``.
|
||||
*/
|
||||
TeamMetadataFieldSchema: {
|
||||
/** Key */
|
||||
key: string;
|
||||
/** Label */
|
||||
label?: string | null;
|
||||
};
|
||||
/**
|
||||
* TeamMetadataSchemaResponse
|
||||
* @description Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured.
|
||||
*/
|
||||
TeamMetadataSchemaResponse: {
|
||||
/** Fields */
|
||||
fields: components["schemas"]["TeamMetadataFieldSchema"][];
|
||||
};
|
||||
/**
|
||||
* TeamModelAddRequest
|
||||
* @description Request to add models to a team
|
||||
|
|
@ -51069,6 +51115,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_team_metadata_schema_team_metadata_schema_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TeamMetadataSchemaResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
team_model_add_team_model_add_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue