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:
Yucheng Zhu 2026-07-28 20:52:30 -07:00
parent a0622a2aab
commit 4ff358fcb8
41 changed files with 170 additions and 1410 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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