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:
yuneng-jiang 2026-08-03 18:37:45 -07:00 committed by GitHub
parent d4d0bf0acc
commit cd87fee9c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2844 additions and 64 deletions

View file

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

View file

@ -543,6 +543,7 @@ class LiteLLMRoutes(enum.Enum):
"/v2/team/list",
"/organization/list",
"/team/available",
"/team/metadata_schema",
"/user/info",
"/v2/user/info",
"/model/info",
@ -609,6 +610,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/block",
"/team/unblock",
"/team/available",
"/team/metadata_schema",
"/team/permissions_list",
"/team/permissions_update",
"/team/permissions_bulk_update",

View file

@ -0,0 +1,40 @@
"""Example validator for `general_settings.custom_team_metadata_validate`.
Wire it up in the proxy config:
```yaml
general_settings:
custom_team_metadata_validate: custom_team_metadata_validate.validate_team_metadata
team_metadata_validation_timeout: 5
team_metadata_validation_error_message: "Validation service unavailable, contact your admin."
```
Return `valid=False` with an `error_message` to reject the write with that
message (HTTP 400). Raise any exception (for example, when the upstream
validation service is unreachable) to fail closed with the generic
`team_metadata_validation_error_message` (HTTP 503).
"""
from litellm.proxy.management_helpers.team_metadata_validation import (
TeamMetadataValidationPayload,
TeamMetadataValidationResult,
)
VALID_COST_CENTERS = frozenset({"CC-1001", "CC-1002", "CC-2001"})
async def validate_team_metadata(
payload: TeamMetadataValidationPayload,
) -> TeamMetadataValidationResult:
cost_center = payload.metadata.get("cost_center")
if cost_center is None:
return TeamMetadataValidationResult(
valid=False,
error_message="Team metadata must include a cost_center. Contact the FinOps team.",
)
if cost_center not in VALID_COST_CENTERS:
return TeamMetadataValidationResult(
valid=False,
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
)
return TeamMetadataValidationResult(valid=True)

View file

@ -7,4 +7,7 @@ model_list:
general_settings:
store_model_in_db: true
custom_team_metadata_validate: team_metadata_validator_e2e.validate_team_metadata
team_metadata_validation_timeout: 5
team_metadata_validation_error_message: "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."

View file

@ -0,0 +1,107 @@
"""Dispatching team metadata validator for the store_model_in_db e2e suite.
The suite runs one proxy with one config, so a single registered validator
dispatches to one of three independent implementations chosen per request via
the `_e2e_validator_impl` metadata key:
- `allowlist`: requires `cost_center` and checks it against a static set
- `http`: POSTs the metadata to the cost center service at
`TEAM_METADATA_VALIDATION_SERVICE_URL`; transport errors raise (fail closed)
- `http_down`: like `http` but targets a closed port, proving the 503 path
- `immutable`: requires `cost_center` and forbids changing it once set
A request whose metadata carries no `_e2e_validator_impl` key is accepted
untouched, so the rest of the suite's team operations are unaffected. An
unknown impl value raises, which the proxy converts to the fail-closed 503.
"""
import os
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy.management_helpers.team_metadata_validation import (
TeamMetadataValidationPayload,
TeamMetadataValidationResult,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
CLOSED_PORT_URL = "http://127.0.0.1:9/validate"
async def _validate_allowlist(payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
cost_center = payload.metadata.get("cost_center")
if cost_center is None:
return TeamMetadataValidationResult(
valid=False,
error_message="cost_center is required in team metadata. Contact the FinOps team.",
)
if cost_center not in ALLOWED_COST_CENTERS:
return TeamMetadataValidationResult(
valid=False,
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
)
return TeamMetadataValidationResult(valid=True)
async def _validate_via_http(payload: TeamMetadataValidationPayload, service_url: str) -> TeamMetadataValidationResult:
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
response = await client.post(
service_url,
json={ # mutable-ok: httpx serializes the request body from a plain dict
"operation": payload.operation,
"metadata": payload.metadata,
},
timeout=2.0,
)
body = response.json()
if body.get("ok") is True:
return TeamMetadataValidationResult(valid=True)
return TeamMetadataValidationResult(
valid=False,
error_message=body.get("reason", "Rejected by the cost center service."),
)
class _ImmutableCostCenterValidator:
def __init__(self, immutable_key: str = "cost_center") -> None:
self.immutable_key = immutable_key
async def __call__(self, payload: TeamMetadataValidationPayload) -> TeamMetadataValidationResult:
current = payload.metadata.get(self.immutable_key)
if current is None:
return TeamMetadataValidationResult(
valid=False,
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
)
if payload.operation == "update" and payload.existing_metadata is not None:
prior = payload.existing_metadata.get(self.immutable_key)
if prior is not None and prior != current:
return TeamMetadataValidationResult(
valid=False,
error_message=(
f"{self.immutable_key} is immutable once set "
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
),
)
return TeamMetadataValidationResult(valid=True)
_IMMUTABLE_VALIDATOR = _ImmutableCostCenterValidator()
async def validate_team_metadata(
payload: TeamMetadataValidationPayload,
) -> TeamMetadataValidationResult:
impl = payload.metadata.get("_e2e_validator_impl")
if impl is None:
return TeamMetadataValidationResult(valid=True)
if impl == "allowlist":
return await _validate_allowlist(payload)
if impl == "http":
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", "http://localhost:9414/validate")
return await _validate_via_http(payload, service_url)
if impl == "http_down":
return await _validate_via_http(payload, CLOSED_PORT_URL)
if impl == "immutable":
return await _IMMUTABLE_VALIDATOR(payload)
raise ValueError(f"unknown _e2e_validator_impl: {impl}")

View file

@ -113,6 +113,10 @@ from litellm.proxy.management_helpers.object_permission_utils import (
from litellm.proxy.management_helpers.team_member_permission_checks import (
TeamMemberPermissionChecks,
)
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
validate_team_metadata_if_configured,
)
from litellm.proxy.management_helpers.utils import (
add_new_member,
management_endpoint_wrapper,
@ -147,6 +151,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
TeamListResponse,
TeamMemberAddResult,
TeamMemberInfoResponse,
TeamMetadataSchemaResponse,
UpdateTeamMemberPermissionsRequest,
)
@ -1287,6 +1292,18 @@ async def new_team(
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
if isinstance(data.metadata, dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
await validate_team_metadata_if_configured(
operation="create",
metadata=data.metadata,
existing_metadata=None,
team_id=data.team_id,
team_alias=data.team_alias,
user_api_key_dict=user_api_key_dict,
)
## ADD TO MODEL TABLE
_model_id = None
if data.model_aliases is not None and isinstance(data.model_aliases, dict):
@ -1301,9 +1318,6 @@ async def new_team(
_model_id = model_dict.id
## Create Team Member Budget Table
if isinstance(data.metadata, dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
data_json = data.json()
## Handle Object Permission - MCP, Vector Stores etc.
@ -1965,6 +1979,25 @@ async def update_team(
if isinstance(updated_kv.get("metadata"), dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(updated_kv["metadata"])
if "metadata" in updated_kv:
stored_metadata = (
{ # mutable-ok: the validator payload's isinstance guard requires a plain dict
key: value
for key, value in existing_team_row.metadata.items()
if key not in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS
}
if isinstance(existing_team_row.metadata, dict)
else None
)
await validate_team_metadata_if_configured(
operation="update",
metadata=updated_kv.get("metadata"),
existing_metadata=stored_metadata,
team_id=data.team_id,
team_alias=data.team_alias if data.team_alias is not None else existing_team_row.team_alias,
user_api_key_dict=user_api_key_dict,
)
# Check budget_duration and budget_reset_at
_set_budget_reset_at(data, updated_kv)
@ -4179,6 +4212,24 @@ async def unblock_team(
return record
@router.get(
"/team/metadata_schema",
tags=["team management"], # mutable-ok: fastapi's decorator signature types tags as a list
dependencies=(Depends(user_api_key_auth),),
response_model=TeamMetadataSchemaResponse,
)
async def get_team_metadata_schema():
"""
Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
The UI uses this to prepopulate the team metadata form with the declared
keys. Returns an empty ``fields`` list when no schema is configured. This
schema is advisory; server-side enforcement stays with
``custom_team_metadata_validate``.
"""
return TeamMetadataSchemaResponse(fields=TEAM_METADATA_SCHEMA_REGISTRY.get())
@router.get("/team/available")
async def list_available_teams(
http_request: Request,

View file

@ -0,0 +1,193 @@
"""Custom validation of team metadata on team create/update.
Operators point `general_settings.custom_team_metadata_validate` at an async
Python function (loaded via `get_instance_fn`, like `custom_key_generate`).
The function receives a `TeamMetadataValidationPayload` and returns a
`TeamMetadataValidationResult`. The proxy awaits it before committing a team
write and fails closed: a rejected value surfaces the function's own message
(HTTP 400), while any raised exception or timeout blocks the write with a
generic message (HTTP 503).
"""
import asyncio
import inspect
from collections.abc import Awaitable, Mapping
from types import MappingProxyType
from typing import Literal, Protocol
from fastapi import HTTPException, status
from pydantic import BaseModel, JsonValue, TypeAdapter
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.types.proxy.management_endpoints.team_endpoints import (
TeamMetadataFieldSchema,
)
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS = 5.0
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE = (
"Team metadata validation is currently unavailable, so the team was not saved. Contact your proxy admin."
)
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE = "Team metadata failed validation."
class TeamMetadataRequester(BaseModel):
user_id: str | None = None
user_email: str | None = None
user_role: str | None = None
class TeamMetadataValidationPayload(BaseModel):
operation: Literal["create", "update"]
metadata: Mapping[str, JsonValue]
existing_metadata: Mapping[str, JsonValue] | None = None
team_id: str | None = None
team_alias: str | None = None
requester: TeamMetadataRequester
class TeamMetadataValidationResult(BaseModel):
valid: bool
error_message: str | None = None
_EMPTY_METADATA: Mapping[str, JsonValue] = MappingProxyType({})
class TeamMetadataValidator(Protocol):
def __call__(self, payload: TeamMetadataValidationPayload, /) -> Awaitable[TeamMetadataValidationResult]: ...
class TeamMetadataValidatorRegistry:
def __init__(self) -> None:
self._validator: TeamMetadataValidator | None = None
def set(self, validator: TeamMetadataValidator | None) -> None:
self._validator = validator
def get(self) -> TeamMetadataValidator | None:
return self._validator
TEAM_METADATA_VALIDATOR_REGISTRY = TeamMetadataValidatorRegistry()
_TEAM_METADATA_SCHEMA_ADAPTER: TypeAdapter[tuple[TeamMetadataFieldSchema, ...]] = TypeAdapter(
tuple[TeamMetadataFieldSchema, ...]
)
def parse_team_metadata_schema(raw_schema: object) -> tuple[TeamMetadataFieldSchema, ...]:
"""Parse ``general_settings.team_metadata_schema``; raises on a malformed schema so config load fails fast."""
if raw_schema is None:
return ()
fields = _TEAM_METADATA_SCHEMA_ADAPTER.validate_python(raw_schema)
keys = tuple(field.key for field in fields)
duplicate_keys = sorted(frozenset(key for key in keys if keys.count(key) > 1))
if duplicate_keys:
raise ValueError(f"team_metadata_schema contains duplicate keys: {', '.join(duplicate_keys)}")
return fields
class TeamMetadataSchemaRegistry:
def __init__(self) -> None:
self._fields: tuple[TeamMetadataFieldSchema, ...] = ()
def set(self, fields: tuple[TeamMetadataFieldSchema, ...]) -> None:
self._fields = fields
def get(self) -> tuple[TeamMetadataFieldSchema, ...]:
return self._fields
TEAM_METADATA_SCHEMA_REGISTRY = TeamMetadataSchemaRegistry()
async def run_team_metadata_validation(
validator: TeamMetadataValidator,
payload: TeamMetadataValidationPayload,
premium_user: bool,
timeout_seconds: float,
unavailable_message: str,
) -> None:
if premium_user is not True:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={ # mutable-ok: HTTPException.detail has no immutable form
"error": f"custom_team_metadata_validate is an Enterprise feature. {CommonProxyErrors.not_premium_user.value}"
},
)
if not (
inspect.iscoroutinefunction(validator) or inspect.iscoroutinefunction(getattr(validator, "__call__", None))
):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={ # mutable-ok: HTTPException.detail has no immutable form
"error": "custom_team_metadata_validate must be an async function"
},
)
try:
raw_result = await asyncio.wait_for(validator(payload), timeout=timeout_seconds)
result = TeamMetadataValidationResult.model_validate(raw_result)
except Exception: # noqa: BLE001 # fail closed: any validator failure must block the team write
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"error": unavailable_message}, # mutable-ok: HTTPException.detail has no immutable form
)
if not result.valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={ # mutable-ok: HTTPException.detail has no immutable form
"error": result.error_message or DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE
},
)
def _read_timeout_seconds(general_settings: Mapping[str, object]) -> float:
raw_timeout = general_settings.get("team_metadata_validation_timeout")
if isinstance(raw_timeout, (int, float)) and not isinstance(raw_timeout, bool) and raw_timeout > 0:
return float(raw_timeout)
return DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS
def _read_unavailable_message(general_settings: Mapping[str, object]) -> str:
raw_message = general_settings.get("team_metadata_validation_error_message")
if isinstance(raw_message, str) and raw_message.strip():
return raw_message
return DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE
async def validate_team_metadata_if_configured(
operation: Literal["create", "update"],
metadata: Mapping[str, JsonValue] | None,
existing_metadata: Mapping[str, JsonValue] | None,
team_id: str | None,
team_alias: str | None,
user_api_key_dict: UserAPIKeyAuth,
registry: TeamMetadataValidatorRegistry = TEAM_METADATA_VALIDATOR_REGISTRY,
) -> None:
from litellm.proxy.proxy_server import general_settings, premium_user
validator = registry.get()
if validator is None:
return
payload = TeamMetadataValidationPayload(
operation=operation,
metadata=metadata if isinstance(metadata, dict) else _EMPTY_METADATA,
existing_metadata=existing_metadata if isinstance(existing_metadata, dict) else None,
team_id=team_id,
team_alias=team_alias,
requester=TeamMetadataRequester(
user_id=user_api_key_dict.user_id,
user_email=user_api_key_dict.user_email,
user_role=user_api_key_dict.user_role.value if user_api_key_dict.user_role is not None else None,
),
)
await run_team_metadata_validation(
validator=validator,
payload=payload,
premium_user=premium_user,
timeout_seconds=_read_timeout_seconds(general_settings),
unavailable_message=_read_unavailable_message(general_settings),
)

View file

@ -461,6 +461,11 @@ from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
create_object_audit_log,
)
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
TEAM_METADATA_VALIDATOR_REGISTRY,
parse_team_metadata_schema,
)
from litellm.proxy.memory.memory_endpoints import router as memory_router
from litellm.proxy.middleware.billable_request_metrics_middleware import (
BillableRequestMetricsMiddleware,
@ -783,6 +788,8 @@ def cleanup_router_config_variables():
user_custom_auth_path = None
user_custom_key_generate = None
user_custom_key_update = None
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
TEAM_METADATA_SCHEMA_REGISTRY.set(())
user_custom_sso = None
user_custom_ui_sso_sign_in_handler = None
use_background_health_checks = None
@ -3499,6 +3506,7 @@ _DB_OVERLAY_REMOTE_MODULE_STR_FIELDS: dict[str, tuple[str, ...]] = {
"custom_auth",
"custom_key_generate",
"custom_key_update",
"custom_team_metadata_validate",
"custom_sso",
"custom_ui_sso_sign_in_handler",
),
@ -4829,6 +4837,14 @@ class ProxyConfig:
if custom_key_update is not None:
user_custom_key_update = get_instance_fn(value=custom_key_update, config_file_path=config_file_path)
custom_team_metadata_validate = general_settings.get("custom_team_metadata_validate", None)
TEAM_METADATA_VALIDATOR_REGISTRY.set(
get_instance_fn(value=custom_team_metadata_validate, config_file_path=config_file_path)
if custom_team_metadata_validate is not None
else None
)
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema(general_settings.get("team_metadata_schema")))
custom_sso = general_settings.get("custom_sso", None)
if custom_sso is not None:
user_custom_sso = get_instance_fn(value=custom_sso, config_file_path=config_file_path)

View file

@ -1,13 +1,12 @@
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from litellm.proxy._types import (
KeyManagementRoutes,
LiteLLM_DeletedTeamTable,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_UserTable,
Member,
)
@ -125,3 +124,22 @@ class TeamMemberInfoResponse(LiteLLM_TeamMembership):
role: Optional[str] = None
user_email: Optional[str] = None
team_alias: Optional[str] = None
class TeamMetadataFieldSchema(BaseModel):
"""One declared team metadata field from ``general_settings.team_metadata_schema``.
Advisory only: the UI uses it to prepopulate the team metadata form.
Enforcement stays with ``custom_team_metadata_validate``.
"""
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1)
label: Optional[str] = None
class TeamMetadataSchemaResponse(BaseModel):
"""Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured."""
fields: tuple[TeamMetadataFieldSchema, ...]

View file

@ -0,0 +1,54 @@
"""Stand-in cost center validation service for the team metadata e2e tests.
Accepts POST /validate with {"operation": ..., "metadata": {...}} and answers
{"ok": true} or {"ok": false, "reason": ...} based on a static allowlist.
GET /health answers 200 for the CI wait loop.
"""
import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ALLOWED_COST_CENTERS = {"CC-1001", "CC-1002"}
class CostCenterHandler(BaseHTTPRequestHandler):
def _respond(self, status: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
if self.path == "/health":
self._respond(200, {"status": "healthy"})
return
self._respond(404, {"error": "not found"})
def do_POST(self):
if self.path != "/validate":
self._respond(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
cost_center = (body.get("metadata") or {}).get("cost_center")
if cost_center is None:
self._respond(200, {"ok": False, "reason": "cost_center missing per cost center service"})
elif cost_center not in ALLOWED_COST_CENTERS:
self._respond(200, {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"})
else:
self._respond(200, {"ok": True})
def log_message(self, format, *args):
pass
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=9414)
args = parser.parse_args()
print(f"cost center service listening on {args.host}:{args.port}")
ThreadingHTTPServer((args.host, args.port), CostCenterHandler).serve_forever()

View file

@ -0,0 +1,171 @@
"""E2E matrix for custom team metadata validation against the DB-backed proxy.
The proxy (see store_model_db_config.yaml) registers
team_metadata_validator_e2e.validate_team_metadata, which dispatches per
request to one of three independent implementations via the
`_e2e_validator_impl` metadata key: a static allowlist function, an
HTTP-backed function calling the cost center service started by CI, and an
immutability-enforcing class instance. Metadata without the dispatch key is
accepted untouched, so the rest of this suite is unaffected.
"""
import os
import uuid
import httpx
import pytest
PROXY_BASE_URL = os.getenv("PROXY_BASE_URL", "http://localhost:4000")
MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-1234")
HEADERS = {"Authorization": f"Bearer {MASTER_KEY}", "Content-Type": "application/json"}
UNAVAILABLE_MESSAGE = "Cost center validation is unavailable right now; the team was not saved. Contact FinOps."
IMPLS = ["allowlist", "http", "immutable"]
REQUIRED_MESSAGES = {
"allowlist": "cost_center is required in team metadata",
"http": "cost_center missing per cost center service",
"immutable": "cost_center is required in team metadata",
}
UNKNOWN_MESSAGES = {
"allowlist": "is not recognized",
"http": "rejected by cost center service",
}
def _meta(impl, **fields):
return {"_e2e_validator_impl": impl, **fields}
def _create_team(metadata, team_id=None):
body = {"team_alias": f"meta-validate-{uuid.uuid4().hex[:8]}"}
if team_id is not None:
body["team_id"] = team_id
if metadata is not None:
body["metadata"] = metadata
return httpx.post(f"{PROXY_BASE_URL}/team/new", headers=HEADERS, json=body, timeout=30)
def _patch_team(team_id, body):
return httpx.patch(f"{PROXY_BASE_URL}/team/{team_id}", headers=HEADERS, json=body, timeout=30)
def _post_update(team_id, body):
return httpx.post(f"{PROXY_BASE_URL}/team/update", headers=HEADERS, json={"team_id": team_id, **body}, timeout=30)
def _team_info(team_id):
return httpx.get(f"{PROXY_BASE_URL}/team/info", headers=HEADERS, params={"team_id": team_id}, timeout=30)
def _delete_team(team_id):
httpx.post(f"{PROXY_BASE_URL}/team/delete", headers=HEADERS, json={"team_ids": [team_id]}, timeout=30)
@pytest.fixture
def team_with_cost_center(request):
impl = request.param
team_id = f"meta-validate-{impl}-{uuid.uuid4().hex[:8]}"
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"), team_id=team_id)
assert response.status_code == 200, response.text
yield impl, team_id
_delete_team(team_id)
@pytest.mark.parametrize("impl", IMPLS)
def test_create_with_valid_cost_center_succeeds(impl):
response = _create_team(metadata=_meta(impl, cost_center="CC-1001"))
assert response.status_code == 200, response.text
team_id = response.json()["team_id"]
try:
assert response.json()["metadata"]["cost_center"] == "CC-1001"
finally:
_delete_team(team_id)
@pytest.mark.parametrize("impl", IMPLS)
def test_create_without_cost_center_is_rejected(impl):
team_id = f"meta-validate-reject-{impl}-{uuid.uuid4().hex[:8]}"
response = _create_team(metadata=_meta(impl), team_id=team_id)
assert response.status_code == 400, response.text
assert REQUIRED_MESSAGES[impl] in response.text
info = _team_info(team_id)
assert info.status_code == 404, "rejected create must not leave a team row behind"
@pytest.mark.parametrize("impl", IMPLS)
def test_create_with_unknown_cost_center(impl):
response = _create_team(metadata=_meta(impl, cost_center="CC-9999"))
if impl == "immutable":
assert response.status_code == 200, response.text
_delete_team(response.json()["team_id"])
return
assert response.status_code == 400, response.text
assert UNKNOWN_MESSAGES[impl] in response.text
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
def test_patch_changing_cost_center(team_with_cost_center):
impl, team_id = team_with_cost_center
response = _patch_team(team_id, {"metadata": {"cost_center": "CC-1002"}})
if impl == "immutable":
assert response.status_code == 400, response.text
assert "immutable once set" in response.text
info = _team_info(team_id).json()["team_info"]["metadata"]
assert info["cost_center"] == "CC-1001", "blocked update must leave stored metadata intact"
return
assert response.status_code == 200, response.text
assert response.json()["metadata"]["cost_center"] == "CC-1002"
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
def test_patch_unrelated_key_validates_merged_result(team_with_cost_center):
impl, team_id = team_with_cost_center
response = _patch_team(team_id, {"metadata": {"team_notes": "hello"}})
assert response.status_code == 200, response.text
merged = response.json()["metadata"]
assert merged["cost_center"] == "CC-1001"
assert merged["team_notes"] == "hello"
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
def test_patch_null_deleting_cost_center_is_rejected(team_with_cost_center):
impl, team_id = team_with_cost_center
response = _patch_team(team_id, {"metadata": {"cost_center": None}})
assert response.status_code == 400, response.text
assert REQUIRED_MESSAGES[impl] in response.text
info = _team_info(team_id).json()["team_info"]["metadata"]
assert info["cost_center"] == "CC-1001"
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
def test_post_update_dropping_cost_center_is_rejected(team_with_cost_center):
impl, team_id = team_with_cost_center
response = _post_update(team_id, {"metadata": _meta(impl, team_notes="only-notes")})
assert response.status_code == 400, response.text
assert REQUIRED_MESSAGES[impl] in response.text
@pytest.mark.parametrize("team_with_cost_center", IMPLS, indirect=True)
def test_update_without_metadata_skips_validation(team_with_cost_center):
impl, team_id = team_with_cost_center
response = _post_update(team_id, {"tpm_limit": 55})
assert response.status_code == 200, response.text
def test_http_service_outage_fails_closed_with_configured_message():
response = _create_team(metadata=_meta("http_down", cost_center="CC-1001"))
assert response.status_code == 503, response.text
assert UNAVAILABLE_MESSAGE in response.text
def test_metadata_without_dispatch_key_is_untouched():
response = _create_team(metadata={"any_key": "any_value"})
assert response.status_code == 200, response.text
team_id = response.json()["team_id"]
try:
update = _post_update(team_id, {"metadata": {"any_key": "changed"}})
assert update.status_code == 200, update.text
finally:
_delete_team(team_id)

View file

@ -9863,11 +9863,14 @@ async def _drive_team_write(
raw_body=None,
user=None,
find_returns_none=False,
mock_sink=None,
):
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
Returns ``(endpoint_result, update_mock)``; propagates whatever the endpoint
raises. Inspect ``update_mock.call_args.kwargs["data"]`` for the DB write.
Pass a dict as ``mock_sink`` to receive the update mock even when the
endpoint raises.
"""
from unittest.mock import AsyncMock, MagicMock, Mock
from unittest.mock import patch as _patch
@ -9913,6 +9916,8 @@ async def _drive_team_write(
return_value=LiteLLM_TeamTable(team_id=_PATCH_TEAM_ID, team_alias="t")
)
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
if mock_sink is not None:
mock_sink["update"] = pc.db.litellm_teamtable.update
req = Mock(spec=Request)
if kind == "post":
@ -10175,6 +10180,249 @@ async def test_patch_returns_full_team_object_not_wrapper():
assert result.team_id == _PATCH_TEAM_ID
# ---------------------------------------------------------------------------
# custom_team_metadata_validate wiring: the configured validator must gate
# every team write path (POST /team/new, POST /team/update, PATCH /team/{id})
# and must see the metadata that will actually be written.
# ---------------------------------------------------------------------------
from contextlib import contextmanager
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_VALIDATOR_REGISTRY,
TeamMetadataValidationResult,
)
@contextmanager
def _configured_team_metadata_validator(validator):
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
try:
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
yield
finally:
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
def _recording_validator(recorded, valid=True, error_message=None):
async def validator(payload):
recorded.append(payload)
return TeamMetadataValidationResult(valid=valid, error_message=error_message)
return validator
@pytest.mark.asyncio
async def test_update_validator_sees_replacement_on_post_and_merged_on_patch():
"""POST hands the validator the wholesale replacement; PATCH hands it the
RFC 7386 merged result including preserved keys."""
existing = {"cost_center": "OLD", "keep": 1}
body = {"metadata": {"cost_center": "NEW"}}
recorded_post = []
with _configured_team_metadata_validator(_recording_validator(recorded_post)):
await _drive_team_write("post", existing_metadata=existing, payload=body)
recorded_patch = []
with _configured_team_metadata_validator(_recording_validator(recorded_patch)):
await _drive_team_write("patch", existing_metadata=existing, payload=body)
assert len(recorded_post) == 1
assert recorded_post[0].operation == "update"
assert recorded_post[0].metadata == {"cost_center": "NEW"}
assert recorded_post[0].existing_metadata == existing
assert len(recorded_patch) == 1
assert recorded_patch[0].operation == "update"
assert recorded_patch[0].metadata == {"cost_center": "NEW", "keep": 1}
assert recorded_patch[0].existing_metadata == existing
@pytest.mark.asyncio
async def test_patch_null_delete_removes_key_from_validated_metadata():
"""Deleting a key via PATCH null must be visible to the validator as the
key's absence in the resulting metadata, so a required key cannot be
silently dropped."""
recorded = []
with _configured_team_metadata_validator(_recording_validator(recorded)):
await _drive_team_write(
"patch",
existing_metadata={"cost_center": "OLD", "keep": 1},
payload={"metadata": {"cost_center": None}},
)
assert len(recorded) == 1
assert recorded[0].metadata == {"keep": 1}
assert recorded[0].existing_metadata == {"cost_center": "OLD", "keep": 1}
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
async def test_update_without_metadata_skips_validator(kind):
recorded = []
with _configured_team_metadata_validator(_recording_validator(recorded)):
await _drive_team_write(kind, existing_metadata={"k": "v"}, payload={"tpm_limit": 5})
assert recorded == []
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
async def test_update_validator_rejection_blocks_db_write(kind):
recorded = []
validator = _recording_validator(recorded, valid=False, error_message="cost center rejected, contact FinOps")
sink = {}
with _configured_team_metadata_validator(validator):
with pytest.raises(ProxyException) as exc_info:
await _drive_team_write(
kind,
existing_metadata={"cost_center": "OLD"},
payload={"metadata": {"cost_center": "BAD"}},
mock_sink=sink,
)
assert str(exc_info.value.code) == "400"
assert "cost center rejected, contact FinOps" in str(exc_info.value.message)
assert len(recorded) == 1
sink["update"].assert_not_awaited()
@pytest.mark.asyncio
async def test_new_team_validator_runs_without_metadata_and_rejection_blocks_create():
"""Create always validates, even when the request carries no metadata, so a
required-key policy can reject a team created without one."""
from fastapi import Request
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import new_team
recorded = []
validator = _recording_validator(recorded, valid=False, error_message="cost_center is required")
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server._license_check") as mock_license,
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
_configured_team_metadata_validator(validator),
):
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_prisma.db.litellm_teamtable.create = AsyncMock()
mock_license.is_team_count_over_limit.return_value = False
with pytest.raises(ProxyException) as exc_info:
await new_team(
data=NewTeamRequest(team_alias="no-metadata-team"),
http_request=MagicMock(spec=Request),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
)
assert str(exc_info.value.code) == "400"
assert "cost_center is required" in str(exc_info.value.message)
assert len(recorded) == 1
assert recorded[0].operation == "create"
assert recorded[0].metadata == {}
assert recorded[0].existing_metadata is None
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
@pytest.mark.asyncio
async def test_new_team_validator_accept_proceeds_to_create(mock_db_client, mock_admin_auth):
from fastapi import Request
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import new_team
mock_db_client.jsonify_team_object = lambda db_data: db_data
mock_db_client.get_data = AsyncMock(return_value=None)
mock_db_client.update_data = AsyncMock(return_value=MagicMock())
mock_db_client.db = MagicMock()
team_create_result = MagicMock(team_id="team-accept-1")
team_create_result.model_dump.return_value = {"team_id": "team-accept-1"}
mock_db_client.db.litellm_teamtable = MagicMock()
mock_db_client.db.litellm_teamtable.create = AsyncMock(return_value=team_create_result)
mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=team_create_result)
mock_db_client.db.litellm_usertable = MagicMock()
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
recorded = []
with _configured_team_metadata_validator(_recording_validator(recorded)):
await new_team(
data=NewTeamRequest(team_alias="accepted-team", metadata={"cost_center": "CC-1001"}),
http_request=MagicMock(spec=Request),
user_api_key_dict=mock_admin_auth,
)
assert len(recorded) == 1
assert recorded[0].operation == "create"
assert recorded[0].metadata == {"cost_center": "CC-1001"}
mock_db_client.db.litellm_teamtable.create.assert_awaited_once()
@pytest.mark.asyncio
async def test_new_team_rejection_precedes_model_alias_write():
"""A rejected create must not leave an orphaned LiteLLM_ModelTable row:
validation runs before the model_aliases insert."""
from fastapi import Request
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import new_team
validator = _recording_validator([], valid=False, error_message="cost_center is required")
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server._license_check") as mock_license,
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
_configured_team_metadata_validator(validator),
):
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_prisma.db.litellm_teamtable.create = AsyncMock()
mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
mock_license.is_team_count_over_limit.return_value = False
with pytest.raises(ProxyException):
await new_team(
data=NewTeamRequest(
team_alias="alias-orphan-check",
model_aliases={"alias-model": "gpt-4o"},
),
http_request=MagicMock(spec=Request),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"),
)
mock_prisma.db.litellm_modeltable.create.assert_not_awaited()
mock_prisma.db.litellm_teamtable.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
async def test_update_existing_metadata_excludes_system_managed_keys(kind):
"""The validator's existing_metadata must be symmetric with metadata:
server-owned keys (team_member_budget_id) are stripped from both, so a
key-preservation validator never sees them 'disappear'."""
recorded = []
stored = {"cost_center": "CC-1001", "team_member_budget_id": "budget-abc"}
with _configured_team_metadata_validator(_recording_validator(recorded)):
await _drive_team_write(
kind,
existing_metadata=dict(stored),
payload={"metadata": {"notes": "x"}},
)
assert len(recorded) == 1
assert recorded[0].existing_metadata == {"cost_center": "CC-1001"}
assert "team_member_budget_id" not in recorded[0].metadata
# ---------------------------------------------------------------------------
# PATCH body is validated through PatchTeamRequest before it is handed to
# update_team. The write below must stay byte-identical to what the untyped
@ -10340,6 +10588,76 @@ async def test_list_available_teams_filters_joined_and_validates_rows(monkeypatc
assert find_many_kwargs["where"] == {"team_id": {"in": ["team-open"]}}
@pytest.mark.asyncio
async def test_get_team_metadata_schema_returns_configured_fields():
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
parse_team_metadata_schema,
)
TEAM_METADATA_SCHEMA_REGISTRY.set(
parse_team_metadata_schema(
[
{"key": "cost_center", "label": "Cost Center"},
{"key": "app_name", "label": "Application Name"},
]
)
)
try:
result = await get_team_metadata_schema()
finally:
TEAM_METADATA_SCHEMA_REGISTRY.set(())
assert [field.key for field in result.fields] == ["cost_center", "app_name"]
assert result.fields[0].label == "Cost Center"
assert result.fields[1].label == "Application Name"
@pytest.mark.asyncio
async def test_get_team_metadata_schema_empty_when_unconfigured():
from litellm.proxy.management_endpoints.team_endpoints import get_team_metadata_schema
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
)
TEAM_METADATA_SCHEMA_REGISTRY.set(())
result = await get_team_metadata_schema()
assert result.fields == ()
def test_get_team_metadata_schema_route_requires_auth():
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
parse_team_metadata_schema,
)
with patch("litellm.proxy.proxy_server.master_key", "sk-1234"):
response = client.get("/team/metadata_schema")
assert response.status_code == 401
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema([{"key": "cost_center", "label": "Cost Center"}]))
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1"
)
try:
authed = client.get("/team/metadata_schema")
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
TEAM_METADATA_SCHEMA_REGISTRY.set(())
assert authed.status_code == 200
assert authed.json() == {"fields": [{"key": "cost_center", "label": "Cost Center"}]}
def test_team_metadata_schema_route_is_readable_by_non_admins():
from litellm.proxy._types import LiteLLMRoutes
assert "/team/metadata_schema" in LiteLLMRoutes.info_routes.value
assert "/team/metadata_schema" in LiteLLMRoutes.management_routes.value
def _provisioning_caller(role: LitellmUserRoles) -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id="caller-1", user_role=role)

View file

@ -0,0 +1,92 @@
"""Three independent `custom_team_metadata_validate` implementations.
Used by the matrix tests in `test_team_metadata_validation.py` and loadable
directly from a proxy config via `get_instance_fn` for live verification:
- `validate_allowlist`: plain async function; requires `cost_center` and
checks it against a static allowlist.
- `validate_via_http`: async function that POSTs the metadata to an external
validation service (`TEAM_METADATA_VALIDATION_SERVICE_URL`); any transport
error or non-2xx response raises, exercising the fail-closed path.
- `IMMUTABLE_COST_CENTER_VALIDATOR`: class instance with an async
`__call__`; requires `cost_center` and forbids changing it once set, using
`existing_metadata` and `operation`.
"""
import os
import httpx
from litellm.proxy.management_helpers.team_metadata_validation import (
TeamMetadataValidationPayload,
TeamMetadataValidationResult,
)
ALLOWED_COST_CENTERS = frozenset({"CC-1001", "CC-1002"})
DEFAULT_SERVICE_URL = "http://localhost:9414/validate"
async def validate_allowlist(
payload: TeamMetadataValidationPayload,
) -> TeamMetadataValidationResult:
cost_center = payload.metadata.get("cost_center")
if cost_center is None:
return TeamMetadataValidationResult(
valid=False,
error_message="cost_center is required in team metadata. Contact the FinOps team.",
)
if cost_center not in ALLOWED_COST_CENTERS:
return TeamMetadataValidationResult(
valid=False,
error_message=f"Cost center {cost_center} is not recognized. Contact the FinOps team.",
)
return TeamMetadataValidationResult(valid=True)
async def validate_via_http(
payload: TeamMetadataValidationPayload,
) -> TeamMetadataValidationResult:
service_url = os.environ.get("TEAM_METADATA_VALIDATION_SERVICE_URL", DEFAULT_SERVICE_URL)
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.post(
service_url,
json={"operation": payload.operation, "metadata": payload.metadata},
)
response.raise_for_status()
body = response.json()
if body.get("ok") is True:
return TeamMetadataValidationResult(valid=True)
return TeamMetadataValidationResult(
valid=False,
error_message=body.get("reason", "Rejected by the cost center service."),
)
class ImmutableCostCenterValidator:
def __init__(self, immutable_key: str = "cost_center") -> None:
self.immutable_key = immutable_key
async def __call__(
self,
payload: TeamMetadataValidationPayload,
) -> TeamMetadataValidationResult:
current = payload.metadata.get(self.immutable_key)
if current is None:
return TeamMetadataValidationResult(
valid=False,
error_message=f"{self.immutable_key} is required in team metadata. Contact the FinOps team.",
)
if payload.operation == "update" and payload.existing_metadata is not None:
prior = payload.existing_metadata.get(self.immutable_key)
if prior is not None and prior != current:
return TeamMetadataValidationResult(
valid=False,
error_message=(
f"{self.immutable_key} is immutable once set "
f"(stored: {prior}, requested: {current}). Contact the FinOps team."
),
)
return TeamMetadataValidationResult(valid=True)
IMMUTABLE_COST_CENTER_VALIDATOR = ImmutableCostCenterValidator()

View file

@ -0,0 +1,704 @@
import asyncio
import os
import sys
from unittest.mock import patch
import pytest
from fastapi import HTTPException
sys.path.insert(0, os.path.abspath("../../../.."))
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_helpers.team_metadata_validation import (
DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE,
DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS,
DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE,
TeamMetadataRequester,
TeamMetadataValidationPayload,
TeamMetadataValidationResult,
TeamMetadataValidatorRegistry,
_read_timeout_seconds,
_read_unavailable_message,
run_team_metadata_validation,
validate_team_metadata_if_configured,
)
def _registry_with(validator):
registry = TeamMetadataValidatorRegistry()
registry.set(validator)
return registry
UNAVAILABLE_MESSAGE = "validation system down, contact ops"
def _payload(**overrides):
values = {
"operation": "create",
"metadata": {"cost_center": "CC-1001"},
"existing_metadata": None,
"team_id": "team-1",
"team_alias": "alias-1",
"requester": TeamMetadataRequester(user_id="u1"),
}
values.update(overrides)
return TeamMetadataValidationPayload(**values)
async def _run(validator, payload=None, premium_user=True, timeout_seconds=1.0):
await run_team_metadata_validation(
validator=validator,
payload=payload or _payload(),
premium_user=premium_user,
timeout_seconds=timeout_seconds,
unavailable_message=UNAVAILABLE_MESSAGE,
)
@pytest.mark.asyncio
async def test_valid_result_passes():
async def validator(payload):
return TeamMetadataValidationResult(valid=True)
await _run(validator)
@pytest.mark.asyncio
async def test_rejection_raises_400_with_validator_message():
async def validator(payload):
return TeamMetadataValidationResult(valid=False, error_message="cost center rejected, contact FinOps")
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == {"error": "cost center rejected, contact FinOps"}
@pytest.mark.asyncio
async def test_rejection_without_message_uses_default():
async def validator(payload):
return TeamMetadataValidationResult(valid=False)
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == {"error": DEFAULT_TEAM_METADATA_VALIDATION_REJECTED_MESSAGE}
@pytest.mark.asyncio
async def test_dict_shaped_return_is_accepted():
async def validator(payload):
return {"valid": False, "error_message": "rejected via dict"}
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == {"error": "rejected via dict"}
@pytest.mark.asyncio
async def test_validator_exception_fails_closed_with_generic_message():
async def validator(payload):
raise RuntimeError("internal validation service is down")
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
@pytest.mark.asyncio
async def test_validator_timeout_fails_closed():
async def validator(payload):
await asyncio.sleep(1.0)
return TeamMetadataValidationResult(valid=True)
with pytest.raises(HTTPException) as exc_info:
await _run(validator, timeout_seconds=0.01)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
@pytest.mark.asyncio
async def test_malformed_return_shape_fails_closed():
async def validator(payload):
return "not-a-validation-result"
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == {"error": UNAVAILABLE_MESSAGE}
@pytest.mark.asyncio
async def test_non_premium_user_is_rejected():
async def validator(payload):
return TeamMetadataValidationResult(valid=True)
with pytest.raises(HTTPException) as exc_info:
await _run(validator, premium_user=False)
assert exc_info.value.status_code == 400
assert CommonProxyErrors.not_premium_user.value in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_non_coroutine_validator_is_rejected():
def validator(payload):
return TeamMetadataValidationResult(valid=True)
with pytest.raises(HTTPException) as exc_info:
await _run(validator)
assert exc_info.value.status_code == 500
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
@pytest.mark.parametrize(
"general_settings, expected",
[
({}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
({"team_metadata_validation_timeout": 2}, 2.0),
({"team_metadata_validation_timeout": 0.5}, 0.5),
({"team_metadata_validation_timeout": True}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
({"team_metadata_validation_timeout": -1}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
({"team_metadata_validation_timeout": 0}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
({"team_metadata_validation_timeout": "3"}, DEFAULT_TEAM_METADATA_VALIDATION_TIMEOUT_SECONDS),
],
)
def test_read_timeout_seconds(general_settings, expected):
assert _read_timeout_seconds(general_settings) == expected
@pytest.mark.parametrize(
"general_settings, expected",
[
({}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
(
{"team_metadata_validation_error_message": "call the help desk"},
"call the help desk",
),
({"team_metadata_validation_error_message": " "}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
({"team_metadata_validation_error_message": None}, DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE),
],
)
def test_read_unavailable_message(general_settings, expected):
assert _read_unavailable_message(general_settings) == expected
@pytest.mark.asyncio
async def test_adapter_is_noop_when_unconfigured():
calls = []
async def validator(payload):
calls.append(payload)
return TeamMetadataValidationResult(valid=True)
await validate_team_metadata_if_configured(
operation="create",
metadata={"cost_center": "CC-1001"},
existing_metadata=None,
team_id="team-1",
team_alias="alias-1",
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
registry=TeamMetadataValidatorRegistry(),
)
assert calls == []
@pytest.mark.asyncio
async def test_adapter_builds_payload_and_reads_settings():
recorded = []
async def validator(payload):
recorded.append(payload)
return TeamMetadataValidationResult(valid=True)
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch(
"litellm.proxy.proxy_server.general_settings",
{"team_metadata_validation_timeout": 3, "team_metadata_validation_error_message": "ops msg"},
),
):
await validate_team_metadata_if_configured(
operation="update",
metadata={"cost_center": "CC-2001"},
existing_metadata={"cost_center": "CC-1001", "keep": 1},
team_id="team-9",
team_alias="alias-9",
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="user-9",
user_email="user-9@example.com",
),
registry=_registry_with(validator),
)
assert len(recorded) == 1
payload = recorded[0]
assert payload.operation == "update"
assert payload.metadata == {"cost_center": "CC-2001"}
assert payload.existing_metadata == {"cost_center": "CC-1001", "keep": 1}
assert payload.team_id == "team-9"
assert payload.team_alias == "alias-9"
assert payload.requester.user_id == "user-9"
assert payload.requester.user_email == "user-9@example.com"
assert payload.requester.user_role == LitellmUserRoles.INTERNAL_USER.value
@pytest.mark.asyncio
async def test_adapter_normalizes_non_dict_metadata_to_empty_dict():
recorded = []
async def validator(payload):
recorded.append(payload)
return TeamMetadataValidationResult(valid=True)
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
await validate_team_metadata_if_configured(
operation="create",
metadata=None,
existing_metadata=None,
team_id="team-1",
team_alias=None,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
registry=_registry_with(validator),
)
assert len(recorded) == 1
assert recorded[0].metadata == {}
assert recorded[0].existing_metadata is None
# ---------------------------------------------------------------------------
# Validator implementation matrix: three independent implementations
# (allowlist function, HTTP-service-backed function, immutability class
# instance) driven through the real team write endpoints.
# ---------------------------------------------------------------------------
import json as _json
import socket
import threading
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import AsyncMock, MagicMock, Mock
import team_metadata_validator_impls as impls
from litellm.proxy._types import ProxyException
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_VALIDATOR_REGISTRY,
)
class _CostCenterServiceHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = _json.loads(self.rfile.read(length) or b"{}")
cost_center = (body.get("metadata") or {}).get("cost_center")
if cost_center is None:
resp = {"ok": False, "reason": "cost_center missing per cost center service"}
elif cost_center not in impls.ALLOWED_COST_CENTERS:
resp = {"ok": False, "reason": f"cost center {cost_center} rejected by cost center service"}
else:
resp = {"ok": True}
payload = _json.dumps(resp).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, format, *args):
pass
@pytest.fixture(scope="module")
def cost_center_service_url():
server = ThreadingHTTPServer(("127.0.0.1", 0), _CostCenterServiceHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/validate"
finally:
server.shutdown()
thread.join(timeout=5)
def _closed_port_url():
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
return f"http://127.0.0.1:{port}/validate"
@contextmanager
def _configured(validator):
TEAM_METADATA_VALIDATOR_REGISTRY.set(validator)
try:
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.general_settings", {}),
):
yield
finally:
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
async def _drive_create(metadata, mock_sink=None):
from fastapi import Request
from litellm.proxy._types import LiteLLM_TeamTable, NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import new_team
with (
patch("litellm.proxy.proxy_server.prisma_client") as pc,
patch("litellm.proxy.proxy_server._license_check") as lic,
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
):
team_row = MagicMock(team_id="matrix-team-1")
team_row.model_dump.return_value = {"team_id": "matrix-team-1"}
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
pc.get_data = AsyncMock(return_value=None)
pc.update_data = AsyncMock(return_value=MagicMock())
pc.db.litellm_teamtable.create = AsyncMock(return_value=team_row)
pc.db.litellm_teamtable.count = AsyncMock(return_value=0)
pc.db.litellm_teamtable.update = AsyncMock(return_value=team_row)
pc.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
pc.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model-1"))
lic.is_team_count_over_limit.return_value = False
if mock_sink is not None:
mock_sink["team_create"] = pc.db.litellm_teamtable.create
mock_sink["model_create"] = pc.db.litellm_modeltable.create
request_kwargs = {"team_alias": "matrix-team"}
if metadata is not None:
request_kwargs["metadata"] = metadata
return await new_team(
data=NewTeamRequest(**request_kwargs),
http_request=MagicMock(spec=Request),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
async def _drive_update(kind, existing_metadata, payload):
from fastapi import Request
from litellm.proxy._types import LiteLLM_TeamTable, PatchTeamRequest, UpdateTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import patch_team, update_team
team_id = "matrix-team-upd"
existing = LiteLLM_TeamTable(
team_id=team_id,
team_alias="matrix",
metadata=existing_metadata,
organization_id=None,
)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
with (
patch("litellm.proxy.proxy_server.prisma_client") as pc,
patch("litellm.proxy.proxy_server.llm_router", None),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch(
"litellm.proxy.management_endpoints.team_endpoints._refresh_cached_team",
new=AsyncMock(),
),
):
pc.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing)
pc.db.litellm_teamtable.update = AsyncMock(
return_value=LiteLLM_TeamTable(team_id=team_id, team_alias="matrix")
)
pc.jsonify_team_object = MagicMock(side_effect=lambda db_data: db_data)
req = Mock(spec=Request)
if kind == "post":
return await update_team(
data=UpdateTeamRequest(team_id=team_id, **payload),
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
return await patch_team(
team_id=team_id,
data=PatchTeamRequest.model_validate(dict(payload)),
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
)
OK = ("ok", None)
_MATRIX_IMPLS = {
"allowlist": lambda: impls.validate_allowlist,
"http": lambda: impls.validate_via_http,
"immutable_class": lambda: impls.IMMUTABLE_COST_CENTER_VALIDATOR,
}
# (scenario, kind, existing_metadata, request payload, {impl: expected})
# expected is ("ok", None) or ("reject", <message substring>)
_MATRIX_SCENARIOS = [
(
"create-valid-cost-center",
"create",
None,
{"cost_center": "CC-1001"},
{"allowlist": OK, "http": OK, "immutable_class": OK},
),
(
"create-missing-cost-center",
"create",
None,
None,
{
"allowlist": ("reject", "cost_center is required"),
"http": ("reject", "cost_center missing per cost center service"),
"immutable_class": ("reject", "cost_center is required"),
},
),
(
"create-unknown-cost-center",
"create",
None,
{"cost_center": "CC-9999"},
{
"allowlist": ("reject", "is not recognized"),
"http": ("reject", "rejected by cost center service"),
"immutable_class": OK,
},
),
(
"patch-change-cost-center",
"patch",
{"cost_center": "CC-1001"},
{"metadata": {"cost_center": "CC-1002"}},
{
"allowlist": OK,
"http": OK,
"immutable_class": ("reject", "immutable once set"),
},
),
(
"patch-unrelated-key-preserves-cost-center",
"patch",
{"cost_center": "CC-1001"},
{"metadata": {"notes": "hello"}},
{"allowlist": OK, "http": OK, "immutable_class": OK},
),
(
"patch-null-deletes-cost-center",
"patch",
{"cost_center": "CC-1001"},
{"metadata": {"cost_center": None}},
{
"allowlist": ("reject", "cost_center is required"),
"http": ("reject", "cost_center missing per cost center service"),
"immutable_class": ("reject", "cost_center is required"),
},
),
(
"post-replace-drops-cost-center",
"post",
{"cost_center": "CC-1001"},
{"metadata": {"notes": "only-notes"}},
{
"allowlist": ("reject", "cost_center is required"),
"http": ("reject", "cost_center missing per cost center service"),
"immutable_class": ("reject", "cost_center is required"),
},
),
(
"update-without-metadata-skips-validation",
"post",
{"cost_center": "CC-1001"},
{"tpm_limit": 5},
{"allowlist": OK, "http": OK, "immutable_class": OK},
),
]
@pytest.mark.asyncio
@pytest.mark.parametrize("impl_name", sorted(_MATRIX_IMPLS))
@pytest.mark.parametrize(
"scenario, kind, existing_metadata, request_payload, expectations",
_MATRIX_SCENARIOS,
ids=[row[0] for row in _MATRIX_SCENARIOS],
)
async def test_validator_implementation_matrix(
monkeypatch,
cost_center_service_url,
impl_name,
scenario,
kind,
existing_metadata,
request_payload,
expectations,
):
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", cost_center_service_url)
validator = _MATRIX_IMPLS[impl_name]()
outcome, message_part = expectations[impl_name]
async def drive():
if kind == "create":
return await _drive_create(metadata=request_payload)
return await _drive_update(kind, existing_metadata, request_payload)
with _configured(validator):
if outcome == "ok":
await drive()
else:
with pytest.raises(ProxyException) as exc_info:
await drive()
assert str(exc_info.value.code) == "400", f"{scenario} x {impl_name}"
assert message_part in str(exc_info.value.message), f"{scenario} x {impl_name}"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"kind, existing_metadata, request_payload",
[
("create", None, {"cost_center": "CC-1001"}),
("patch", {"cost_center": "CC-1001"}, {"metadata": {"notes": "x"}}),
],
)
async def test_http_validator_service_outage_fails_closed(monkeypatch, kind, existing_metadata, request_payload):
monkeypatch.setenv("TEAM_METADATA_VALIDATION_SERVICE_URL", _closed_port_url())
with _configured(impls.validate_via_http):
with pytest.raises(ProxyException) as exc_info:
if kind == "create":
await _drive_create(metadata=request_payload)
else:
await _drive_update(kind, existing_metadata, request_payload)
assert str(exc_info.value.code) == "503"
assert DEFAULT_TEAM_METADATA_VALIDATION_UNAVAILABLE_MESSAGE in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_class_instance_with_async_call_is_accepted():
await _run(impls.ImmutableCostCenterValidator(), payload=_payload(metadata={"cost_center": "CC-1"}))
@pytest.mark.asyncio
async def test_class_instance_with_sync_call_is_rejected():
class SyncValidator:
def __call__(self, payload):
return TeamMetadataValidationResult(valid=True)
with pytest.raises(HTTPException) as exc_info:
await _run(SyncValidator())
assert exc_info.value.status_code == 500
from litellm.proxy.management_helpers.team_metadata_validation import (
TeamMetadataSchemaRegistry,
parse_team_metadata_schema,
)
def test_parse_schema_none_returns_empty():
assert parse_team_metadata_schema(None) == ()
def test_parse_schema_round_trips_fields_in_order():
raw = [
{"key": "cost_center", "label": "Cost Center"},
{"key": "app_name"},
]
fields = parse_team_metadata_schema(raw)
assert [field.key for field in fields] == ["cost_center", "app_name"]
assert fields[0].label == "Cost Center"
assert fields[1].label is None
@pytest.mark.parametrize(
"raw",
[
"cost_center",
{"key": "cost_center"},
[{"label": "missing key"}],
[{"key": ""}],
[{"key": "cost_center", "required": True}],
[{"key": "cost_center", "description": "Cost center code"}],
[{"key": "cost_center", "allowed_values": ["CC-1001"]}],
],
)
def test_parse_schema_malformed_raises(raw):
with pytest.raises(Exception):
parse_team_metadata_schema(raw)
def test_parse_schema_duplicate_keys_raise():
with pytest.raises(ValueError, match="duplicate"):
parse_team_metadata_schema([{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}])
def test_schema_registry_defaults_empty_and_round_trips():
registry = TeamMetadataSchemaRegistry()
assert registry.get() == ()
fields = parse_team_metadata_schema([{"key": "cost_center"}])
registry.set(fields)
assert registry.get() == fields
registry.set(())
assert registry.get() == ()
@pytest.mark.asyncio
async def test_non_callable_validator_is_rejected_with_clean_500():
class NotCallable:
pass
with pytest.raises(HTTPException) as exc_info:
await _run(NotCallable())
assert exc_info.value.status_code == 500
assert exc_info.value.detail == {"error": "custom_team_metadata_validate must be an async function"}
def test_parse_schema_duplicate_error_lists_offending_keys():
with pytest.raises(ValueError) as exc_info:
parse_team_metadata_schema(
[{"key": "cost_center"}, {"key": "app_name"}, {"key": "cost_center"}, {"key": "app_name"}]
)
assert str(exc_info.value) == "team_metadata_schema contains duplicate keys: app_name, cost_center"
@pytest.mark.asyncio
async def test_adapter_applies_configured_timeout_to_slow_validator():
async def slow_validator(payload):
await asyncio.sleep(0.2)
return TeamMetadataValidationResult(valid=True)
registry = TeamMetadataValidatorRegistry()
registry.set(slow_validator)
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch(
"litellm.proxy.proxy_server.general_settings",
{"team_metadata_validation_timeout": 0.01},
),
):
with pytest.raises(HTTPException) as exc_info:
await validate_team_metadata_if_configured(
operation="create",
metadata={"cost_center": "CC-1001"},
existing_metadata=None,
team_id="team-1",
team_alias="alias-1",
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="u1"),
registry=registry,
)
assert exc_info.value.status_code == 503

View file

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

View file

@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchTeamMetadataSchema } from "./useTeamMetadataSchema";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
describe("fetchTeamMetadataSchema", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("should return the declared fields from the response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ fields: [{ key: "cost_center", label: "Cost Center", required: true }] }),
}),
);
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([
{ key: "cost_center", label: "Cost Center", required: true },
]);
});
it("should return an empty list when the response has no fields array", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "{}" }));
await expect(fetchTeamMetadataSchema("sk-test")).resolves.toEqual([]);
});
it("should throw on a non-ok response so the query can retry and fail open", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, text: async () => "" }));
await expect(fetchTeamMetadataSchema("sk-test")).rejects.toThrow("404");
});
});

View file

@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { createApiClient } from "@/lib/http/client";
export interface TeamMetadataField {
key: string;
label?: string | null;
}
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
const silentApiClient = createApiClient({
getBaseUrl: getProxyBaseUrl,
getAuthHeaderName: getGlobalLitellmHeaderName,
});
export const fetchTeamMetadataSchema = async (accessToken: string): Promise<TeamMetadataField[]> => {
const data = await silentApiClient.get<{ fields?: TeamMetadataField[] }>("/team/metadata_schema", { accessToken });
return Array.isArray(data?.fields) ? data.fields : [];
};
export const teamMetadataSchemaKeys = createQueryKeys("teamMetadataSchema");
export const useTeamMetadataSchema = () => {
const { accessToken } = useAuthorized();
return useQuery<TeamMetadataField[]>({
queryKey: teamMetadataSchemaKeys.list({}),
queryFn: async () => await fetchTeamMetadataSchema(accessToken!),
enabled: Boolean(accessToken),
staleTime: TWENTY_FOUR_HOURS_MS,
gcTime: TWENTY_FOUR_HOURS_MS,
retry: 1,
});
};

View file

@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import NotificationsManager from "./molecules/notifications_manager";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
import Teams from "./Teams";
@ -33,6 +35,10 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
teamsTableKeys: { all: ["teamsTable"] },
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
}));
vi.mock("./molecules/notifications_manager", () => ({
default: {
info: vi.fn(),
@ -617,6 +623,210 @@ describe("Teams - access_group_ids in team create", () => {
});
});
describe("Teams - metadata key-value pairs in team create", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamCreateCall).mockResolvedValue({
team_id: "new-team-1",
team_alias: "Test Team",
models: ["gpt-4"],
organization_id: null,
keys: [],
members_with_roles: [],
spend: 0,
});
mockUseOrganizations.mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }],
});
});
const openCreateModal = async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
act(() => {
fireEvent.click(createButton);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
};
it("renders the metadata editor in the main form without opening Additional Settings", async () => {
await openCreateModal();
expect(screen.getByRole("button", { name: /add key-value pair/i })).toBeInTheDocument();
});
it("submits metadata built from key-value pairs as a typed JSON object", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Key")).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText("Key"), { target: { value: "cost_center" } });
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "eng-42" } });
fireEvent.click(screen.getByRole("button", { name: /add key-value pair/i }));
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key")).toHaveLength(2);
});
fireEvent.change(screen.getAllByPlaceholderText("Key")[1], { target: { value: "tier" } });
fireEvent.change(screen.getAllByPlaceholderText("Value")[1], { target: { value: "3" } });
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalled();
});
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "eng-42", tier: 3 });
});
it("omits metadata entirely when no pairs are added", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalled();
});
expect(vi.mocked(teamCreateCall).mock.calls[0][1].metadata).toBeUndefined();
});
});
describe("Teams - schema-declared metadata fields in team create", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamCreateCall).mockResolvedValue({
team_id: "new-team-1",
team_alias: "Test Team",
models: ["gpt-4"],
organization_id: null,
keys: [],
members_with_roles: [],
spend: 0,
});
mockUseOrganizations.mockReturnValue({ data: null });
vi.mocked(useTeamMetadataSchema).mockReturnValue({
data: [{ key: "cost_center", label: "Cost Center" }],
isLoading: false,
} as any);
});
const openCreateModal = async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
act(() => {
fireEvent.click(createButton);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
};
it("should prepopulate the declared key as an ordinary pair row and submit its value", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
await waitFor(() => {
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
});
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-1001" } });
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalled();
});
const submittedValues = vi.mocked(teamCreateCall).mock.calls[0][1];
expect(JSON.parse(submittedValues.metadata)).toEqual({ cost_center: "CC-1001" });
});
it("should toast only the validator's own message when the backend rejects the create", async () => {
vi.mocked(teamCreateCall).mockRejectedValue(
new Error("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
);
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
await waitFor(() => {
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
});
fireEvent.change(screen.getByPlaceholderText("Value"), { target: { value: "CC-9999" } });
const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]);
await waitFor(() => {
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
"Error creating the team: Cost center CC-9999 is not recognized. Contact the FinOps team.",
);
});
});
it("should show a skeleton in the metadata section while the schema is loading", async () => {
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: undefined, isLoading: true } as any);
await openCreateModal();
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
});
it("should re-seed declared keys when the create modal is closed and reopened", async () => {
await openCreateModal();
await waitFor(() => {
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
});
fireEvent.click(screen.getByLabelText("Remove key-value pair"));
await waitFor(() => {
expect(screen.queryByPlaceholderText("Key")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
await waitFor(() => {
expect(screen.queryByLabelText(/team name/i)).not.toBeInTheDocument();
});
const createButton = screen.getAllByRole("button", { name: /create team/i })[0];
act(() => {
fireEvent.click(createButton);
});
await waitFor(() => {
expect((screen.getByPlaceholderText("Key") as HTMLInputElement).value).toBe("cost_center");
});
});
});
describe("Teams - models dropdown options", () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -15,6 +15,8 @@ import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useTeamDetailRouting } from "@/app/(dashboard)/teams/detailNavigation";
import { TeamsTable } from "./TeamsPage/TeamsTable";
import AccessGroupSelector from "./common_components/AccessGroupSelector";
import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields";
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector";
import AgentSelector from "./agent_management/AgentSelector";
import ModelAliasManager from "./common_components/ModelAliasManager";
@ -28,6 +30,7 @@ import type { Team } from "./key_team_helpers/key_list";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
import NotificationsManager from "./molecules/notifications_manager";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking";
import NumericalInput from "./shared/numerical_input";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
@ -125,6 +128,7 @@ const getOrganizationAlias = (
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
const { data: organizationsData } = useOrganizations();
const organizations = organizationsData ?? null;
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all });
const [currentOrg] = useState<Organization | null>(null);
@ -322,25 +326,11 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
NotificationsManager.info("Creating Team");
// Handle logging settings in metadata
if (loggingSettings.length > 0) {
let metadata = {};
if (formValues.metadata) {
try {
metadata = JSON.parse(formValues.metadata);
} catch (e) {
console.warn("Invalid JSON in metadata field, starting with empty object");
}
}
// Add logging settings to metadata
metadata = {
...metadata,
logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name
};
formValues.metadata = JSON.stringify(metadata);
}
const metadataObject = {
...metadataPairsToObject(formValues.metadata),
...(loggingSettings.length > 0 ? { logging: loggingSettings.filter((config) => config.callback_name) } : {}),
};
formValues.metadata = Object.keys(metadataObject).length > 0 ? JSON.stringify(metadataObject) : undefined;
if (formValues.secret_manager_settings) {
if (typeof formValues.secret_manager_settings === "string") {
@ -451,7 +441,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}
} catch (error) {
console.error("Error creating the team:", error);
NotificationsManager.fromBackend("Error creating the team: " + error);
NotificationsManager.fromBackend("Error creating the team: " + extractProxyErrorMessage(error));
}
};
@ -593,6 +583,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
footer={null}
onOk={handleOk}
onCancel={handleCancel}
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<>
@ -747,6 +738,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label="Metadata"
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
>
<MetadataKeyValueFields
form={form}
schemaFields={teamMetadataSchemaFields}
schemaLoading={isTeamMetadataSchemaLoading}
/>
</Form.Item>
<Accordion
className="mt-20 mb-8"
@ -801,13 +802,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
>
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label="Metadata"
name="metadata"
help="Additional team metadata. Enter metadata as JSON object."
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label="Secret Manager Settings"
name="secret_manager_settings"

View file

@ -0,0 +1,282 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import MetadataKeyValueFields, {
MetadataPair,
metadataObjectToPairs,
metadataPairsToObject,
} from "./MetadataKeyValueFields";
describe("metadataObjectToPairs", () => {
it("returns an empty list for null or undefined metadata", () => {
expect(metadataObjectToPairs(null)).toEqual([]);
expect(metadataObjectToPairs(undefined)).toEqual([]);
});
it("keeps plain string values as-is", () => {
expect(metadataObjectToPairs({ department: "research" })).toEqual([{ key: "department", value: "research" }]);
});
it("serializes non-string values as JSON", () => {
expect(
metadataObjectToPairs({
tier: 3,
beta: true,
config: { region: "us" },
tags: ["a", "b"],
empty: null,
}),
).toEqual([
{ key: "tier", value: "3" },
{ key: "beta", value: "true" },
{ key: "config", value: '{"region":"us"}' },
{ key: "tags", value: '["a","b"]' },
{ key: "empty", value: "null" },
]);
});
it("quotes string values that would otherwise parse as JSON, so types round-trip", () => {
expect(metadataObjectToPairs({ code: "42", flag: "true" })).toEqual([
{ key: "code", value: '"42"' },
{ key: "flag", value: '"true"' },
]);
});
it("filters out excluded keys", () => {
expect(
metadataObjectToPairs({ department: "research", logging: [{ callback_name: "langfuse" }] }, new Set(["logging"])),
).toEqual([{ key: "department", value: "research" }]);
});
});
describe("metadataPairsToObject", () => {
it("returns an empty object for undefined pairs", () => {
expect(metadataPairsToObject(undefined)).toEqual({});
});
it("keeps plain text values as strings", () => {
expect(metadataPairsToObject([{ key: "department", value: "research" }])).toEqual({ department: "research" });
});
it("parses JSON values into their typed form", () => {
expect(
metadataPairsToObject([
{ key: "tier", value: "3" },
{ key: "beta", value: "true" },
{ key: "config", value: '{"region":"us"}' },
{ key: "code", value: '"42"' },
]),
).toEqual({ tier: 3, beta: true, config: { region: "us" }, code: "42" });
});
it("skips rows without a key and defaults a missing value to an empty string", () => {
expect(metadataPairsToObject([{ key: "", value: "orphan" }, undefined, { key: "kept" }])).toEqual({ kept: "" });
});
it("round-trips a mixed-type metadata object losslessly", () => {
const metadata = {
department: "research",
code: "42",
tier: 3,
beta: true,
config: { region: "us", replicas: 2 },
};
expect(metadataPairsToObject(metadataObjectToPairs(metadata))).toEqual(metadata);
});
});
interface HarnessProps {
onFinish: (values: { metadata?: MetadataPair[] }) => void;
initialMetadata?: MetadataPair[];
schemaFields?: TeamMetadataField[];
schemaLoading?: boolean;
}
const Harness: React.FC<HarnessProps> = ({ onFinish, initialMetadata, schemaFields, schemaLoading }) => {
const [form] = Form.useForm();
return (
<Form form={form} onFinish={onFinish} initialValues={{ metadata: initialMetadata }}>
<MetadataKeyValueFields form={form} schemaFields={schemaFields} schemaLoading={schemaLoading} />
<button type="submit">Save</button>
</Form>
);
};
describe("MetadataKeyValueFields", () => {
it("renders one row per existing pair", () => {
render(
<Harness
onFinish={vi.fn()}
initialMetadata={[
{ key: "department", value: "research" },
{ key: "tier", value: "3" },
]}
/>,
);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
expect(keyInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["department", "tier"]);
expect(valueInputs.map((input) => (input as HTMLInputElement).value)).toEqual(["research", "3"]);
});
it("adds a row and submits the entered pair", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
render(<Harness onFinish={onFinish} />);
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "eng-1" }] });
});
});
it("removes a row when its remove icon is clicked", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
render(
<Harness
onFinish={onFinish}
initialMetadata={[
{ key: "department", value: "research" },
{ key: "tier", value: "3" },
]}
/>,
);
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "tier", value: "3" }] });
});
});
it("blocks submission on duplicate keys", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
render(
<Harness
onFinish={onFinish}
initialMetadata={[
{ key: "department", value: "research" },
{ key: "department", value: "sales" },
]}
/>,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getAllByText("Duplicate key").length).toBeGreaterThan(0);
});
expect(onFinish).not.toHaveBeenCalled();
});
it("blocks submission when a row is missing its key", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
render(<Harness onFinish={onFinish} />);
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
await user.type(screen.getByPlaceholderText("Value"), "orphan");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByText("Missing key")).toBeInTheDocument();
});
expect(onFinish).not.toHaveBeenCalled();
});
});
describe("MetadataKeyValueFields with a declared schema", () => {
const schema: TeamMetadataField[] = [
{ key: "cost_center", label: "Cost Center" },
{ key: "app_name", label: "Application Name" },
];
it("should prepopulate one ordinary editable pair row per declared key", async () => {
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
"cost_center",
"app_name",
]);
});
screen.getAllByPlaceholderText("Key").forEach((input) => expect(input).toBeEnabled());
expect(screen.getAllByLabelText("Remove key-value pair")).toHaveLength(2);
});
it("should submit a prepopulated key with its typed value", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
render(<Harness onFinish={onFinish} schemaFields={[{ key: "cost_center", label: "Cost Center" }]} />);
await user.type(await screen.findByPlaceholderText("Value"), "CC-1001");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(onFinish).toHaveBeenCalledWith({ metadata: [{ key: "cost_center", value: "CC-1001" }] });
});
});
it("should not add a second row for keys already present in the form", async () => {
render(
<Harness onFinish={vi.fn()} schemaFields={schema} initialMetadata={[{ key: "cost_center", value: "CC-1001" }]} />,
);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
"cost_center",
"app_name",
]);
});
expect(screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value)).toEqual([
"CC-1001",
"",
]);
});
it("should let the user remove a prepopulated row", async () => {
const user = userEvent.setup();
render(<Harness onFinish={vi.fn()} schemaFields={schema} />);
await screen.findAllByPlaceholderText("Key");
await user.click(screen.getAllByLabelText("Remove key-value pair")[0]);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
"app_name",
]);
});
});
it("should show a skeleton instead of the editor while the schema is loading", () => {
render(<Harness onFinish={vi.fn()} schemaLoading />);
expect(screen.getByTestId("metadata-schema-skeleton")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add key-value pair/i })).not.toBeInTheDocument();
});
it("should seed rows when the schema arrives after an initial loading state", async () => {
const onFinish = vi.fn();
const { rerender } = render(<Harness onFinish={onFinish} schemaLoading />);
rerender(<Harness onFinish={onFinish} schemaFields={schema} schemaLoading={false} />);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
"cost_center",
"app_name",
]);
});
});
});

View file

@ -0,0 +1,135 @@
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Form, FormInstance, Input, Skeleton, Space } from "antd";
import React, { useEffect, useRef } from "react";
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
export interface MetadataPair {
key: string;
value: string;
}
function formatMetadataValue(value: unknown): string {
if (typeof value !== "string") {
return JSON.stringify(value) ?? "";
}
try {
JSON.parse(value);
return JSON.stringify(value);
} catch {
return value;
}
}
function parseMetadataValue(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export function metadataObjectToPairs(
metadata: Record<string, unknown> | null | undefined,
excludedKeys: ReadonlySet<string> = new Set(),
): MetadataPair[] {
return Object.entries(metadata ?? {})
.filter(([key]) => !excludedKeys.has(key))
.map(([key, value]) => ({ key, value: formatMetadataValue(value) }));
}
export function metadataPairsToObject(
pairs: readonly (Partial<MetadataPair> | undefined)[] | undefined,
): Record<string, unknown> {
return Object.fromEntries(
(pairs ?? [])
.filter((pair): pair is Partial<MetadataPair> & { key: string } => Boolean(pair?.key))
.map((pair) => [pair.key, parseMetadataValue(pair.value ?? "")]),
);
}
interface MetadataKeyValueFieldsProps {
form: FormInstance;
name?: string;
schemaFields?: readonly TeamMetadataField[];
schemaLoading?: boolean;
}
const MetadataKeyValueFields: React.FC<MetadataKeyValueFieldsProps> = ({
form,
name = "metadata",
schemaFields = [],
schemaLoading = false,
}) => {
const seededRef = useRef(false);
useEffect(() => {
if (seededRef.current || schemaLoading || schemaFields.length === 0) return;
seededRef.current = true;
const pairs: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
if (!Array.isArray(pairs)) return;
const existingKeys = new Set(pairs.map((pair) => pair?.key).filter(Boolean));
const seeded = schemaFields
.filter((field) => !existingKeys.has(field.key))
.map((field) => ({ key: field.key, value: "" }));
if (seeded.length > 0) {
form.setFieldValue(name, [...pairs, ...seeded]);
}
}, [form, name, schemaFields, schemaLoading]);
if (schemaLoading) {
return (
<div data-testid="metadata-schema-skeleton">
<Skeleton active title={false} paragraph={{ rows: 3 }} />
</div>
);
}
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name: fieldName, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[fieldName, "key"]}
rules={[
{ required: true, message: "Missing key" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all: (Partial<MetadataPair> | undefined)[] = form.getFieldValue(name) ?? [];
const dupes = all.filter((entry) => entry?.key === value);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate key"));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item {...restField} name={[fieldName, "value"]}>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined
aria-label="Remove key-value pair"
onClick={() => remove(fieldName)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item style={{ marginBottom: 0 }}>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
);
};
export default MetadataKeyValueFields;

View file

@ -38,7 +38,7 @@ import type {
CoordinationRedisTestResponse,
} from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types";
import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants";
import { createApiClient, deriveErrorMessage } from "@/lib/http/client";
import { createApiClient, deriveErrorMessage, unwrapProxyErrorMessage } from "@/lib/http/client";
import { resolveApiBase } from "@/lib/http/resolveApiBase";
import {
registerAuthHeaderNameGetter,
@ -2643,7 +2643,7 @@ export const teamUpdateCall = async (
const errorData = await response.text();
handleError(errorData);
console.error("Error response from the server:", errorData);
NotificationsManager.fromBackend("Failed to update team settings: " + errorData);
NotificationsManager.fromBackend("Failed to update team settings: " + unwrapProxyErrorMessage(errorData));
throw new Error(errorData);
}
const data = (await response.json()) as { data: Team; team_id: string };

View file

@ -1,3 +1,4 @@
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import * as networking from "@/components/networking";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@ -26,6 +27,10 @@ vi.mock("@/components/utils/dataUtils", () => ({
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
}));
@ -220,6 +225,7 @@ describe("TeamInfoView", () => {
isFetching: false,
refetch: vi.fn(),
} as any);
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
@ -893,6 +899,137 @@ describe("TeamInfoView", () => {
});
});
describe("metadata key-value editing", () => {
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
const teamNameElements = screen.queryAllByText("Test Team");
expect(teamNameElements.length).toBeGreaterThan(0);
});
await user.click(screen.getByRole("tab", { name: "Settings" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /edit settings/i }));
await waitFor(() => {
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
});
};
it("prefills pairs from team metadata, hides UI-managed keys, and round-trips typed values on save", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
metadata: {
department: "research",
tier: 3,
beta: true,
config: { region: "us" },
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
guardrails: ["g1"],
disable_global_guardrails: false,
model_tpm_limit: { "gpt-4": 100 },
},
models: ["gpt-4"],
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openSettingsEditor(user);
const keyValues = screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value);
expect(keyValues).toEqual(["department", "tier", "beta", "config"]);
const valueValues = screen.getAllByPlaceholderText("Value").map((input) => (input as HTMLInputElement).value);
expect(valueValues).toEqual(["research", "3", "true", '{"region":"us"}']);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
const updateArg = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
expect(updateArg.metadata).toMatchObject({
department: "research",
tier: 3,
beta: true,
config: { region: "us" },
logging: [{ callback_name: "langfuse", callback_type: "success", callback_vars: {} }],
});
expect(updateArg.metadata).not.toHaveProperty("model_tpm_limit");
expect(updateArg.model_tpm_limit).toEqual({ "gpt-4": 100 });
});
it("includes a newly added pair in the team update", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"] }));
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openSettingsEditor(user);
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
await user.type(screen.getByPlaceholderText("Key"), "cost_center");
await user.type(screen.getByPlaceholderText("Value"), "eng-1");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({ cost_center: "eng-1" });
});
it("should keep declared keys as ordinary prefilled rows and submit the edited value", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(useTeamMetadataSchema).mockReturnValue({
data: [
{ key: "cost_center", label: "Cost Center" },
{ key: "app_name", label: "Application Name" },
],
isLoading: false,
} as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
metadata: { cost_center: "CC-OLD", department: "research" },
models: ["gpt-4"],
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openSettingsEditor(user);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Key").map((input) => (input as HTMLInputElement).value)).toEqual([
"cost_center",
"department",
"app_name",
]);
});
expect(screen.getAllByPlaceholderText("Value")[0]).toHaveValue("CC-OLD");
await user.clear(screen.getAllByPlaceholderText("Value")[0]);
await user.type(screen.getAllByPlaceholderText("Value")[0], "CC-NEW");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].metadata).toMatchObject({
cost_center: "CC-NEW",
department: "research",
app_name: "",
});
});
});
describe("model aliases", () => {
const openSettingsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {

View file

@ -35,6 +35,11 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import MetadataKeyValueFields, {
metadataObjectToPairs,
metadataPairsToObject,
} from "../common_components/MetadataKeyValueFields";
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import ModelAliasManager from "../common_components/ModelAliasManager";
import AgentSelector from "../agent_management/AgentSelector";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
@ -66,6 +71,18 @@ import {
import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
"logging",
"secret_manager_settings",
"soft_budget_alerting_emails",
"model_tpm_limit",
"model_rpm_limit",
"allowed_passthrough_routes",
"guardrails",
"opted_out_global_guardrails",
"disable_global_guardrails",
]);
export interface TeamMembership {
user_id: string;
team_id: string;
@ -203,6 +220,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [organization, setOrganization] = useState<Organization | null>(null);
const { userRole, userId } = useAuthorized();
const { data: userOrganizations = [] } = useOrganizations();
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
// Check if user is org admin for this team's organization
@ -461,16 +479,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
if (!accessToken) return;
setIsTeamSaving(true);
let parsedMetadata = {};
try {
const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {};
// Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately
const { soft_budget_alerting_emails, ...rest } = rawMetadata;
parsedMetadata = rest;
} catch (e) {
NotificationsManager.fromBackend("Invalid JSON in metadata field");
return;
}
const parsedMetadata = metadataPairsToObject(values.metadata);
let secretManagerSettings: Record<string, any> | undefined;
if (typeof values.secret_manager_settings === "string") {
@ -980,21 +989,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
soft_budget_alerting_emails: Array.isArray(info.metadata?.soft_budget_alerting_emails)
? info.metadata.soft_budget_alerting_emails.join(", ")
: "",
metadata: info.metadata
? JSON.stringify(
(({
logging,
secret_manager_settings,
soft_budget_alerting_emails,
model_tpm_limit,
model_rpm_limit,
allowed_passthrough_routes,
...rest
}) => rest)(info.metadata),
null,
2,
)
: "",
metadata: metadataObjectToPairs(info.metadata, UI_MANAGED_METADATA_KEYS),
logging_settings: info.metadata?.logging || [],
secret_manager_settings: info.metadata?.secret_manager_settings
? JSON.stringify(info.metadata.secret_manager_settings, null, 2)
@ -1170,6 +1165,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<NumericalInput step={1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label="Metadata"
help='Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {"region": "us"}.'
>
<MetadataKeyValueFields
form={form}
schemaFields={teamMetadataSchemaFields}
schemaLoading={isTeamMetadataSchemaLoading}
/>
</Form.Item>
<Form.Item
label="Model-Specific Rate Limits"
tooltip="Set per-model TPM/RPM limits that apply across the whole team."
@ -1493,10 +1499,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={10} />
</Form.Item>
<div className="sticky z-10 bg-white p-4 pr-0 border-t border-gray-200 -bottom-6 -inset-x-6">
<div className="flex justify-end items-center gap-2">
<Button onClick={() => setIsEditing(false)} disabled={isTeamSaving}>

View file

@ -1,5 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { createApiClient, ApiError, deriveErrorMessage } from "./client";
import {
createApiClient,
ApiError,
deriveErrorMessage,
extractProxyErrorMessage,
unwrapProxyErrorMessage,
} from "./client";
const okResponse = (data: unknown): Response =>
({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response;
@ -126,3 +132,41 @@ describe("deriveErrorMessage", () => {
);
});
});
describe("unwrapProxyErrorMessage", () => {
it("should unwrap the proxy's stringified python dict message", () => {
expect(
unwrapProxyErrorMessage("{'error': 'Cost center CC-9999 is not recognized. Contact the FinOps team.'}"),
).toBe("Cost center CC-9999 is not recognized. Contact the FinOps team.");
});
it("should unwrap the full JSON error envelope down to the inner message", () => {
const envelope = JSON.stringify({
error: {
message: "{'error': 'cost_center is required in team metadata. Contact the FinOps team.'}",
type: "internal_server_error",
param: "None",
code: "400",
},
});
expect(unwrapProxyErrorMessage(envelope)).toBe(
"cost_center is required in team metadata. Contact the FinOps team.",
);
});
it("should return plain messages and unparseable input unchanged", () => {
expect(unwrapProxyErrorMessage("Failed to fetch")).toBe("Failed to fetch");
expect(unwrapProxyErrorMessage("{}")).toBe("{}");
});
});
describe("extractProxyErrorMessage", () => {
it("should unwrap an Error's message without the error name prefix", () => {
const error = new ApiError("{'error': 'Cost center CC-9999 is not recognized.'}", 400, {});
expect(extractProxyErrorMessage(error)).toBe("Cost center CC-9999 is not recognized.");
});
it("should stringify non-Error inputs", () => {
expect(extractProxyErrorMessage("plain failure")).toBe("plain failure");
});
});

View file

@ -62,6 +62,38 @@ export const deriveErrorMessage = (errorData: any): string => {
);
};
/**
* The proxy serializes HTTPException details as the string form of a Python dict,
* so a rejection reaches the UI as "{'error': 'actual message'}" (or that string
* nested inside the JSON error envelope). Unwraps to the actual message; returns
* the input unchanged when it does not match a known wrapper shape.
*/
export const unwrapProxyErrorMessage = (raw: string): string => {
const trimmed = raw.trim();
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object") {
const derived = deriveErrorMessage(parsed);
if (typeof derived === "string" && derived !== trimmed) {
return unwrapProxyErrorMessage(derived);
}
}
} catch {
const pythonDictMatch = trimmed.match(/^\{'error':\s*(['"])([\s\S]*)\1\}$/);
if (pythonDictMatch) {
return pythonDictMatch[2];
}
}
return raw;
};
export const extractProxyErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return unwrapProxyErrorMessage(error.message);
}
return unwrapProxyErrorMessage(String(error));
};
export interface ApiClientConfig {
/** Resolves the API origin at call time (it can change at runtime). */
getBaseUrl: () => string;

View file

@ -13640,6 +13640,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/team/metadata_schema": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Team Metadata Schema
* @description Get the team metadata fields declared in ``general_settings.team_metadata_schema``.
*
* The UI uses this to prepopulate the team metadata form with the declared
* keys. Returns an empty ``fields`` list when no schema is configured. This
* schema is advisory; server-side enforcement stays with
* ``custom_team_metadata_validate``.
*/
get: operations["get_team_metadata_schema_team_metadata_schema_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/team/model/add": {
parameters: {
query?: never;
@ -32024,6 +32049,27 @@ export interface components {
/** User Id */
user_id: string;
};
/**
* TeamMetadataFieldSchema
* @description One declared team metadata field from ``general_settings.team_metadata_schema``.
*
* Advisory only: the UI uses it to prepopulate the team metadata form.
* Enforcement stays with ``custom_team_metadata_validate``.
*/
TeamMetadataFieldSchema: {
/** Key */
key: string;
/** Label */
label?: string | null;
};
/**
* TeamMetadataSchemaResponse
* @description Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured.
*/
TeamMetadataSchemaResponse: {
/** Fields */
fields: components["schemas"]["TeamMetadataFieldSchema"][];
};
/**
* TeamModelAddRequest
* @description Request to add models to a team
@ -51069,6 +51115,26 @@ export interface operations {
};
};
};
get_team_metadata_schema_team_metadata_schema_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TeamMetadataSchemaResponse"];
};
};
};
};
team_model_add_team_model_add_post: {
parameters: {
query?: never;