mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
refactor(otel/v2): make destination access the sole routing determinant
A logging destination's credential_info.access (global / teams / orgs) now fully decides which requests it receives; a destination fires for a request exactly when its access grants the request's team or org. This removes the second, redundant way to express the same team-to-destination mapping that the admin-only model left behind: the auto_enable flag and the per-team/org logging_exporters assignment column both existed for tenant self-service opt-in, and once assignment became proxy-admin-only they only duplicated what access already says. Removed: the auto_enable field on CredentialInfo; the team and organization logging_exporters columns and their assignment gate (validate_logging_exporter_field / validate_logging_exporter_assignment); the request-time naming union in litellm_pre_call_utils; and the dashboard's per-team/org destination picker and the "Enable for entire scope" toggle. The access-shape validator stays, the credential's access fields stay, and /team/info and /organization/info still disclose resolved_logging_exporters computed from access alone. This also removes the /v2/organization write that two review bots flagged (there is no longer a logging_exporters field on that endpoint) and the "(via scope)" UI ambiguity that came from carrying two representations of the same mapping. Verified live on a 2-org / 4-team matrix against Langfuse, Arize, Weave, a generic OTLP collector, and a self-hosted Phoenix: per-team and per-org isolation, empty access as deny-all, injection defense, admin-only credential management, and complete trace trees read back from each destination's own API.
This commit is contained in:
parent
a0622a2aab
commit
4ff358fcb8
41 changed files with 170 additions and 1410 deletions
|
|
@ -1,8 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DeletedTeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_OrganizationTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -143,7 +142,6 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -66,12 +66,11 @@ class CredentialInfo(BaseModel):
|
|||
Existing stored credentials carry arbitrary extra fields (e.g.
|
||||
``custom_llm_provider``); ``extra="allow"`` preserves them. Only the fields
|
||||
the resolver consumes are typed: ``credential_type`` selects logging
|
||||
destinations, and ``access``/``auto_enable`` decide which identities the
|
||||
destination fires for.
|
||||
destinations, and ``access`` decides which identities the destination fires
|
||||
for.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
credential_type: str | None = None
|
||||
access: CredentialAccess | None = None
|
||||
auto_enable: bool = False
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ Canonical definition for ``litellm_organizationtable``. Re-exported from
|
|||
``litellm.proxy._types`` for backwards compatibility.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import List, Optional
|
||||
|
||||
from litellm.models.budget import LiteLLM_BudgetTable
|
||||
|
|
@ -23,7 +22,6 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
|
|||
spend: float = 0.0
|
||||
metadata: Optional[dict] = None
|
||||
models: List[str] = []
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
model_spend: Optional[dict] = {}
|
||||
created_by: str
|
||||
updated_by: str
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ budget-window value types and the team-model alias table). Re-exported from
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
|
|
@ -93,7 +92,6 @@ class LiteLLM_TeamTable(TeamBase):
|
|||
model_spend: Optional[dict] = {}
|
||||
model_max_budget: Optional[dict] = {}
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
allow_team_guardrail_config: Optional[bool] = False
|
||||
litellm_model_table: Optional[LiteLLM_ModelTable] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
|
||||
|
|
|
|||
|
|
@ -1778,7 +1778,6 @@ class NewTeamRequest(TeamBase):
|
|||
tags: Optional[list] = None
|
||||
guardrails: Optional[List[str]] = None
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
prompts: Optional[List[str]] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
allowed_passthrough_routes: Optional[list] = None
|
||||
|
|
@ -1845,7 +1844,6 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
|||
model_aliases: Optional[dict] = None
|
||||
guardrails: Optional[List[str]] = None
|
||||
policies: Optional[List[str]] = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
disable_global_guardrails: Optional[bool] = None
|
||||
team_member_budget: Optional[float] = None
|
||||
|
|
@ -2000,7 +1998,6 @@ class NewOrganizationRequest(LiteLLM_BudgetTable):
|
|||
models: List = []
|
||||
budget_id: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
model_rpm_limit: Optional[Dict[str, int]] = None
|
||||
model_tpm_limit: Optional[Dict[str, int]] = None
|
||||
|
||||
|
|
@ -2801,7 +2798,6 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
|
|||
spend: Optional[float] = None
|
||||
metadata: Optional[dict] = None
|
||||
models: Optional[List[str]] = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
updated_by: Optional[str] = None
|
||||
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
|
||||
model_tpm_limit: Optional[Dict[str, int]] = None
|
||||
|
|
@ -2841,7 +2837,6 @@ class OrganizationUpdateRequestV2(LiteLLMPydanticObjectBase):
|
|||
max_parallel_requests: int | None = None
|
||||
model_max_budget: dict | None = None
|
||||
budget_duration: str | None = None
|
||||
logging_exporters: Sequence[str] | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json
|
|||
import re
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Sequence
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
|
@ -622,82 +622,15 @@ async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
|||
return getattr(team_obj, "organization_id", None)
|
||||
|
||||
|
||||
async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: str | None) -> frozenset[str]:
|
||||
"""The union of admin-assigned exporter names across the request's identity chain.
|
||||
|
||||
Each level is read from its own ``logging_exporters`` column: the team via
|
||||
``get_team_object``, the org via ``get_org_object`` on the effective ``org_id``
|
||||
(token org or team fallback). Keys inherit from their team and org. Internal-user is
|
||||
intentionally not a routing dimension. The assignment is an admin-owned column;
|
||||
the request never supplies it. Needs a DB connection: in SDK mode there is no
|
||||
identity to resolve against, so this is empty (admin-owned destinations do not
|
||||
apply off the proxy).
|
||||
"""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_org_object,
|
||||
get_team_object,
|
||||
)
|
||||
|
||||
prisma_client = proxy_server.prisma_client
|
||||
if prisma_client is None:
|
||||
return frozenset()
|
||||
cache = proxy_server.user_api_key_cache
|
||||
span = getattr(user_api_key_dict, "parent_otel_span", None)
|
||||
|
||||
def _assigned(obj: object) -> tuple[str, ...]:
|
||||
assigned = getattr(obj, "logging_exporters", None)
|
||||
if isinstance(assigned, (list, tuple)):
|
||||
return tuple(str(name) for name in assigned)
|
||||
return ()
|
||||
|
||||
async def _level(lookup: "Awaitable[object]") -> tuple[str, ...]:
|
||||
try:
|
||||
return _assigned(await lookup)
|
||||
except Exception: # noqa: BLE001 # best-effort identity enrichment; a failed lookup must not block the request
|
||||
return ()
|
||||
|
||||
team_names = (
|
||||
await _level(
|
||||
get_team_object(
|
||||
team_id=user_api_key_dict.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
if user_api_key_dict.team_id
|
||||
else ()
|
||||
)
|
||||
org_names = (
|
||||
await _level(
|
||||
get_org_object(
|
||||
org_id=org_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=span,
|
||||
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
||||
)
|
||||
)
|
||||
if org_id
|
||||
else ()
|
||||
)
|
||||
return frozenset((*team_names, *org_names))
|
||||
|
||||
|
||||
async def _resolve_logging_exporters(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> "tuple[tuple[OtelDestinationParams, ...], tuple[str, ...]]":
|
||||
"""Resolve the destinations this request fans out to, as (destinations, backends).
|
||||
|
||||
``credential_info.access`` gates every destination: empty access grants no one, so
|
||||
an empty-access destination never fires (proxy-wide requires ``access.global``). A
|
||||
destination is selected when its ``access`` grants the caller AND either it is
|
||||
``auto_enable`` (fires without being named) or it is named in the identity chain's
|
||||
``logging_exporters`` (key + team + org). The access check is also the defensive
|
||||
re-check on a named destination, so a stale or cross-tenant assignment can never
|
||||
route traffic out. Each survivor is built via ``build_destination`` and deduped on
|
||||
``credential_info.access`` is the sole routing determinant: a destination is
|
||||
selected when its ``access`` grants the caller's team/org. Empty access grants no
|
||||
one, so an empty-access destination never fires (proxy-wide requires
|
||||
``access.global``). Each survivor is built via ``build_destination`` and deduped on
|
||||
(endpoint, headers, resource attributes). Returns ([], []) when nothing is selected
|
||||
(default-deny).
|
||||
"""
|
||||
|
|
@ -710,16 +643,13 @@ async def _resolve_logging_exporters(
|
|||
|
||||
team_id = user_api_key_dict.team_id
|
||||
org_id = await _effective_org_id(user_api_key_dict)
|
||||
names = await _union_logging_exporter_names(user_api_key_dict, org_id)
|
||||
team_ids, org_ids = identity_scope(team_id, org_id)
|
||||
|
||||
def _selected(credential: "CredentialItem") -> bool:
|
||||
info = parse_credential_info(credential.credential_info)
|
||||
if info is None or info.credential_type != "logging":
|
||||
return False
|
||||
if not access_grants(info.access, team_ids, org_ids):
|
||||
return False
|
||||
return info.auto_enable or credential.credential_name in names
|
||||
return access_grants(info.access, team_ids, org_ids)
|
||||
|
||||
def _build(
|
||||
credential: "CredentialItem",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
"""Request-time routing predicate for admin-owned logging destinations.
|
||||
|
||||
``credential_info.access`` answers "which identities' traces may this destination
|
||||
receive". It is routing scope, decoupled from enablement (a named assignment plus
|
||||
the explicit ``auto_enable`` default-on flag). The request-time resolver in
|
||||
``litellm_pre_call_utils`` is the consumer: at call time it checks whether the
|
||||
request's team/org is granted before firing the destination.
|
||||
receive". It is the sole routing determinant: at call time the resolver in
|
||||
``litellm_pre_call_utils`` fires a destination for a request exactly when the
|
||||
request's team/org is granted by that destination's ``access``.
|
||||
|
||||
``access_grants`` is the primitive: does this ``access`` reach an identity whose
|
||||
scope is the given set of team ids and org ids. The resolver passes a
|
||||
one-element scope built with ``identity_scope``.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
import litellm
|
||||
|
|
@ -45,7 +42,6 @@ def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[s
|
|||
|
||||
|
||||
def resolved_logging_exporter_names(
|
||||
assigned: Sequence[str] | None,
|
||||
team_id: str | None,
|
||||
org_id: str | None,
|
||||
) -> tuple[str, ...]:
|
||||
|
|
@ -53,19 +49,16 @@ def resolved_logging_exporter_names(
|
|||
the team/org info pages.
|
||||
|
||||
Mirrors the request-time resolver's selection: a logging destination is included
|
||||
when its ``access`` grants the identity AND it is either ``auto_enable`` or named
|
||||
in ``assigned`` (the identity's own ``logging_exporters``). Names only; endpoints,
|
||||
headers, and the access map itself stay proxy-admin information.
|
||||
when its ``access`` grants the identity. Names only; endpoints, headers, and the
|
||||
access map itself stay proxy-admin information.
|
||||
"""
|
||||
team_ids, org_ids = identity_scope(team_id, org_id)
|
||||
own = frozenset(str(name) for name in (assigned or ()))
|
||||
selected = tuple(
|
||||
credential.credential_name
|
||||
for credential in litellm.credential_list
|
||||
if (info := parse_credential_info(credential.credential_info)) is not None
|
||||
and info.credential_type == "logging"
|
||||
and access_grants(info.access, team_ids, org_ids)
|
||||
and (info.auto_enable or credential.credential_name in own)
|
||||
)
|
||||
return tuple(dict.fromkeys(selected))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
"""Validation for admin-owned logging-exporter assignment on key/team/org.
|
||||
"""Shape validation for an admin-owned logging destination's ``credential_info.access``.
|
||||
|
||||
An identity's ``metadata.logging_exporters`` binds it to admin-owned trace
|
||||
destinations. Only the proxy admin may write it, and every name must be a registered
|
||||
logging credential. Which identities a destination actually fires for is governed by
|
||||
the destination's own ``credential_info.access``; the resolver
|
||||
(``litellm_pre_call_utils``) evaluates that at request time.
|
||||
Which identities a destination fires for is governed entirely by its
|
||||
``credential_info.access``; the resolver (``litellm_pre_call_utils``) evaluates that at
|
||||
request time. This module only checks that a write sets a well-formed ``access`` object.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
LOGGING_EXPORTERS_KEY = "logging_exporters"
|
||||
|
||||
|
||||
def validate_credential_access(credential_info: Mapping[str, object] | None) -> None:
|
||||
"""Validate ``credential_info.access`` shape when the write sets one.
|
||||
|
|
@ -50,123 +43,3 @@ def validate_credential_access(credential_info: Mapping[str, object] | None) ->
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": f"access contains unknown field(s): {sorted(unknown)}"},
|
||||
)
|
||||
|
||||
|
||||
def _logging_credentials_by_name() -> Mapping[str, Mapping[str, object]]:
|
||||
return {
|
||||
credential.credential_name: (credential.credential_info or {})
|
||||
for credential in litellm.credential_list
|
||||
if (credential.credential_info or {}).get("credential_type") == "logging"
|
||||
}
|
||||
|
||||
|
||||
def _logging_credential_names() -> frozenset[str]:
|
||||
return frozenset(_logging_credentials_by_name())
|
||||
|
||||
|
||||
def _validate_exporters_shape_and_names(exporters: object) -> None:
|
||||
"""Common shape + registry check shared by every entry point."""
|
||||
if not isinstance(exporters, list):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": "logging_exporters must be a list of credential names"},
|
||||
)
|
||||
known = _logging_credential_names()
|
||||
unknown = [name for name in exporters if name not in known]
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": (
|
||||
f"Unknown or non-logging credential(s): {unknown}. Register them "
|
||||
"as logging credentials before assigning."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _exporter_value_changes(
|
||||
requested_metadata: Mapping[str, object] | None,
|
||||
existing_metadata: Mapping[str, object] | None,
|
||||
) -> bool:
|
||||
"""True if the effective ``metadata.logging_exporters`` value would change.
|
||||
|
||||
An update endpoint that REPLACES stored metadata with ``requested_metadata``
|
||||
will drop ``logging_exporters`` when the new payload omits it. So a write
|
||||
requires authorization whenever:
|
||||
|
||||
- the new metadata sets ``logging_exporters`` (the previously-handled case), OR
|
||||
- the new metadata is provided but omits ``logging_exporters`` while the
|
||||
stored metadata had one (removal-via-omission, Veria F4).
|
||||
|
||||
Returns False when stored and requested values match exactly, or when the
|
||||
update doesn't touch metadata at all.
|
||||
"""
|
||||
if not isinstance(requested_metadata, dict):
|
||||
return False
|
||||
new_has = LOGGING_EXPORTERS_KEY in requested_metadata
|
||||
existing = existing_metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(existing_metadata, dict) else None
|
||||
existing_has = existing is not None
|
||||
if not new_has and not existing_has:
|
||||
return False
|
||||
if new_has and not existing_has:
|
||||
return True
|
||||
if not new_has and existing_has:
|
||||
return True
|
||||
new_value = requested_metadata.get(LOGGING_EXPORTERS_KEY)
|
||||
if isinstance(new_value, (list, tuple)) and isinstance(existing, (list, tuple)):
|
||||
return tuple(new_value) != tuple(existing)
|
||||
return new_value != existing
|
||||
|
||||
|
||||
def validate_logging_exporter_field(
|
||||
requested_exporters: Sequence[str] | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
existing_exporters: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Authorize a typed ``logging_exporters`` write (proxy-admin only).
|
||||
|
||||
Adapts the typed list to the metadata-shaped input the shared assignment
|
||||
validator expects, so the authorization logic lives in one place.
|
||||
``requested_exporters is None`` means the field was not provided (no-op); an
|
||||
empty list is an explicit clear and is gated like any other change.
|
||||
``existing_exporters`` is the stored column value, passed so a change is
|
||||
detected and a non-admin cannot silently clear an admin-assigned value.
|
||||
"""
|
||||
requested_metadata = None if requested_exporters is None else {LOGGING_EXPORTERS_KEY: requested_exporters}
|
||||
existing_metadata = None if existing_exporters is None else {LOGGING_EXPORTERS_KEY: existing_exporters}
|
||||
validate_logging_exporter_assignment(
|
||||
requested_metadata,
|
||||
user_api_key_dict,
|
||||
existing_metadata=existing_metadata,
|
||||
)
|
||||
|
||||
|
||||
def validate_logging_exporter_assignment(
|
||||
metadata: Mapping[str, object] | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
existing_metadata: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
"""Validate a ``metadata.logging_exporters`` write on key / team / org endpoints.
|
||||
|
||||
Proxy-admin only. No-op when the update does not change the effective
|
||||
``logging_exporters`` value; otherwise a non-proxy-admin is rejected.
|
||||
|
||||
Update paths replace stored metadata wholesale, so a caller could drop an
|
||||
admin-assigned exporter by sending ``metadata`` without ``logging_exporters``.
|
||||
Pass ``existing_metadata`` from the loaded row so removal-via-omission is gated
|
||||
too (Veria F4). Every exporter name (when present) must resolve to a registered
|
||||
logging credential.
|
||||
"""
|
||||
if not _exporter_value_changes(metadata, existing_metadata):
|
||||
return
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": "Only the proxy admin can assign logging exporters"},
|
||||
)
|
||||
requested = metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(metadata, dict) else None
|
||||
if requested is not None:
|
||||
_validate_exporters_shape_and_names(requested)
|
||||
|
|
|
|||
|
|
@ -335,7 +335,6 @@ async def new_organization(
|
|||
|
||||
- organization_alias: *str* - The name of the organization.
|
||||
- models: *List* - The models the organization has access to.
|
||||
- logging_exporters: *Optional[List[str]]* - Names of admin-owned logging destinations (credential names) this organization exports its traces to.
|
||||
- budget_id: *Optional[str]* - The id for a budget (tpm/rpm/max budget) for the organization.
|
||||
### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ###
|
||||
- max_budget: *Optional[float]* - Max budget for org
|
||||
|
|
@ -388,12 +387,6 @@ async def new_organization(
|
|||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict)
|
||||
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_admin_name,
|
||||
llm_router,
|
||||
|
|
@ -652,12 +645,6 @@ async def update_organization(
|
|||
# Create validated data model
|
||||
data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields)
|
||||
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict)
|
||||
|
||||
# Validate budget values are not negative
|
||||
if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0):
|
||||
raise HTTPException(
|
||||
|
|
@ -862,7 +849,6 @@ async def update_organization_v2(
|
|||
org_column_updates: Mapping[str, object] = {
|
||||
**{field: field_values[field] for field in present_fields if field in _ORG_COLUMN_FIELDS},
|
||||
**({"metadata": data.metadata or {}} if "metadata" in present_fields else {}),
|
||||
**({"logging_exporters": data.logging_exporters or []} if "logging_exporters" in present_fields else {}),
|
||||
}
|
||||
|
||||
object_permission_cleared = "object_permission" in present_fields and data.object_permission is None
|
||||
|
|
@ -1124,7 +1110,6 @@ async def info_organization(
|
|||
|
||||
response_pydantic_obj = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump())
|
||||
response_pydantic_obj.resolved_logging_exporters = resolved_logging_exporter_names(
|
||||
response_pydantic_obj.logging_exporters,
|
||||
None,
|
||||
organization_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -957,7 +957,6 @@ async def new_team(
|
|||
- model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
|
||||
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
|
||||
- policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
|
||||
- logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to.
|
||||
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
|
||||
|
|
@ -1007,9 +1006,6 @@ async def new_team(
|
|||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
|
@ -1022,9 +1018,6 @@ async def new_team(
|
|||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(data.logging_exporters, user_api_key_dict)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
|
|
@ -1632,7 +1625,6 @@ async def update_team(
|
|||
- model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
|
||||
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
|
||||
- policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
|
||||
- logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to.
|
||||
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
|
||||
|
|
@ -1676,9 +1668,6 @@ async def update_team(
|
|||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_admin_name,
|
||||
llm_router,
|
||||
|
|
@ -1733,13 +1722,6 @@ async def update_team(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
if data.logging_exporters is not None:
|
||||
validate_logging_exporter_field(
|
||||
data.logging_exporters,
|
||||
user_api_key_dict,
|
||||
existing_exporters=getattr(existing_team_row, "logging_exporters", None),
|
||||
)
|
||||
|
||||
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
|
||||
|
||||
if data.soft_budget is not None:
|
||||
|
|
@ -3658,7 +3640,6 @@ async def team_info(
|
|||
await _resolve_team_access_group_resources(_team_info)
|
||||
|
||||
_team_info.resolved_logging_exporters = resolved_logging_exporter_names(
|
||||
_team_info.logging_exporters,
|
||||
team_id,
|
||||
_team_info.organization_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -143,7 +142,6 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable {
|
|||
budget_id String
|
||||
metadata Json @default("{}")
|
||||
models String[]
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names)
|
||||
spend Float @default(0.0)
|
||||
model_spend Json @default("{}")
|
||||
object_permission_id String?
|
||||
|
|
@ -143,7 +142,6 @@ model LiteLLM_TeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names)
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
budget_limits Json? // per-model budget limits for the team
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
|
|
@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable {
|
|||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
logging_exporters String[] @default([])
|
||||
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,18 +29,16 @@ def test_parse_none_for_non_dict():
|
|||
assert parse_credential_info(["a"]) is None
|
||||
|
||||
|
||||
def test_parse_typed_access_and_auto_enable():
|
||||
def test_parse_typed_access():
|
||||
info = parse_credential_info(
|
||||
{
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"auto_enable": True,
|
||||
"access": {"global": True, "teams": ["t1"], "orgs": ["o1"]},
|
||||
}
|
||||
)
|
||||
assert info is not None
|
||||
assert info.credential_type == "logging"
|
||||
assert info.auto_enable is True
|
||||
assert info.access is not None
|
||||
assert info.access.global_ is True
|
||||
assert info.access.teams == ("t1",)
|
||||
|
|
@ -51,7 +49,6 @@ def test_parse_missing_access_is_none_not_error():
|
|||
info = parse_credential_info({"credential_type": "logging"})
|
||||
assert info is not None
|
||||
assert info.access is None
|
||||
assert info.auto_enable is False
|
||||
|
||||
|
||||
def test_parse_malformed_access_fails_closed():
|
||||
|
|
@ -101,50 +98,41 @@ def test_access_grants_not_global_when_false():
|
|||
|
||||
# --- routing scope decided entirely by access -------------------------------
|
||||
#
|
||||
# Routing is access-only. auto_enable does not widen it: an empty-access
|
||||
# destination fires for no one regardless of auto_enable (empty access =
|
||||
# deny-all). Proxy-wide routing must be requested explicitly with
|
||||
# access.global=True.
|
||||
# Routing is access-only: a destination fires for exactly the identities its
|
||||
# access grants. Empty access fires for no one (deny-all); proxy-wide routing
|
||||
# must be requested explicitly with access.global=True.
|
||||
|
||||
|
||||
def test_empty_access_is_deny_all_even_with_auto_enable():
|
||||
"""Empty access grants no one, even when auto_enable=True: not proxy-wide."""
|
||||
info = CredentialInfo(credential_type="logging", auto_enable=True)
|
||||
def test_empty_access_is_deny_all():
|
||||
"""Empty access grants no one: not proxy-wide."""
|
||||
info = CredentialInfo(credential_type="logging")
|
||||
assert access_grants(info.access, frozenset(), frozenset()) is False
|
||||
assert access_grants(info.access, frozenset({"any-team"}), frozenset()) is False
|
||||
assert access_grants(info.access, frozenset(), frozenset({"any-org"})) is False
|
||||
|
||||
|
||||
def test_global_access_is_proxy_wide():
|
||||
"""access.global=True is proxy-wide regardless of auto_enable."""
|
||||
info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(global_=True))
|
||||
"""access.global=True reaches every identity."""
|
||||
info = CredentialInfo(credential_type="logging", access=_access(global_=True))
|
||||
assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True
|
||||
assert access_grants(info.access, frozenset(), frozenset()) is True
|
||||
manual = CredentialInfo(credential_type="logging", access=_access(global_=True))
|
||||
assert access_grants(manual.access, frozenset(), frozenset()) is True
|
||||
|
||||
|
||||
def test_auto_enable_team_scoped():
|
||||
"""auto_enable=True + access.teams=[t1] fires only for t1 identities."""
|
||||
info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(teams=["t1"]))
|
||||
def test_access_team_scoped():
|
||||
"""access.teams=[t1] fires only for t1 identities."""
|
||||
info = CredentialInfo(credential_type="logging", access=_access(teams=["t1"]))
|
||||
assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True
|
||||
assert access_grants(info.access, frozenset({"t2"}), frozenset()) is False
|
||||
assert access_grants(info.access, frozenset(), frozenset()) is False
|
||||
|
||||
|
||||
def test_auto_enable_org_scoped():
|
||||
"""auto_enable=True + access.orgs=[o1] fires only for o1 identities."""
|
||||
info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(orgs=["o1"]))
|
||||
def test_access_org_scoped():
|
||||
"""access.orgs=[o1] fires only for o1 identities."""
|
||||
info = CredentialInfo(credential_type="logging", access=_access(orgs=["o1"]))
|
||||
assert access_grants(info.access, frozenset(), frozenset({"o1"})) is True
|
||||
assert access_grants(info.access, frozenset(), frozenset({"o2"})) is False
|
||||
|
||||
|
||||
def test_access_scoped_when_not_auto_enable():
|
||||
info = CredentialInfo(credential_type="logging", access=_access(teams=["t1"]))
|
||||
assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True
|
||||
assert access_grants(info.access, frozenset({"t2"}), frozenset()) is False
|
||||
|
||||
|
||||
def test_denies_when_no_access():
|
||||
info = CredentialInfo(credential_type="logging")
|
||||
assert access_grants(info.access, frozenset({"t1"}), frozenset({"o1"})) is False
|
||||
|
|
@ -168,32 +156,31 @@ def test_identity_scope_empty_for_none():
|
|||
# --- resolved_logging_exporter_names: the /team/info + /organization/info disclosure --
|
||||
|
||||
|
||||
def _cred(name, access=None, auto=False, ctype="logging"):
|
||||
info = {"credential_type": ctype, "auto_enable": auto}
|
||||
def _cred(name, access=None, ctype="logging"):
|
||||
info = {"credential_type": ctype}
|
||||
if access is not None:
|
||||
info["access"] = access
|
||||
return CredentialItem(credential_name=name, credential_values={}, credential_info=info)
|
||||
|
||||
|
||||
def test_resolved_names_mirror_the_resolver(monkeypatch):
|
||||
"""Included: auto+granted, named+granted. Excluded: granted-but-manual-unnamed,
|
||||
named-but-not-granted, empty-access even with auto, provider credentials."""
|
||||
def test_resolved_names_are_access_only(monkeypatch):
|
||||
"""A destination name appears iff its access grants the (team_id, org_id).
|
||||
Included: team-granted, org-granted, global. Excluded: empty-access,
|
||||
granted-but-not-logging (provider) credentials, access for another team."""
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"credential_list",
|
||||
[
|
||||
_cred("auto-team", access={"teams": ["t1"]}, auto=True),
|
||||
_cred("manual-team", access={"teams": ["t1"]}, auto=False),
|
||||
_cred("named-manual", access={"teams": ["t1"]}, auto=False),
|
||||
_cred("named-ungranted", access={"teams": ["other"]}, auto=False),
|
||||
_cred("empty-auto", auto=True),
|
||||
_cred("global-auto", access={"global": True}, auto=True),
|
||||
_cred("org-auto", access={"orgs": ["o1"]}, auto=True),
|
||||
_cred("provider", access={"global": True}, auto=True, ctype=None),
|
||||
_cred("team-granted", access={"teams": ["t1"]}),
|
||||
_cred("team-other", access={"teams": ["other"]}),
|
||||
_cred("org-granted", access={"orgs": ["o1"]}),
|
||||
_cred("empty-access"),
|
||||
_cred("global-access", access={"global": True}),
|
||||
_cred("provider", access={"global": True}, ctype=None),
|
||||
],
|
||||
)
|
||||
names = resolved_logging_exporter_names(["named-manual", "named-ungranted"], "t1", "o1")
|
||||
assert names == ("auto-team", "named-manual", "global-auto", "org-auto")
|
||||
names = resolved_logging_exporter_names("t1", "o1")
|
||||
assert names == ("team-granted", "org-granted", "global-access")
|
||||
|
||||
|
||||
def test_resolved_names_empty_scope_gets_global_only(monkeypatch):
|
||||
|
|
@ -201,13 +188,13 @@ def test_resolved_names_empty_scope_gets_global_only(monkeypatch):
|
|||
litellm,
|
||||
"credential_list",
|
||||
[
|
||||
_cred("global-auto", access={"global": True}, auto=True),
|
||||
_cred("team-auto", access={"teams": ["t1"]}, auto=True),
|
||||
_cred("global-access", access={"global": True}),
|
||||
_cred("team-scoped", access={"teams": ["t1"]}),
|
||||
],
|
||||
)
|
||||
assert resolved_logging_exporter_names(None, None, None) == ("global-auto",)
|
||||
assert resolved_logging_exporter_names(None, None) == ("global-access",)
|
||||
|
||||
|
||||
def test_resolved_names_empty_registry(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "credential_list", [])
|
||||
assert resolved_logging_exporter_names(["anything"], "t1", "o1") == ()
|
||||
assert resolved_logging_exporter_names("t1", "o1") == ()
|
||||
|
|
|
|||
|
|
@ -1,218 +1,21 @@
|
|||
"""Validation for admin-owned logging-exporter assignment on key/team/org.
|
||||
"""Tests for ``validate_credential_access`` -- the shape check on a logging
|
||||
destination's ``credential_info.access`` at create/update time.
|
||||
|
||||
The single ``validate_logging_exporter_assignment`` runs across every write path
|
||||
(``/team/new``, ``/team/update``, ``/key/generate``, ``/key/update``,
|
||||
``/organization/*``). Assigning ``logging_exporters`` is proxy-admin only: a
|
||||
non-proxy-admin write is rejected, and every named exporter must resolve to a
|
||||
registered logging credential.
|
||||
Which identities a destination fires for is governed entirely by ``access`` and
|
||||
evaluated by the request-time resolver; there is no separate assignment/enable
|
||||
surface, so this module only guards that a write stores a well-formed ``access``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
import litellm
|
||||
from litellm.models.credentials import CredentialItem
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.logging_exporter_validation import (
|
||||
validate_credential_access,
|
||||
validate_logging_exporter_assignment,
|
||||
validate_logging_exporter_field,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _registry():
|
||||
original = litellm.credential_list
|
||||
litellm.credential_list = [
|
||||
# global: visible to (and assignable by) every scope.
|
||||
CredentialItem(
|
||||
credential_name="langfuse-eu",
|
||||
credential_values={},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "langfuse_otel",
|
||||
"access": {"global": True},
|
||||
},
|
||||
),
|
||||
# scoped to one team / one org: assignable only within that scope.
|
||||
CredentialItem(
|
||||
credential_name="arize-ds",
|
||||
credential_values={},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"access": {"teams": ["ds-team"], "orgs": ["ds-org"]},
|
||||
},
|
||||
),
|
||||
# proxy-wide auto default: access.global makes it visible to every scope,
|
||||
# auto_enable makes it fire without being named.
|
||||
CredentialItem(
|
||||
credential_name="central-default",
|
||||
credential_values={},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"auto_enable": True,
|
||||
"access": {"global": True},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
credential_name="openai-key",
|
||||
credential_values={},
|
||||
credential_info={"custom_llm_provider": "openai"}, # provider credential
|
||||
),
|
||||
]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
litellm.credential_list = original
|
||||
|
||||
|
||||
def _admin():
|
||||
return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
|
||||
def _non_admin():
|
||||
return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
|
||||
|
||||
def _ok(metadata):
|
||||
return {"logging_exporters": metadata}
|
||||
|
||||
|
||||
# --- Role allow paths -------------------------------------------------------
|
||||
|
||||
|
||||
def test_proxy_admin_always_allowed(_registry):
|
||||
"""No flags needed; proxy_admin role suffices."""
|
||||
validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _admin())
|
||||
|
||||
|
||||
def test_non_admin_with_no_flags_is_forbidden(_registry):
|
||||
"""The headline deny: internal_user with no team/org admin context."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _non_admin())
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
# --- Shape / registry checks (run regardless of who's calling) --------------
|
||||
|
||||
|
||||
def test_unknown_credential_rejected_for_admin(_registry):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(_ok(["does-not-exist"]), _admin())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_provider_credential_rejected(_registry):
|
||||
"""openai-key exists but is provider-typed, not a logging destination."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(_ok(["openai-key"]), _admin())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_non_list_is_rejected(_registry):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(
|
||||
{"logging_exporters": "langfuse-eu"}, _admin()
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_noop_when_field_absent(_registry):
|
||||
"""An update that does not touch logging_exporters skips the gate even
|
||||
for a non-admin with no flags."""
|
||||
validate_logging_exporter_assignment({"some_other_key": 1}, _non_admin())
|
||||
validate_logging_exporter_assignment(None, _non_admin())
|
||||
|
||||
|
||||
# --- Veria F4: removal-via-omission ----------------------------------------
|
||||
#
|
||||
# Update endpoints replace stored metadata wholesale. A caller can wipe an
|
||||
# admin-assigned `logging_exporters` by sending a `metadata` payload that
|
||||
# omits the field. The validator must catch this when ``existing_metadata``
|
||||
# is passed.
|
||||
|
||||
|
||||
def test_removal_via_omission_blocked_for_non_admin(_registry):
|
||||
"""A non-admin with no flags cannot wipe an admin-assigned exporter by
|
||||
submitting metadata without logging_exporters."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(
|
||||
{"some_other_key": 1}, # no logging_exporters in the new payload
|
||||
_non_admin(),
|
||||
existing_metadata={"logging_exporters": ["langfuse-eu"]},
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_removal_via_omission_allowed_for_proxy_admin(_registry):
|
||||
"""Proxy admin may drop the exporter via omission."""
|
||||
validate_logging_exporter_assignment(
|
||||
{"some_other_key": 1},
|
||||
_admin(),
|
||||
existing_metadata={"logging_exporters": ["langfuse-eu"]},
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_empty_list_blocked_for_non_admin(_registry):
|
||||
"""A non-admin submitting `logging_exporters: []` over a non-empty stored
|
||||
value is a removal write and must be gated."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(
|
||||
{"logging_exporters": []},
|
||||
_non_admin(),
|
||||
existing_metadata={"logging_exporters": ["langfuse-eu"]},
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_explicit_null_blocked_for_non_admin(_registry):
|
||||
"""`logging_exporters: null` over a non-empty stored value is also a
|
||||
removal; the validator's shape check would reject it as non-list, but
|
||||
F4's authorization gate must fire first."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_assignment(
|
||||
{"logging_exporters": None},
|
||||
_non_admin(),
|
||||
existing_metadata={"logging_exporters": ["langfuse-eu"]},
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_unchanged_value_is_noop(_registry):
|
||||
"""A metadata payload that re-sends the SAME logging_exporters value is
|
||||
a noop and skips the gate even for a non-admin -- there is no net change
|
||||
to authorize."""
|
||||
validate_logging_exporter_assignment(
|
||||
{"logging_exporters": ["langfuse-eu"]},
|
||||
_non_admin(),
|
||||
existing_metadata={"logging_exporters": ["langfuse-eu"]},
|
||||
)
|
||||
|
||||
|
||||
def test_omitted_on_both_sides_is_noop(_registry):
|
||||
"""A metadata update that doesn't touch logging_exporters on a row that
|
||||
never had one is a noop."""
|
||||
validate_logging_exporter_assignment(
|
||||
{"some_other_key": 1},
|
||||
_non_admin(),
|
||||
existing_metadata={"some_other_key": 0},
|
||||
)
|
||||
|
||||
|
||||
# --- validate_credential_access ---------------------------------------------
|
||||
|
||||
|
||||
def test_validate_credential_access_accepts_valid_object():
|
||||
validate_credential_access(
|
||||
{"access": {"global": False, "teams": ["t1", "t2"], "orgs": ["o1"]}}
|
||||
)
|
||||
validate_credential_access({"access": {"global": False, "teams": ["t1", "t2"], "orgs": ["o1"]}})
|
||||
|
||||
|
||||
def test_validate_credential_access_noop_without_access():
|
||||
|
|
@ -243,49 +46,3 @@ def test_validate_credential_access_rejects_unknown_field():
|
|||
validate_credential_access({"access": {"global": True, "legacy_field": "x"}})
|
||||
assert exc.value.status_code == 400
|
||||
assert "legacy_field" in exc.value.detail["error"]
|
||||
|
||||
|
||||
# --- validate_logging_exporter_field (the column-backed adapter) ------------
|
||||
#
|
||||
# The endpoints now pass a typed list off the request's ``logging_exporters``
|
||||
# field instead of a metadata dict. The adapter must gate the same way, and the
|
||||
# typed field's None-means-omitted semantics must not open a bypass.
|
||||
|
||||
|
||||
def test_field_none_is_noop_for_non_admin(_registry):
|
||||
"""A request that omits logging_exporters (None) must not require authorization."""
|
||||
validate_logging_exporter_field(None, _non_admin())
|
||||
|
||||
|
||||
def test_field_set_by_non_admin_is_forbidden(_registry):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_field(["langfuse-eu"], _non_admin())
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_field_proxy_admin_can_assign(_registry):
|
||||
"""Proxy admin may assign any registered logging destination."""
|
||||
validate_logging_exporter_field(["arize-ds"], _admin())
|
||||
|
||||
|
||||
def test_field_empty_clear_over_existing_is_gated_for_non_admin(_registry):
|
||||
"""Clearing an admin-assigned value ([] over a non-empty stored column) is a
|
||||
change and must be authorized; a non-admin cannot silently wipe it."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validate_logging_exporter_field(
|
||||
[],
|
||||
_non_admin(),
|
||||
existing_exporters=["langfuse-eu"],
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_field_unchanged_value_is_noop(_registry):
|
||||
"""Re-sending the same column value is a no-op even for a non-admin."""
|
||||
validate_logging_exporter_field(
|
||||
["langfuse-eu"],
|
||||
_non_admin(),
|
||||
existing_exporters=["langfuse-eu"],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -779,50 +779,6 @@ async def test_v2_update_metadata_replaces_not_merges(monkeypatch):
|
|||
assert json.loads(write_data["metadata"]) == {"a": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_writes_logging_exporters_to_org_column(monkeypatch):
|
||||
"""Assigning logging_exporters writes the credential names to the org column, not the budget row or metadata."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"logging_exporters": ["arize-prod", "langfuse-eu"]},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"keep": "me"},
|
||||
)
|
||||
|
||||
prisma.db.litellm_budgettable.update.assert_not_awaited()
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["logging_exporters"] == ["arize-prod", "langfuse-eu"]
|
||||
assert "metadata" not in write_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_clears_logging_exporters_with_empty_list(monkeypatch):
|
||||
"""A null logging_exporters clears the org's assignments by writing an empty list to the non-nullable column."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"logging_exporters": None},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"keep": "me"},
|
||||
)
|
||||
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert write_data["logging_exporters"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_update_omitted_logging_exporters_not_written(monkeypatch):
|
||||
"""Omitting logging_exporters leaves the existing assignments untouched."""
|
||||
prisma = await _run_update_organization_v2(
|
||||
monkeypatch,
|
||||
body={"organization_alias": "renamed"},
|
||||
existing_budget_id="budget-1",
|
||||
existing_metadata={"keep": "me"},
|
||||
)
|
||||
|
||||
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
|
||||
assert "logging_exporters" not in write_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_rejects_null_clear_of_non_nullable_fields(monkeypatch):
|
||||
"""organization_alias and models are non-nullable columns, so a null clear is a 422, not a 500."""
|
||||
|
|
|
|||
|
|
@ -5156,7 +5156,7 @@ def _seeded_logging_credentials():
|
|||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "langfuse_otel",
|
||||
"access": {"global": True},
|
||||
"access": {"teams": ["team-x"]},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
|
|
@ -5169,7 +5169,25 @@ def _seeded_logging_credentials():
|
|||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"access": {"global": True},
|
||||
"access": {"teams": ["team-az"]},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
credential_name="generic-org",
|
||||
credential_values={"otel_endpoint": "http://collector.internal/v1/traces"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "generic",
|
||||
"access": {"orgs": ["org-1"]},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
credential_name="empty-deny",
|
||||
credential_values={"otel_endpoint": "http://never/v1/traces"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "generic",
|
||||
"access": {},
|
||||
},
|
||||
),
|
||||
# A provider credential that must never resolve as a logging destination.
|
||||
|
|
@ -5189,14 +5207,12 @@ def _auth(token="hashed-key", org_id=None, team_id="team-x"):
|
|||
return UserAPIKeyAuth(api_key="hashed-key", token=token, org_id=org_id, team_id=team_id)
|
||||
|
||||
|
||||
def _patch_identity(monkeypatch, *, key=(), team=(), org=(), team_org_id=None):
|
||||
"""Route the resolver's identity lookups to ``logging_exporters`` columns.
|
||||
def _patch_identity(monkeypatch, *, team_org_id=None, **_ignored):
|
||||
"""Connect a prisma client and route the resolver's only remaining DB lookup.
|
||||
|
||||
Assignments now live on typed columns, so the resolver reads each level from
|
||||
its own DB object. This connects a prisma client and patches
|
||||
``get_key_object`` / ``get_team_object`` / ``get_org_object`` to return objects
|
||||
carrying the given ``logging_exporters``. ``team_org_id`` sets the team's
|
||||
``organization_id`` for the token-has-no-org_id org fallback.
|
||||
Selection is access-only, read from ``litellm.credential_list``. The sole lookup
|
||||
left is ``_effective_org_id`` resolving the team's organization when the token
|
||||
carries no ``org_id``, so ``get_team_object`` returns just ``organization_id``.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
|
@ -5205,49 +5221,44 @@ def _patch_identity(monkeypatch, *, key=(), team=(), org=(), team_org_id=None):
|
|||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_key_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(key)))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_team_object",
|
||||
AsyncMock(return_value=SimpleNamespace(logging_exporters=list(team), organization_id=team_org_id)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_org_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(org)))
|
||||
AsyncMock(return_value=SimpleNamespace(organization_id=team_org_id)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, monkeypatch):
|
||||
# team assignment lives on the team's logging_exporters column.
|
||||
async def test_resolve_logging_exporters_team_access(_seeded_logging_credentials, monkeypatch):
|
||||
"""A destination whose access grants the caller's team fires for it, and only it."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, backends = await _resolve_logging_exporters(_auth())
|
||||
assert {d["endpoint"] for d in destinations} == {
|
||||
"https://cloud.langfuse.com/api/public/otel"
|
||||
}
|
||||
_patch_identity(monkeypatch)
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-x"))
|
||||
assert {d["endpoint"] for d in destinations} == {"https://cloud.langfuse.com/api/public/otel"}
|
||||
assert backends == ("langfuse_otel",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_unions_team_org_keys_inherit(
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
# team and org are each read from their OWN logging_exporters column and union,
|
||||
# deduped. Keys have no assignment column: they inherit from team/org, so a
|
||||
# key-level value (patched below) must contribute nothing.
|
||||
async def test_resolve_logging_exporters_org_access(_seeded_logging_credentials, monkeypatch):
|
||||
"""An org-scoped destination fires for a caller in that org."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, key=["arize-prod"], team=["langfuse-eu"], org=["langfuse-eu"])
|
||||
_patch_identity(monkeypatch)
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-none", org_id="org-1"))
|
||||
assert {d["endpoint"] for d in destinations} == {"http://collector.internal/v1/traces"}
|
||||
assert backends == ("generic",)
|
||||
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(org_id="org-1"))
|
||||
|
||||
assert {d["endpoint"] for d in destinations} == {
|
||||
"https://cloud.langfuse.com/api/public/otel", # team + org (deduped)
|
||||
}
|
||||
assert set(backends) == {"langfuse_otel"}
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_org_fallback_from_team(_seeded_logging_credentials, monkeypatch):
|
||||
"""When the token carries no org_id, the team's organization grants org-scoped
|
||||
destinations via the ``_effective_org_id`` fallback."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team_org_id="org-1")
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-none", org_id=None))
|
||||
assert {d["endpoint"] for d in destinations} == {"http://collector.internal/v1/traces"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5256,8 +5267,8 @@ async def test_resolve_logging_exporters_carries_arize_project(
|
|||
):
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["arize-prod"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth())
|
||||
_patch_identity(monkeypatch)
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-az"))
|
||||
|
||||
assert destinations == (
|
||||
{
|
||||
|
|
@ -5273,25 +5284,28 @@ async def test_resolve_logging_exporters_carries_arize_project(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_empty_without_assignment(
|
||||
_seeded_logging_credentials,
|
||||
async def test_resolve_logging_exporters_empty_without_access(
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
"""An identity no destination's access grants gets nothing; empty access is deny-all."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, backends = await _resolve_logging_exporters(_auth())
|
||||
_patch_identity(monkeypatch)
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-none"))
|
||||
assert destinations == () and backends == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_skips_unknown_and_provider_creds(
|
||||
async def test_resolve_logging_exporters_skips_provider_creds(
|
||||
_seeded_logging_credentials, monkeypatch
|
||||
):
|
||||
"""A provider credential (not credential_type=logging) is never a destination,
|
||||
even for a team that resolves a real one."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
# unknown name + a provider credential (not credential_type=logging) -> nothing
|
||||
_patch_identity(monkeypatch, team=["does-not-exist", "openai-key"])
|
||||
destinations, backends = await _resolve_logging_exporters(_auth())
|
||||
assert destinations == () and backends == ()
|
||||
_patch_identity(monkeypatch)
|
||||
destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-az"))
|
||||
assert backends == ("arize",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5304,7 +5318,7 @@ async def test_apply_admin_logging_exporters_stamps_and_activates(
|
|||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
_patch_identity(monkeypatch)
|
||||
token = _request_destinations.set(())
|
||||
data: dict = {}
|
||||
try:
|
||||
|
|
@ -5451,218 +5465,19 @@ async def test_apply_admin_logging_exporters_registers_on_failure(
|
|||
from litellm.integrations.otel.plumbing.context import _request_destinations
|
||||
from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu", "arize-prod"])
|
||||
_patch_identity(monkeypatch)
|
||||
token = _request_destinations.set(())
|
||||
# Seed a pre-existing failure callback to prove backends are unioned in, not
|
||||
# overwriting, and that a duplicate backend is not appended twice.
|
||||
data: dict = {"failure_callback": ["arize"]}
|
||||
try:
|
||||
await _apply_admin_logging_exporters(data, _auth())
|
||||
await _apply_admin_logging_exporters(data, _auth(team_id="team-az"))
|
||||
for callback_list in ("success_callback", "failure_callback"):
|
||||
registered = data[callback_list]
|
||||
assert "langfuse_otel" in registered
|
||||
assert "arize" in registered
|
||||
assert registered.count("arize") == 1
|
||||
finally:
|
||||
_request_destinations.reset(token)
|
||||
|
||||
|
||||
_LANGFUSE_ENDPOINT = "https://cloud.langfuse.com/api/public/otel"
|
||||
_ARIZE_ENDPOINT = "https://otlp.arize.com/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _seeded_logging_credentials_with_access():
|
||||
"""``access`` gates enablement. ``langfuse-eu`` is granted to
|
||||
``team-eu``/``org-eu`` but never auto-fires (not auto_enable, not named);
|
||||
``arize-global`` carries ``access.global`` to prove global visibility alone
|
||||
STILL does not auto-fire; ``arize-default`` is the proxy-wide auto default
|
||||
(``auto_enable`` + ``access.global``). Empty access would be deny-all."""
|
||||
from litellm.models.credentials import CredentialItem
|
||||
|
||||
original = litellm.credential_list
|
||||
litellm.credential_list = [
|
||||
CredentialItem(
|
||||
credential_name="langfuse-eu",
|
||||
credential_values={
|
||||
"langfuse_host": "https://cloud.langfuse.com",
|
||||
"langfuse_public_key": "pk-eu",
|
||||
"langfuse_secret_key": "sk-eu",
|
||||
},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "langfuse_otel",
|
||||
"access": {"teams": ["team-eu"], "orgs": ["org-eu"]},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
credential_name="arize-global",
|
||||
credential_values={"arize_space_id": "S", "arize_api_key": "K"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"access": {"global": True},
|
||||
},
|
||||
),
|
||||
CredentialItem(
|
||||
credential_name="arize-default",
|
||||
credential_values={"arize_space_id": "D", "arize_api_key": "K"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"auto_enable": True,
|
||||
"access": {"global": True},
|
||||
},
|
||||
),
|
||||
]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
litellm.credential_list = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_grant_does_not_auto_enable(
|
||||
_seeded_logging_credentials_with_access,
|
||||
):
|
||||
"""Granting a destination to a team (or globally) must NOT enable it for the
|
||||
team's requests. The pre-fix resolver fired on access alone; this pins that
|
||||
access is now visibility-only. ``arize-default`` (auto_enable) is the only thing
|
||||
that fires for an unassigned team-eu caller."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu"))
|
||||
|
||||
# langfuse-eu is granted to team-eu and arize-global is access.global, yet
|
||||
# neither fires because neither is named; only the explicit auto_enable does.
|
||||
assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_name_with_grant_enables(
|
||||
_seeded_logging_credentials_with_access, monkeypatch
|
||||
):
|
||||
"""Naming a destination the caller is granted enables it (alongside the
|
||||
auto_enable default)."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu"))
|
||||
|
||||
assert {d["endpoint"] for d in destinations} == {_LANGFUSE_ENDPOINT, _ARIZE_ENDPOINT}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_name_without_visibility_is_dropped(
|
||||
_seeded_logging_credentials_with_access, monkeypatch
|
||||
):
|
||||
"""A name that points at a destination NOT visible to the request identity is
|
||||
defensively ignored, so a stale or cross-tenant assignment can never route
|
||||
traffic out. team-other names langfuse-eu (granted only to team-eu)."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
_patch_identity(monkeypatch, team=["langfuse-eu"])
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-other"))
|
||||
|
||||
# langfuse-eu dropped (not visible to team-other); only auto_enable survives.
|
||||
assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_auto_enable_empty_access_is_deny_all(monkeypatch):
|
||||
"""The core of the empty-access hardening: an auto_enable destination with no
|
||||
access grants fires for NO ONE (empty access = deny-all, not proxy-wide).
|
||||
Mutating the resolver to treat empty access as proxy-wide re-fires it here."""
|
||||
from litellm.models.credentials import CredentialItem
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
original = litellm.credential_list
|
||||
litellm.credential_list = [
|
||||
CredentialItem(
|
||||
credential_name="arize-empty-auto",
|
||||
credential_values={"arize_space_id": "E", "arize_api_key": "K"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"auto_enable": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
try:
|
||||
for auth in (_auth(team_id="team-x"), _auth(org_id="org-y"), _auth()):
|
||||
destinations, _ = await _resolve_logging_exporters(auth)
|
||||
assert destinations == ()
|
||||
finally:
|
||||
litellm.credential_list = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_access_global_alone_does_not_fire(
|
||||
_seeded_logging_credentials_with_access,
|
||||
):
|
||||
"""The headline regression: a destination with access.global but no auto_enable
|
||||
and no name must NOT fire for an unassigned caller. Mutating the resolver back to
|
||||
selecting on access alone re-adds arize-global here and fails this test."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
# an org with no grants, no names: only the auto_enable default fires.
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(org_id="org-unrelated"))
|
||||
|
||||
# exactly one destination -- the auto_enable arize-default (space_id "D").
|
||||
# arize-global shares the arize endpoint but carries space_id "S"; if access.global
|
||||
# auto-fired it would survive as a SECOND destination here.
|
||||
assert len(destinations) == 1
|
||||
assert destinations[0]["endpoint"] == _ARIZE_ENDPOINT
|
||||
assert destinations[0]["headers"]["space_id"] == "D"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_logging_exporters_access_default_deny(
|
||||
_seeded_logging_credentials,
|
||||
):
|
||||
"""With no auto_enable and no identity assignment, nothing resolves even though
|
||||
the seeded creds are access.global-visible -- visibility never invents a
|
||||
destination."""
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
destinations, backends = await _resolve_logging_exporters(
|
||||
_auth(team_id="team-eu", org_id="org-eu")
|
||||
)
|
||||
assert destinations == () and backends == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch):
|
||||
"""A team key whose token carries no org_id must still resolve an org-scoped
|
||||
destination, via the team's organization_id. The write gate loads the team and
|
||||
accepts the assignment, so the resolver must agree (M1); without the fallback the
|
||||
org-granted destination is named but invisible (org_id None) and silently dropped.
|
||||
Reverting _effective_org_id to user_api_key_dict.org_id fails this test."""
|
||||
from litellm.models.credentials import CredentialItem
|
||||
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
|
||||
|
||||
original = litellm.credential_list
|
||||
litellm.credential_list = [
|
||||
CredentialItem(
|
||||
credential_name="arize-org",
|
||||
credential_values={"arize_space_id": "S", "arize_api_key": "K"},
|
||||
credential_info={
|
||||
"credential_type": "logging",
|
||||
"description": "arize",
|
||||
"access": {"orgs": ["org-7"]},
|
||||
},
|
||||
),
|
||||
]
|
||||
# The key's token has no org_id; the team it belongs to is in org-7, and the
|
||||
# arize-org destination is named on the team's logging_exporters column.
|
||||
_patch_identity(monkeypatch, team=["arize-org"], team_org_id="org-7")
|
||||
try:
|
||||
destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-x"))
|
||||
assert {d["endpoint"] for d in destinations} == {"https://otlp.arize.com/v1"}
|
||||
finally:
|
||||
litellm.credential_list = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_merges_metadata_tags_on_responses_route():
|
||||
"""Regression for #31584: user-supplied metadata.tags must be merged into
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ describe("LoggingCallbacksTable", () => {
|
|||
expect(screen.getByText("Failure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a global destination's scope and manual assignment mode", () => {
|
||||
it("renders a global destination's scope", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
|
|
@ -134,7 +134,6 @@ describe("LoggingCallbacksTable", () => {
|
|||
/>,
|
||||
);
|
||||
expect(screen.getByText("Global access")).toBeInTheDocument();
|
||||
expect(screen.getByText("Manual assignment")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Success")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -158,45 +157,6 @@ describe("LoggingCallbacksTable", () => {
|
|||
expect(screen.getByText("org: o1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders auto-enable mode for a destination", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
{
|
||||
name: "otel-auto",
|
||||
variables: baseVars,
|
||||
credentialName: "otel-auto",
|
||||
access: { teams: ["t1"] },
|
||||
autoEnable: true,
|
||||
resolvedScope: { global: false, teams: ["t1"], orgs: [] },
|
||||
},
|
||||
]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Auto-enabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders disabled mode for an auto-enable destination with no access grants", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
{
|
||||
name: "otel-empty",
|
||||
variables: baseVars,
|
||||
credentialName: "otel-empty",
|
||||
access: { global: false, teams: [], orgs: [] },
|
||||
autoEnable: true,
|
||||
resolvedScope: { global: false, teams: [], orgs: [] },
|
||||
},
|
||||
]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Disabled")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Auto-enabled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a destination row edits access and deletes without exposing callback actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditAccess = vi.fn();
|
||||
|
|
|
|||
|
|
@ -45,34 +45,6 @@ function callbackModeTone(mode: string): StatusTone {
|
|||
return "info";
|
||||
}
|
||||
|
||||
function destinationMode(record: AlertingObject) {
|
||||
if (record.autoEnable !== true) {
|
||||
return <span className="text-xs text-muted-foreground">Manual assignment</span>;
|
||||
}
|
||||
const access = record.access;
|
||||
const hasExplicitGrants = [
|
||||
access?.global === true,
|
||||
(access?.teams?.length ?? 0) > 0,
|
||||
(access?.orgs?.length ?? 0) > 0,
|
||||
].some(Boolean);
|
||||
if (!hasExplicitGrants) {
|
||||
return (
|
||||
<StatusBadge
|
||||
tone="neutral"
|
||||
label="Disabled"
|
||||
tooltip="No access grants, so this destination receives nothing. Add Access (Global, or specific Teams/Orgs) to enable it."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StatusBadge
|
||||
tone="warning"
|
||||
label="Auto-enabled"
|
||||
tooltip="Exports automatically for all identities within the access scope without requiring explicit assignment."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeCell({ callback }: { callback: AlertingObject }) {
|
||||
const scope = callback.resolvedScope;
|
||||
const hasResolvedScope = scope?.global === true || [...(scope?.teams ?? []), ...(scope?.orgs ?? [])].length > 0;
|
||||
|
|
@ -195,7 +167,7 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (isDestination(row.original)) {
|
||||
return destinationMode(row.original);
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
const mode = callbackRowMode(row.original);
|
||||
return <StatusBadge tone={callbackModeTone(mode)} label={CALLBACK_MODE_LABELS[mode] || mode} />;
|
||||
|
|
|
|||
|
|
@ -14,14 +14,9 @@ export interface AlertingObject {
|
|||
credentialName?: string;
|
||||
destinationLabel?: string;
|
||||
access?: CredentialAccess;
|
||||
// True when credential_info.auto_enable=true: destination exports on every
|
||||
// request without needing explicit key/team/org assignment. Distinct from
|
||||
// access.global (which controls visibility/assignability, not routing).
|
||||
autoEnable?: boolean;
|
||||
// The union of identities that route to this destination, resolved at render
|
||||
// time from both directions (destination-side credential_info.access AND
|
||||
// identity-side metadata.logging_exporters). Display labels only -- ids are
|
||||
// not surfaced here. global=true bypasses the lists.
|
||||
// The set of identities that route to this destination, resolved at render
|
||||
// time from credential_info.access. Display labels only -- ids are not
|
||||
// surfaced here. global=true bypasses the lists.
|
||||
resolvedScope?: ResolvedScope;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ interface EditTeamModalProps {
|
|||
}
|
||||
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import { LoggingExportersFormItem } from "./logging_credentials/LoggingExportersSelect";
|
||||
import { teamCreateCall } from "./networking";
|
||||
import { ModelSelect } from "./ModelSelect/ModelSelect";
|
||||
|
||||
|
|
@ -342,13 +341,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
formValues.metadata = JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
// logging_exporters is a top-level typed field on the team (its own column),
|
||||
// not part of the free-form metadata blob; send it as-is when set so the user
|
||||
// can assign destinations from the new-team form (instead of create-then-edit).
|
||||
if (!Array.isArray(formValues.logging_exporters) || formValues.logging_exporters.length === 0) {
|
||||
delete formValues.logging_exporters;
|
||||
}
|
||||
|
||||
if (formValues.secret_manager_settings) {
|
||||
if (typeof formValues.secret_manager_settings === "string") {
|
||||
if (formValues.secret_manager_settings.trim() === "") {
|
||||
|
|
@ -1101,10 +1093,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
<b>Logging Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<LoggingExportersFormItem
|
||||
tooltip="Admin-owned trace destinations this team exports to. Resolved server-side and fanned out (added to the key's and org's). Manage destinations under Settings -> Logging Callbacks."
|
||||
className="mt-4"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<PremiumLoggingSettings
|
||||
value={loggingSettings}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
|
|||
id: "arize",
|
||||
displayName: "Arize",
|
||||
logo: arizeLogo.src,
|
||||
// OTEL v2 destination: assigned per identity via the "Logging Exporters" field
|
||||
// (metadata.logging_exporters), not configured as a per-team callback here.
|
||||
// OTEL v2 destination: admin-owned and routed by credential_info.access,
|
||||
// not configured as a per-team callback here.
|
||||
supports_key_team_logging: false,
|
||||
dynamic_params: {},
|
||||
description: "Arize Logging Integration",
|
||||
|
|
@ -103,8 +103,8 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
|
|||
id: "langfuse_otel",
|
||||
displayName: "Langfuse OTEL",
|
||||
logo: langfuseLogo.src,
|
||||
// OTEL v2 destination: assigned per identity via the "Logging Exporters" field
|
||||
// (metadata.logging_exporters), not configured as a per-team callback here.
|
||||
// OTEL v2 destination: admin-owned and routed by credential_info.access,
|
||||
// not configured as a per-team callback here.
|
||||
supports_key_team_logging: false,
|
||||
dynamic_params: {},
|
||||
description: "Langfuse v3 OTEL Logging Integration",
|
||||
|
|
@ -112,8 +112,8 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
|
|||
{
|
||||
id: "weave_otel",
|
||||
displayName: "Weave OTEL",
|
||||
// OTEL v2 destination: assigned per identity via the "Logging Exporters" field
|
||||
// (metadata.logging_exporters), not configured as a per-team callback here.
|
||||
// OTEL v2 destination: admin-owned and routed by credential_info.access,
|
||||
// not configured as a per-team callback here.
|
||||
supports_key_team_logging: false,
|
||||
dynamic_params: {},
|
||||
description: "Weave (W&B) OTEL Logging Integration",
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, o
|
|||
<>
|
||||
<Form.Item
|
||||
label="Global"
|
||||
tooltip="Routing scope only: traces from every team and org may export to this destination. It does not turn on tracing by itself -- assign it on a key/team/org, or turn on Enable for entire scope, for that."
|
||||
tooltip="Routing scope: traces from every team and org export to this destination."
|
||||
>
|
||||
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Teams"
|
||||
tooltip="Routing scope: only these teams' traffic may export to this destination, once it is auto-enabled or assigned."
|
||||
tooltip="Routing scope: only these teams' traffic exports to this destination."
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
|
|
@ -52,7 +52,7 @@ const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, o
|
|||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Organizations"
|
||||
tooltip="Routing scope: only these orgs' traffic may export to this destination, once it is auto-enabled or assigned."
|
||||
tooltip="Routing scope: only these orgs' traffic exports to this destination."
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import LoggingExportersSelect from "./LoggingExportersSelect";
|
||||
|
||||
const mockUseCredentials = vi.fn();
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
|
||||
useCredentials: () => mockUseCredentials(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
function Select(props: any) {
|
||||
const { value, onChange, options, notFoundContent } = props;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "logging-exporters-select" },
|
||||
React.createElement(
|
||||
"ul",
|
||||
null,
|
||||
(options ?? []).map((opt: any) =>
|
||||
React.createElement("li", { key: opt.value, "data-testid": "option" }, opt.label),
|
||||
),
|
||||
),
|
||||
options && options.length === 0 ? React.createElement("div", { "data-testid": "empty" }, notFoundContent) : null,
|
||||
React.createElement(
|
||||
"button",
|
||||
{ "data-testid": "pick-first", onClick: () => onChange?.(options?.[0] ? [options[0].value] : []) },
|
||||
"pick first",
|
||||
),
|
||||
React.createElement("div", { "data-testid": "value" }, JSON.stringify(value ?? [])),
|
||||
);
|
||||
}
|
||||
return { Select };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseCredentials.mockReset();
|
||||
});
|
||||
|
||||
describe("LoggingExportersSelect", () => {
|
||||
it("only surfaces credentials whose credential_type is 'logging'", () => {
|
||||
mockUseCredentials.mockReturnValue({
|
||||
data: {
|
||||
credentials: [
|
||||
{
|
||||
credential_name: "poc-langfuse",
|
||||
credential_info: { credential_type: "logging", host: "https://cloud.langfuse.com" },
|
||||
},
|
||||
{
|
||||
credential_name: "poc-arize",
|
||||
credential_info: { credential_type: "logging" },
|
||||
},
|
||||
{
|
||||
credential_name: "openai-prod",
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
|
||||
|
||||
const options = screen.getAllByTestId("option").map((el) => el.textContent);
|
||||
expect(options).toEqual(["poc-langfuse (https://cloud.langfuse.com)", "poc-arize"]);
|
||||
});
|
||||
|
||||
it("renders empty-state copy when no logging destinations exist", () => {
|
||||
mockUseCredentials.mockReturnValue({
|
||||
data: {
|
||||
credentials: [
|
||||
{
|
||||
credential_name: "openai-prod",
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
|
||||
|
||||
expect(screen.queryAllByTestId("option")).toHaveLength(0);
|
||||
expect(screen.getByTestId("empty").textContent).toMatch(/Create one under Settings/i);
|
||||
});
|
||||
|
||||
it("shows exactly the logging destinations the backend returned, without any client-side scope filtering", () => {
|
||||
// GET /credentials is proxy-admin only and returns every destination; the
|
||||
// picker (which itself renders only for a proxy admin) must show every
|
||||
// logging-typed destination in the response verbatim regardless of its
|
||||
// access shape, since access controls request-time routing, not what the
|
||||
// admin may assign. This response mixes access shapes to prove none are
|
||||
// dropped locally.
|
||||
mockUseCredentials.mockReturnValue({
|
||||
data: {
|
||||
credentials: [
|
||||
{ credential_name: "team-scoped", credential_info: { credential_type: "logging", access: { teams: ["t"] } } },
|
||||
{ credential_name: "org-scoped", credential_info: { credential_type: "logging", access: { orgs: ["o"] } } },
|
||||
{ credential_name: "everyone", credential_info: { credential_type: "logging", access: { global: true } } },
|
||||
{ credential_name: "always-on", credential_info: { credential_type: "logging", auto_enable: true } },
|
||||
{ credential_name: "provider", credential_info: { custom_llm_provider: "openai" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
|
||||
|
||||
const options = screen.getAllByTestId("option").map((el) => el.textContent);
|
||||
// every logging destination the backend returned, and only those (provider dropped)
|
||||
expect(options).toEqual(["team-scoped", "org-scoped", "everyone", "always-on"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Form, Select } from "antd";
|
||||
import React from "react";
|
||||
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
interface LoggingExportersSelectProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select of admin-owned logging destinations (credential_type=logging) that an
|
||||
* identity (key / team / org) exports its traces to. The selected names are persisted to
|
||||
* the identity's logging_exporters column; the proxy unions them across the identity
|
||||
* chain and fans out.
|
||||
*
|
||||
* Assigning logging exporters is proxy-admin only (GET /credentials and the assignment
|
||||
* gate both reject non-proxy-admins), so this control renders only for a proxy admin.
|
||||
*/
|
||||
const LoggingExportersSelect: React.FC<LoggingExportersSelectProps> = ({ value, onChange }) => {
|
||||
const { userRole } = useAuthorized();
|
||||
const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false;
|
||||
const { data } = useCredentials(isProxyAdmin);
|
||||
|
||||
if (!isProxyAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = (data?.credentials ?? [])
|
||||
.filter((credential) => credential.credential_info?.credential_type === "logging")
|
||||
.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_info?.host
|
||||
? `${credential.credential_name} (${credential.credential_info.host})`
|
||||
: credential.credential_name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="Select logging destinations this identity exports to"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
notFoundContent="No logging destinations available. Create one under Settings -> Logging Callbacks."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoggingExportersSelect;
|
||||
|
||||
interface LoggingExportersFormItemProps {
|
||||
tooltip: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The antd Form.Item wrapper for LoggingExportersSelect, gated to proxy admins so
|
||||
* non-admin forms render neither the picker nor an orphaned "Logging Exporters"
|
||||
* label. Keeps the role gate in one place for every antd form that binds the
|
||||
* logging_exporters field.
|
||||
*/
|
||||
export const LoggingExportersFormItem: React.FC<LoggingExportersFormItemProps> = ({ tooltip, className }) => {
|
||||
const { userRole } = useAuthorized();
|
||||
if (userRole == null || !isProxyAdminRole(userRole)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item label="Logging Exporters" name="logging_exporters" tooltip={tooltip} className={className}>
|
||||
<LoggingExportersSelect />
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
|
@ -25,12 +25,10 @@ export interface CreateLoggingCredentialInput {
|
|||
values: Record<string, string>;
|
||||
host?: string;
|
||||
access?: CredentialAccess;
|
||||
autoEnable?: boolean;
|
||||
}
|
||||
|
||||
// One place that owns the logging-credential contract: the credential_type tag, the
|
||||
// backend in description, the non-secret host, the admin-owned access grant, and the
|
||||
// explicit global/default (auto_enable) opt-in.
|
||||
// backend in description, the non-secret host, and the admin-owned access grant.
|
||||
export const createLoggingCredential = async (accessToken: string, input: CreateLoggingCredentialInput) =>
|
||||
credentialCreateCall(accessToken, {
|
||||
credential_name: input.credentialName,
|
||||
|
|
@ -40,6 +38,5 @@ export const createLoggingCredential = async (accessToken: string, input: Create
|
|||
description: input.backend,
|
||||
...(input.host ? { host: input.host } : {}),
|
||||
...(input.access ? { access: input.access } : {}),
|
||||
...(input.autoEnable ? { auto_enable: true } : {}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* The admin-owned logging destinations assigned to an identity (key / team / org).
|
||||
*
|
||||
* Assignments live on a typed logging_exporters column, surfaced at the top level of
|
||||
* the API object. A metadata.logging_exporters fallback is kept only so a row written
|
||||
* before the column existed still renders; the column is the source of truth.
|
||||
*/
|
||||
export const loggingExportersOf = (obj: unknown): string[] => {
|
||||
const record = obj as {
|
||||
logging_exporters?: unknown;
|
||||
metadata?: { logging_exporters?: unknown } | null;
|
||||
} | null;
|
||||
if (Array.isArray(record?.logging_exporters)) {
|
||||
return record.logging_exporters as string[];
|
||||
}
|
||||
const fromMetadata = record?.metadata?.logging_exporters;
|
||||
return Array.isArray(fromMetadata) ? (fromMetadata as string[]) : [];
|
||||
};
|
||||
|
|
@ -13,11 +13,8 @@ interface LoggingConfig {
|
|||
interface LoggingSettingsViewProps {
|
||||
loggingConfigs?: LoggingConfig[];
|
||||
disabledCallbacks?: string[];
|
||||
// Destinations this identity assigned itself, via metadata.logging_exporters.
|
||||
loggingExporters?: string[];
|
||||
// Destinations that target this identity via the credential's own scope
|
||||
// (credential_info.access.{teams,orgs,global}) -- the other direction. The
|
||||
// resolver unions both at request time; the UI unions them here for display.
|
||||
// Destinations that route to this identity via credential_info.access
|
||||
// (teams/orgs/global), resolved server-side. Display only.
|
||||
scopedExporters?: string[];
|
||||
variant?: "card" | "inline";
|
||||
className?: string;
|
||||
|
|
@ -26,7 +23,6 @@ interface LoggingSettingsViewProps {
|
|||
export function LoggingSettingsView({
|
||||
loggingConfigs = [],
|
||||
disabledCallbacks = [],
|
||||
loggingExporters = [],
|
||||
scopedExporters = [],
|
||||
variant = "card",
|
||||
className = "",
|
||||
|
|
@ -65,44 +61,26 @@ export function LoggingSettingsView({
|
|||
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Logging Exporters: the union of destinations routing to this identity.
|
||||
Own = destinations this identity listed in its metadata.logging_exporters.
|
||||
Via scope = destinations whose credential_info.access targets this identity
|
||||
(a team/org id, or global). Both directions count; we render them together,
|
||||
marking how each entry was resolved. */}
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const ownSet = new Set(loggingExporters);
|
||||
const scopedOnly = scopedExporters.filter((name) => !ownSet.has(name));
|
||||
const entries = [
|
||||
...loggingExporters.map((name) => ({ name, source: "own" as const })),
|
||||
...scopedOnly.map((name) => ({ name, source: "scope" as const })),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CogIcon className="h-4 w-4 text-blue-600" />
|
||||
<span className="font-semibold text-gray-900">Logging Exporters</span>
|
||||
<Tag color="blue">{entries.length}</Tag>
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entries.map((entry, index) => (
|
||||
<Tag key={index} color={entry.source === "own" ? "blue" : "geekblue"}>
|
||||
{entry.name}
|
||||
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<CogIcon className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center gap-2">
|
||||
<CogIcon className="h-4 w-4 text-blue-600" />
|
||||
<span className="font-semibold text-gray-900">Logging Exporters</span>
|
||||
<Tag color="blue">{scopedExporters.length}</Tag>
|
||||
</div>
|
||||
{scopedExporters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopedExporters.map((name, index) => (
|
||||
<Tag key={index} color="geekblue">
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
|
||||
<CogIcon className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logging Integrations Section */}
|
||||
|
|
|
|||
|
|
@ -233,10 +233,6 @@ export interface CredentialItem {
|
|||
teams?: string[];
|
||||
orgs?: string[];
|
||||
};
|
||||
// Explicit global/default: when true the destination exports on every request
|
||||
// without being named on any key/team/org. The deliberate replacement for the
|
||||
// old behavior where access.global implicitly auto-enabled.
|
||||
auto_enable?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,6 @@ vi.mock("@/components/ModelSelect/ModelSelect", () => ({
|
|||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/logging_credentials/LoggingExportersSelect", () => ({
|
||||
__esModule: true,
|
||||
default: ({ onChange }: { onChange: (values: string[]) => void }) => (
|
||||
<button type="button" onClick={() => onChange(["arize-prod"])}>
|
||||
set-logging-exporters
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
|
||||
__esModule: true,
|
||||
default: ({ onChange }: { onChange: (values: string[]) => void }) => (
|
||||
|
|
@ -117,22 +109,6 @@ describe("OrgCreateDialog", () => {
|
|||
expect(createOrganization.mock.calls[0][0]).toStrictEqual(expectedBody);
|
||||
});
|
||||
|
||||
it("sends the selected logging exporters in the create body", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { createOrganization } = renderDialog();
|
||||
|
||||
await user.type(screen.getByLabelText("Organization Name"), "new-org");
|
||||
await user.click(screen.getByRole("button", { name: "set-logging-exporters" }));
|
||||
await user.click(screen.getByRole("button", { name: "Create Organization" }));
|
||||
|
||||
await waitFor(() => expect(createOrganization).toHaveBeenCalledTimes(1));
|
||||
expect(createOrganization.mock.calls[0][0]).toStrictEqual({
|
||||
organization_alias: "new-org",
|
||||
models: [],
|
||||
logging_exporters: ["arize-prod"],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submit and shows an error for invalid metadata JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { createOrganization } = renderDialog();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import * as React from "react";
|
||||
|
||||
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import LoggingExportersSelect from "@/components/logging_credentials/LoggingExportersSelect";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
|
@ -45,8 +42,6 @@ export const OrgCreateDialog = ({
|
|||
}: OrgCreateDialogProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(orgSettingsSchema, { defaultValues: emptyOrgFormValues });
|
||||
const { userRole } = useAuthorized();
|
||||
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
|
||||
|
||||
const closeAndReset = () => {
|
||||
form.reset(emptyOrgFormValues);
|
||||
|
|
@ -166,17 +161,6 @@ export const OrgCreateDialog = ({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
{isProxyAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logging_exporters"
|
||||
label="Logging Exporters"
|
||||
description="Admin-owned trace destinations every team and key in this org exports to. Manage destinations under Settings -> Logging Callbacks."
|
||||
>
|
||||
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField control={form.control} name="metadata" label="Metadata">
|
||||
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ describe("buildOrgCreateBody", () => {
|
|||
rpm_limit: "50",
|
||||
vector_stores: ["vs-1"],
|
||||
mcp: { servers: ["srv-1"], accessGroups: ["ag-1"], toolsets: ["ts-1"] },
|
||||
logging_exporters: ["arize-prod", "langfuse-eu"],
|
||||
metadata: '{"env": "prod"}',
|
||||
};
|
||||
const expectedBody = {
|
||||
|
|
@ -30,7 +29,6 @@ describe("buildOrgCreateBody", () => {
|
|||
budget_duration: "30d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 50,
|
||||
logging_exporters: ["arize-prod", "langfuse-eu"],
|
||||
metadata: { env: "prod" },
|
||||
object_permission: {
|
||||
vector_stores: ["vs-1"],
|
||||
|
|
@ -53,16 +51,6 @@ describe("buildOrgCreateBody", () => {
|
|||
).toStrictEqual({ mcp_toolsets: ["ts-1"] });
|
||||
});
|
||||
|
||||
it("sends logging_exporters only when at least one destination is selected", () => {
|
||||
expect(buildOrgCreateBody({ ...emptyOrgFormValues, organization_alias: "acme" })).not.toHaveProperty(
|
||||
"logging_exporters",
|
||||
);
|
||||
expect(
|
||||
buildOrgCreateBody({ ...emptyOrgFormValues, organization_alias: "acme", logging_exporters: ["arize-prod"] })
|
||||
.logging_exporters,
|
||||
).toStrictEqual(["arize-prod"]);
|
||||
});
|
||||
|
||||
it("parses metadata into an object instead of sending the raw string", () => {
|
||||
expect(
|
||||
buildOrgCreateBody({ ...emptyOrgFormValues, organization_alias: "acme", metadata: '{"a": 1}' }).metadata,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export const emptyOrgFormValues: OrgSettingsFormValues = {
|
|||
rpm_limit: "",
|
||||
vector_stores: [],
|
||||
mcp: { servers: [], accessGroups: [], toolsets: [] },
|
||||
logging_exporters: [],
|
||||
metadata: "",
|
||||
};
|
||||
|
||||
|
|
@ -40,7 +39,6 @@ export const buildOrgCreateBody = (values: OrgSettingsFormValues): OrgCreateBody
|
|||
...(values.tpm_limit.trim() !== "" && { tpm_limit: Number(values.tpm_limit) }),
|
||||
...(values.rpm_limit.trim() !== "" && { rpm_limit: Number(values.rpm_limit) }),
|
||||
...(values.budget_duration !== "" && { budget_duration: values.budget_duration }),
|
||||
...(values.logging_exporters.length > 0 && { logging_exporters: values.logging_exporters }),
|
||||
...(values.metadata.trim() !== "" && { metadata: metadataRecordSchema.parse(JSON.parse(values.metadata)) }),
|
||||
...(objectPermission !== undefined && { object_permission: objectPermission }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import * as React from "react";
|
||||
|
||||
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import LoggingExportersSelect from "@/components/logging_credentials/LoggingExportersSelect";
|
||||
import type { Organization } from "@/components/networking";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -61,8 +58,6 @@ export const OrgSettingsForm = ({
|
|||
}: OrgSettingsFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(orgSettingsSchema, { defaultValues: orgToForm(org) });
|
||||
const { userRole } = useAuthorized();
|
||||
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
const mutation = useMutation({
|
||||
|
|
@ -155,17 +150,6 @@ export const OrgSettingsForm = ({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
{isProxyAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logging_exporters"
|
||||
label="Logging Exporters"
|
||||
description="Admin-owned trace destinations every team in this org exports to. Manage destinations under Settings -> Logging Callbacks."
|
||||
>
|
||||
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField control={form.control} name="metadata" label="Metadata">
|
||||
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -39,17 +39,11 @@ describe("orgToForm", () => {
|
|||
tpm_limit: "1000",
|
||||
rpm_limit: "50",
|
||||
vector_stores: ["vs-1"],
|
||||
logging_exporters: [],
|
||||
mcp: { servers: ["srv-1"], accessGroups: ["group-1"], toolsets: ["ts-1"] },
|
||||
metadata: JSON.stringify({ cost_center: "eng" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the org's assigned logging exporters from the typed column", () => {
|
||||
const withExporters = { ...org, logging_exporters: ["arize-prod", "langfuse-eu"] } as Organization;
|
||||
expect(orgToForm(withExporters).logging_exporters).toEqual(["arize-prod", "langfuse-eu"]);
|
||||
});
|
||||
|
||||
it("maps missing budget values and permissions to empty widget state", () => {
|
||||
const bare: Organization = {
|
||||
...org,
|
||||
|
|
@ -66,7 +60,6 @@ describe("orgToForm", () => {
|
|||
tpm_limit: "",
|
||||
rpm_limit: "",
|
||||
vector_stores: [],
|
||||
logging_exporters: [],
|
||||
mcp: { servers: [], accessGroups: [], toolsets: [] },
|
||||
metadata: "",
|
||||
});
|
||||
|
|
@ -112,9 +105,4 @@ describe("buildOrgPatch", () => {
|
|||
it("omits object_permission entirely when neither permission field is dirty", () => {
|
||||
expect(buildOrgPatch({ organization_alias: "acme-2" })).toEqual({ organization_alias: "acme-2" });
|
||||
});
|
||||
|
||||
it("sends logging_exporters as a top-level array when dirty, clearing with []", () => {
|
||||
expect(buildOrgPatch({ logging_exporters: ["arize-prod"] })).toEqual({ logging_exporters: ["arize-prod"] });
|
||||
expect(buildOrgPatch({ logging_exporters: [] })).toEqual({ logging_exporters: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { z } from "zod/v4";
|
|||
import type { Organization } from "@/components/networking";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
import { loggingExportersOf } from "../../logging_credentials/loggingExportersOf";
|
||||
import type { OrgSettingsFormValues } from "./schema";
|
||||
|
||||
export type OrgPatchBody = components["schemas"]["OrganizationUpdateRequestV2"];
|
||||
|
|
@ -27,7 +26,6 @@ export const orgToForm = (org: Organization): OrgSettingsFormValues => {
|
|||
tpm_limit: budget.tpm_limit?.toString() ?? "",
|
||||
rpm_limit: budget.rpm_limit?.toString() ?? "",
|
||||
vector_stores: org.object_permission?.vector_stores ?? [],
|
||||
logging_exporters: loggingExportersOf(org),
|
||||
mcp: {
|
||||
servers: org.object_permission?.mcp_servers ?? [],
|
||||
accessGroups: org.object_permission?.mcp_access_groups ?? [],
|
||||
|
|
@ -70,7 +68,6 @@ export const buildOrgPatch = (dirty: Partial<OrgSettingsFormValues>): OrgPatchBo
|
|||
budget_duration: dirty.budget_duration === "" ? null : dirty.budget_duration,
|
||||
}),
|
||||
...(dirty.metadata !== undefined && { metadata: metadataOrNull(dirty.metadata) }),
|
||||
...(dirty.logging_exporters !== undefined && { logging_exporters: dirty.logging_exporters }),
|
||||
...(objectPermission !== undefined && { object_permission: objectPermission }),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ const orgSettingsShape = {
|
|||
tpm_limit: wholeNumberOrEmpty,
|
||||
rpm_limit: wholeNumberOrEmpty,
|
||||
vector_stores: z.array(z.string()),
|
||||
logging_exporters: z.array(z.string()),
|
||||
mcp: z.object({
|
||||
servers: z.array(z.string()),
|
||||
accessGroups: z.array(z.string()),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
} from "../networking";
|
||||
import ObjectPermissionsView from "../object_permissions_view";
|
||||
import MemberModal from "../team/EditMembership";
|
||||
import { loggingExportersOf } from "../logging_credentials/loggingExportersOf";
|
||||
import { OrgSettingsForm } from "./org-settings/OrgSettingsForm";
|
||||
|
||||
interface OrganizationInfoProps {
|
||||
|
|
@ -58,22 +57,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
|
||||
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
|
||||
|
||||
// Destinations that will receive this org's traces, resolved server-side by
|
||||
// /organization/info (own logging_exporters plus auto-enabled destinations whose
|
||||
// access grants the org). Names only; identical for every role.
|
||||
const scopedExportersForOrg = useMemo<string[]>(() => {
|
||||
const own = new Set(loggingExportersOf(orgData));
|
||||
return (orgData?.resolved_logging_exporters ?? []).filter((name) => !own.has(name));
|
||||
}, [orgData]);
|
||||
|
||||
const loggingExporterBadges = useMemo(() => {
|
||||
const own = loggingExportersOf(orgData);
|
||||
const ownSet = new Set(own);
|
||||
return [
|
||||
...own.map((name) => ({ name, viaScope: false })),
|
||||
...scopedExportersForOrg.filter((name) => !ownSet.has(name)).map((name) => ({ name, viaScope: true })),
|
||||
];
|
||||
}, [orgData, scopedExportersForOrg]);
|
||||
const loggingExporterBadges = useMemo(
|
||||
() => (orgData?.resolved_logging_exporters ?? []).map((name) => ({ name })),
|
||||
[orgData],
|
||||
);
|
||||
|
||||
const handleMemberAdd = async (values: any) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
|||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal";
|
||||
import AccessControlFields from "./logging_credentials/AccessControlFields";
|
||||
import { loggingExportersOf } from "./logging_credentials/loggingExportersOf";
|
||||
import {
|
||||
backendLabel,
|
||||
createLoggingCredential,
|
||||
|
|
@ -271,8 +270,6 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
const [editAccessFor, setEditAccessFor] = useState<{ name: string; access?: CredentialAccess } | null>(null);
|
||||
// access for the destination branch of the unified Add modal
|
||||
const [addAccess, setAddAccess] = useState<CredentialAccess>({});
|
||||
// explicit global/default (auto_enable) opt-in for the destination branch
|
||||
const [addAutoEnable, setAddAutoEnable] = useState(false);
|
||||
const addingDestination = selectedCallback != null && LOGGING_BACKEND_IDS.has(selectedCallback);
|
||||
const addingDestinationFields = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedCallback)?.fields ?? [];
|
||||
|
||||
|
|
@ -285,30 +282,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
return o?.organization_alias || id;
|
||||
};
|
||||
|
||||
// For each destination, the Scope column reflects BOTH directions:
|
||||
// (a) destination-side credential_info.access (global/teams/orgs on the credential)
|
||||
// (b) identity-side metadata.logging_exporters on each team/org that lists this destination
|
||||
// The resolver unions them at request time; the column unions them at render time.
|
||||
const resolveScope = (destinationName: string, access?: CredentialAccess): ResolvedScope => {
|
||||
const teams = new Set<string>();
|
||||
const orgs = new Set<string>();
|
||||
const global = access?.global === true;
|
||||
for (const teamId of access?.teams ?? []) teams.add(teamAlias(teamId));
|
||||
for (const orgId of access?.orgs ?? []) orgs.add(orgAlias(orgId));
|
||||
for (const team of teamsData ?? []) {
|
||||
const exporters = loggingExportersOf(team);
|
||||
if (exporters.includes(destinationName)) {
|
||||
teams.add(team.team_alias || team.team_id);
|
||||
}
|
||||
}
|
||||
for (const org of orgsData ?? []) {
|
||||
const exporters = loggingExportersOf(org);
|
||||
if (exporters.includes(destinationName)) {
|
||||
orgs.add(org.organization_alias || org.organization_id);
|
||||
}
|
||||
}
|
||||
return { global, teams: Array.from(teams), orgs: Array.from(orgs) };
|
||||
};
|
||||
const resolveScope = (access?: CredentialAccess): ResolvedScope => ({
|
||||
global: access?.global === true,
|
||||
teams: (access?.teams ?? []).map(teamAlias),
|
||||
orgs: (access?.orgs ?? []).map(orgAlias),
|
||||
});
|
||||
|
||||
const destinationRows: AlertingObject[] = (credentialData?.credentials ?? [])
|
||||
.filter((c) => c.credential_info?.credential_type === "logging")
|
||||
|
|
@ -320,8 +298,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}`
|
||||
: backendLabel(c.credential_info?.description),
|
||||
access: c.credential_info?.access,
|
||||
autoEnable: c.credential_info?.auto_enable === true,
|
||||
resolvedScope: resolveScope(c.credential_name, c.credential_info?.access),
|
||||
resolvedScope: resolveScope(c.credential_info?.access),
|
||||
}));
|
||||
|
||||
const handleDeleteDestination = async (name: string) => {
|
||||
|
|
@ -488,14 +465,12 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
values,
|
||||
host,
|
||||
access: hasAccess ? addAccess : undefined,
|
||||
autoEnable: addAutoEnable,
|
||||
});
|
||||
NotificationsManager.success("Logging destination created");
|
||||
refetchCredentials();
|
||||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setAddAccess({});
|
||||
setAddAutoEnable(false);
|
||||
addForm.resetFields();
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
|
|
@ -843,7 +818,6 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
setAddAccess({});
|
||||
setAddAutoEnable(false);
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
|
|
@ -899,12 +873,6 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
</FormItem>
|
||||
))}
|
||||
<AccessControlFields value={addAccess} onChange={setAddAccess} />
|
||||
<Form.Item
|
||||
label="Enable for entire scope"
|
||||
tooltip="On: every team and org in this destination's scope exports to it automatically. Off: only the keys, teams, or orgs you explicitly assign this destination to will export, letting you activate it for a subset of the scope."
|
||||
>
|
||||
<Switch checked={addAutoEnable} onChange={setAddAutoEnable} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : (
|
||||
<DynamicParamsFields
|
||||
|
|
@ -921,7 +889,6 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
setAddAccess({});
|
||||
setAddAutoEnable(false);
|
||||
addForm.resetFields();
|
||||
}}
|
||||
disabled={isAddingCallback}
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ import NumericalInput from "../shared/numerical_input";
|
|||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "../search_tools/SearchToolSelector";
|
||||
import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import { LoggingExportersFormItem } from "../logging_credentials/LoggingExportersSelect";
|
||||
import { loggingExportersOf } from "../logging_credentials/loggingExportersOf";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import MemberModal from "./EditMembership";
|
||||
import MemberPermissions from "./member_permissions";
|
||||
|
|
@ -231,13 +229,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
|
||||
|
||||
// Destinations that will receive this team's traces, resolved server-side by
|
||||
// /team/info (own logging_exporters plus auto-enabled destinations whose access
|
||||
// grants the team). Names only, visible to every team viewer; the badge list is
|
||||
// identical for every role because no client-side credentials read is involved.
|
||||
const scopedExportersForTeam = useMemo<string[]>(() => {
|
||||
const own = new Set(loggingExportersOf(teamData?.team_info));
|
||||
return (teamData?.team_info?.resolved_logging_exporters ?? []).filter((name) => !own.has(name));
|
||||
}, [teamData?.team_info]);
|
||||
// /team/info from credential_info.access. Names only, visible to every team viewer.
|
||||
const scopedExportersForTeam = useMemo<string[]>(
|
||||
() => teamData?.team_info?.resolved_logging_exporters ?? [],
|
||||
[teamData?.team_info],
|
||||
);
|
||||
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
|
||||
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
|
||||
|
||||
|
|
@ -529,8 +525,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
max_budget: values.max_budget,
|
||||
soft_budget: sanitizeNumeric(values.soft_budget),
|
||||
budget_duration: values.budget_duration,
|
||||
// logging_exporters is a top-level typed column on the team, not metadata.
|
||||
...(values.logging_exporters !== undefined ? { logging_exporters: values.logging_exporters } : {}),
|
||||
metadata: {
|
||||
...parsedMetadata,
|
||||
...passthroughRoutesMetadata,
|
||||
|
|
@ -888,7 +882,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={info.metadata?.logging || []}
|
||||
loggingExporters={loggingExportersOf(info)}
|
||||
scopedExporters={scopedExportersForTeam}
|
||||
disabledCallbacks={[]}
|
||||
variant="card"
|
||||
|
|
@ -1008,7 +1001,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
)
|
||||
: "",
|
||||
logging_settings: info.metadata?.logging || [],
|
||||
logging_exporters: loggingExportersOf(info),
|
||||
secret_manager_settings: info.metadata?.secret_manager_settings
|
||||
? JSON.stringify(info.metadata.secret_manager_settings, null, 2)
|
||||
: "",
|
||||
|
|
@ -1471,8 +1463,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<LoggingExportersFormItem tooltip="Trace destinations this team exports to. Resolved server-side and unioned with the key's and org's destinations. Destinations are created and assigned by the proxy admin." />
|
||||
|
||||
<Form.Item label="Logging Settings" name="logging_settings">
|
||||
<EditLoggingSettings
|
||||
value={form.getFieldValue("logging_settings")}
|
||||
|
|
@ -1707,7 +1697,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={info.metadata?.logging || []}
|
||||
loggingExporters={loggingExportersOf(info)}
|
||||
scopedExporters={scopedExportersForTeam}
|
||||
disabledCallbacks={[]}
|
||||
variant="inline"
|
||||
|
|
|
|||
25
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
25
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -9067,7 +9067,6 @@ export interface paths {
|
|||
*
|
||||
* - organization_alias: *str* - The name of the organization.
|
||||
* - models: *List* - The models the organization has access to.
|
||||
* - logging_exporters: *Optional[List[str]]* - Names of admin-owned logging destinations (credential names) this organization exports its traces to.
|
||||
* - budget_id: *Optional[str]* - The id for a budget (tpm/rpm/max budget) for the organization.
|
||||
* ### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ###
|
||||
* - max_budget: *Optional[float]* - Max budget for org
|
||||
|
|
@ -13726,7 +13725,6 @@ export interface paths {
|
|||
* - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
|
||||
* - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
|
||||
* - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
|
||||
* - logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to.
|
||||
* - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
* - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
* - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
|
||||
|
|
@ -13904,7 +13902,6 @@ export interface paths {
|
|||
* - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
|
||||
* - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
|
||||
* - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
|
||||
* - logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to.
|
||||
* - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
* - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
* - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
|
||||
|
|
@ -25104,8 +25101,6 @@ export interface components {
|
|||
/** Litellm Changed By */
|
||||
litellm_changed_by?: string | null;
|
||||
litellm_model_table?: components["schemas"]["LiteLLM_ModelTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -25738,8 +25733,6 @@ export interface components {
|
|||
/** Created By */
|
||||
created_by: string;
|
||||
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/**
|
||||
* Members
|
||||
* @default []
|
||||
|
|
@ -26293,8 +26286,6 @@ export interface components {
|
|||
/** Default Team Member Models */
|
||||
default_team_member_models?: string[] | null;
|
||||
litellm_model_table?: components["schemas"]["LiteLLM_ModelTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -28080,8 +28071,6 @@ export interface components {
|
|||
budget_duration?: string | null;
|
||||
/** Budget Id */
|
||||
budget_id?: string | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -28131,8 +28120,6 @@ export interface components {
|
|||
/** Created By */
|
||||
created_by: string;
|
||||
litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Metadata */
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -28335,8 +28322,6 @@ export interface components {
|
|||
} | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Mcp Rpm Limit */
|
||||
|
|
@ -28841,8 +28826,6 @@ export interface components {
|
|||
OrganizationUpdateRequestV2: {
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -29091,8 +29074,6 @@ export interface components {
|
|||
} | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Mcp Rpm Limit */
|
||||
|
|
@ -31500,8 +31481,6 @@ export interface components {
|
|||
/** Default Team Member Models */
|
||||
default_team_member_models?: string[] | null;
|
||||
litellm_model_table?: components["schemas"]["LiteLLM_ModelTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -31617,8 +31596,6 @@ export interface components {
|
|||
*/
|
||||
keys_count: number;
|
||||
litellm_model_table?: components["schemas"]["LiteLLM_ModelTable"] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -32851,8 +32828,6 @@ export interface components {
|
|||
} | null;
|
||||
/** Guardrails */
|
||||
guardrails?: string[] | null;
|
||||
/** Logging Exporters */
|
||||
logging_exporters?: string[] | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Mcp Rpm Limit */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue