diff --git a/OTEL_V2_REWORK_DESIGN_NOTE.md b/OTEL_V2_REWORK_DESIGN_NOTE.md index 0c4f3ddb620..627959caa10 100644 --- a/OTEL_V2_REWORK_DESIGN_NOTE.md +++ b/OTEL_V2_REWORK_DESIGN_NOTE.md @@ -93,6 +93,21 @@ FORK C - Y7 fan_out/routing: **C2 chosen**. Merge `TenantFanOutSpanProcessor` in - Y6 full carrier removal (request-data -> ContextVar for the gen-AI span): NOT done blind. Evidence against a naive removal: the gen-AI logger binds the tenant tracer from `standard_callback_dynamic_params.otel_destinations` (`logger.py:237`, and the deferred close path `:344/:364`). The tracer is bound at `log_pre_api_call` (request task, ContextVar present) for the mainline, but `streaming_handler.py:1668` closes via `asyncio.run(...)`, a fresh event-loop context where the server-only ContextVar may not survive, whereas the kwargs-borne `dynamic_params` always does. So moving the gen-AI path to the ContextVar risks a streaming tenant-routing regression that cannot be validated without a live streaming proxy. Recommendation: keep `dynamic_params` as the robust carrier, rely on the now-hardened wipe for the trust boundary (Y3 proven), and treat ContextVar unification as a follow-up gated on live streaming validation. Open for the maintainer's call. +## 7b. A2 progress (as built, uncommitted WIP) + +Environment self-unblocked: installed + generated prisma-client-py 0.11.0 (was an ungenerated namespace stub), stood up a throwaway Postgres (docker `litellm-pg`, port 5599), `prisma db push` applied the schema. + +Done and DB-validated: +- `logging_exporters String[] @default([])` added to `LiteLLM_TeamTable`, `LiteLLM_OrganizationTable`, `LiteLLM_VerificationToken`, and the `Deleted*` mirrors, in all three synced `schema.prisma` copies. Round-trip proven against real Postgres: `team.logging_exporters` and `key.logging_exporters` persist and read back through the generated client. +- Pydantic mirrors carry the field (`litellm/models/team.py`, `organization.py`, `verification_token.py`); it propagates to `LiteLLM_VerificationTokenView` / `UserAPIKeyAuth`. `get_key_object`/`get_org_object`/`get_team_object` hydrate via `**model_dump()`, so the column flows in automatically. +- Resolver read path (`_union_logging_exporter_names`) migrated from `metadata.logging_exporters` to the `.logging_exporters` column, reading each level from its own object (key/team/org), DB-required. + +Remaining for a complete, consistent A2 (this is why the tree currently has 5 red resolver tests, since a half-migration is intentionally not committed): +- Write path across 6 endpoint sites (key generate/update/regenerate, team new/update, org new/update), each with bespoke persistence (team `/new` auto-persists via `data.json()` -> mirror; keys map fields explicitly). Recommended low-churn approach: add `logging_exporters: Optional[list[str]]` to the request models, feed the existing `validate_logging_exporter_assignment` a synthesized `{"logging_exporters": data.logging_exporters}` dict (leaving the validator and its 38 tests unchanged), and persist the field to the column. +- Update the 5 resolver tests to the column model (mock `get_team_object`; set `prisma_client`). +- Add the proxy-extras `migration.sql` (`ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]` on the four tables, per the `policies` precedent). +- UI: send `logging_exporters` as a top-level field, read from the column. + ## 8. Test plan (maps to the 6 required areas) 1. Visibility: proxy-admin sees per policy; team/org-admin cannot see out-of-scope destinations from `GET /credentials`; global/scoped/auto_enable follow policy. diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql new file mode 100644 index 00000000000..bed03a36455 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql @@ -0,0 +1,14 @@ +-- 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_VerificationToken" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" 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[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index e21c0016491..7739279df64 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -87,6 +87,7 @@ 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? @@ -142,6 +143,7 @@ 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 @@ -210,6 +212,7 @@ 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) @@ -415,6 +418,7 @@ model LiteLLM_VerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names) model_spend Json @default("{}") model_max_budget Json @default("{}") budget_id String? @@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) model_spend Json @default("{}") model_max_budget Json @default("{}") router_settings Json? @default("{}") diff --git a/litellm/models/organization.py b/litellm/models/organization.py index 8b2d95c3e09..3a78e03d5e8 100644 --- a/litellm/models/organization.py +++ b/litellm/models/organization.py @@ -22,6 +22,7 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase): spend: float = 0.0 metadata: Optional[dict] = None models: List[str] = [] + logging_exporters: Optional[List[str]] = None model_spend: Optional[dict] = {} created_by: str updated_by: str diff --git a/litellm/models/team.py b/litellm/models/team.py index f11c21a078e..e735082db26 100644 --- a/litellm/models/team.py +++ b/litellm/models/team.py @@ -92,6 +92,7 @@ class LiteLLM_TeamTable(TeamBase): model_spend: Optional[dict] = {} model_max_budget: Optional[dict] = {} policies: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None allow_team_guardrail_config: Optional[bool] = False litellm_model_table: Optional[LiteLLM_ModelTable] = None object_permission: Optional[LiteLLM_ObjectPermissionTable] = None diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index 8bddd1c1619..457f71ccecd 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -52,6 +52,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): object_permission_id: Optional[str] = None object_permission: Optional[LiteLLM_ObjectPermissionTable] = None access_group_ids: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None rotation_count: Optional[int] = 0 auto_rotate: Optional[bool] = False rotation_interval: Optional[str] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b2250c9fbc7..46ef1641c52 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1048,6 +1048,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase): mcp_rpm_limit: Optional[Dict[str, int]] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None prompts: Optional[List[str]] = None blocked: Optional[bool] = None aliases: Optional[dict] = {} @@ -1705,6 +1706,7 @@ class NewTeamRequest(TeamBase): tags: Optional[list] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None prompts: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None allowed_passthrough_routes: Optional[list] = None @@ -1771,6 +1773,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): model_aliases: Optional[dict] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None disable_global_guardrails: Optional[bool] = None team_member_budget: Optional[float] = None @@ -1914,6 +1917,7 @@ class NewOrganizationRequest(LiteLLM_BudgetTable): models: List = [] budget_id: Optional[str] = None metadata: Optional[dict] = None + logging_exporters: Optional[List[str]] = None model_rpm_limit: Optional[Dict[str, int]] = None model_tpm_limit: Optional[Dict[str, int]] = None @@ -2598,6 +2602,7 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable): spend: Optional[float] = None metadata: Optional[dict] = None models: Optional[List[str]] = None + logging_exporters: Optional[List[str]] = None updated_by: Optional[str] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None model_tpm_limit: Optional[Dict[str, int]] = None diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 50eb52e8b8d..f0e42ef3b81 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -544,57 +544,73 @@ async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> Optional[str]: async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: Optional[str]) -> set: """The union of admin-assigned exporter names across the request's identity chain. - Resolves each level from its OWN record: the key's ``metadata`` is shadowed by the - team's on the auth object, so it is fetched fresh via ``get_key_object``; the org's - metadata is fetched via ``get_org_object`` using the effective ``org_id`` (token org - or team fallback); the team's is already its own on ``team_metadata``. Internal-user - is intentionally not a routing dimension. The lists are admin-owned; the request - never supplies them. Degrades to team-only when no DB is connected (SDK mode). + Each level is read from its own ``logging_exporters`` column: the key via + ``get_key_object`` (the auth object's fields are the team's shadow, so it is + fetched fresh), the team via ``get_team_object``, the org via ``get_org_object`` + on the effective ``org_id`` (token org or team fallback). 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_key_object, get_org_object - - names: set = set() - - def _add(metadata: Any) -> None: - if not isinstance(metadata, dict): - return - assigned = metadata.get("logging_exporters") - if isinstance(assigned, list): - names.update(str(name) for name in assigned) + from litellm.proxy.auth.auth_checks import ( + get_key_object, + get_org_object, + get_team_object, + ) prisma_client = proxy_server.prisma_client + if prisma_client is None: + return set() cache = proxy_server.user_api_key_cache span = getattr(user_api_key_dict, "parent_otel_span", None) + names: set = set() - # KEY: the key's own metadata (the auth object's .metadata is the team's shadow). - if user_api_key_dict.token and prisma_client is not None: + def _add(obj: object) -> None: + assigned = getattr(obj, "logging_exporters", None) + if isinstance(assigned, (list, tuple)): + names.update(str(name) for name in assigned) + + if user_api_key_dict.token: try: - key_obj = await get_key_object( - hashed_token=user_api_key_dict.token, - prisma_client=prisma_client, - user_api_key_cache=cache, - parent_otel_span=span, - proxy_logging_obj=proxy_server.proxy_logging_obj, + _add( + await get_key_object( + hashed_token=user_api_key_dict.token, + prisma_client=prisma_client, + user_api_key_cache=cache, + parent_otel_span=span, + proxy_logging_obj=proxy_server.proxy_logging_obj, + ) ) - _add(key_obj.metadata) except Exception: # noqa: BLE001 pass - # TEAM: team_metadata is already the team's own. - _add(user_api_key_dict.team_metadata) - - # ORG: the org's own metadata (central catch-all). - if org_id and prisma_client is not None: + if user_api_key_dict.team_id: try: - org_obj = await 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, + _add( + await 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, + ) + ) + except Exception: # noqa: BLE001 + pass + + if org_id: + try: + _add( + await 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, + ) ) - _add(getattr(org_obj, "metadata", None)) except Exception: # noqa: BLE001 pass diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 4b84b3493e1..56fac2158bb 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1405,8 +1405,7 @@ async def generate_key_fn( _is_user_team_admin, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - LOGGING_EXPORTERS_KEY, - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) from litellm.proxy.proxy_server import ( prisma_client, @@ -1495,14 +1494,13 @@ async def generate_key_fn( route=KeyManagementRoutes.KEY_GENERATE, ) - # Team-admin of the key's team or org-admin of that team's org may - # write metadata.logging_exporters on team-owned keys. Personal keys - # (no team_table) stay proxy-admin only. Skip the role lookup when - # the field isn't in the payload to keep /key/generate cheap for the - # common case. - if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata: - validate_logging_exporter_assignment( - data.metadata, + # Team-admin of the key's team or org-admin of that team's org may assign + # logging_exporters on team-owned keys. Personal keys (no team_table) stay + # proxy-admin only. Skip the role lookup when the field isn't in the payload + # to keep /key/generate cheap for the common case. + if data.logging_exporters is not None: + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_team_admin=( team_table is not None @@ -1700,13 +1698,12 @@ async def generate_service_account_key_fn( _is_user_team_admin, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - LOGGING_EXPORTERS_KEY, - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) - if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata: - validate_logging_exporter_assignment( - data.metadata, + if data.logging_exporters is not None: + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_team_admin=( team_table is not None and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table) @@ -2457,7 +2454,7 @@ async def update_key_fn( # noqa: C901 _is_user_team_admin, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) from litellm.proxy.proxy_server import ( llm_router, @@ -2485,14 +2482,11 @@ async def update_key_fn( # noqa: C901 prisma_client=prisma_client, ) - # logging-exporters validation runs once the key's team is known so - # a team-admin or org-admin of that team can attach destinations. - # The validator no-ops when the effective value doesn't change; pass - # the stored metadata so removal-via-omission gates too (Veria F4). - if isinstance(data.metadata, dict): - _existing_key_metadata = ( - existing_key_row.metadata if isinstance(getattr(existing_key_row, "metadata", None), dict) else None - ) + # logging-exporters validation runs once the key's team is known so a + # team-admin or org-admin of that team can attach destinations. The + # validator no-ops when the effective value doesn't change; pass the + # stored column value so a non-admin cannot clear an admin-assigned one. + if data.logging_exporters is not None: _key_team_id = getattr(existing_key_row, "team_id", None) _key_team = None if _key_team_id is not None: @@ -2506,8 +2500,8 @@ async def update_key_fn( # noqa: C901 ) except HTTPException: _key_team = None - validate_logging_exporter_assignment( - data.metadata, + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_team_admin=( _key_team is not None @@ -2517,7 +2511,7 @@ async def update_key_fn( # noqa: C901 _key_team is not None and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=_key_team) ), - existing_metadata=_existing_key_metadata, + existing_exporters=getattr(existing_key_row, "logging_exporters", None), scope_team_id=getattr(_key_team, "team_id", None), scope_org_id=getattr(_key_team, "organization_id", None), ) @@ -3507,6 +3501,7 @@ async def generate_key_helper_fn( rotation_interval: Optional[str] = None, router_settings: Optional[dict] = None, access_group_ids: Optional[list] = None, + logging_exporters: Optional[list] = None, # admin-owned OTEL destinations (credential names) budget_limits: Optional[list] = None, # multiple concurrent budget windows ): from litellm.proxy.proxy_server import premium_user, prisma_client @@ -3638,6 +3633,7 @@ async def generate_key_helper_fn( "object_permission_id": object_permission_id, "router_settings": router_settings_json, "access_group_ids": access_group_ids or [], + "logging_exporters": logging_exporters or [], } # Add rotation fields if auto_rotate is enabled @@ -4580,26 +4576,22 @@ async def regenerate_key_fn( # noqa: C901 ) # logging_exporters gate on regenerate matches /key/generate and - # /key/update. Without this, a key owner could set - # metadata.logging_exporters on /key/{id}/regenerate and route - # future traces to a destination they aren't allowed to assign - # (Veria F3). The validator no-ops when the effective value - # doesn't change; pass stored metadata so removal-via-omission - # gates too (Veria F4). - if data is not None and isinstance(data.metadata, dict): + # /key/update. Without this, a key owner could set logging_exporters on + # /key/{id}/regenerate and route future traces to a destination they + # aren't allowed to assign (Veria F3). The validator no-ops when the + # effective value doesn't change; pass the stored column value so a + # non-admin cannot clear an admin-assigned one. + if data is not None and data.logging_exporters is not None: from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, _is_user_team_admin, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) - _regen_existing_metadata = ( - _key_in_db.metadata if isinstance(getattr(_key_in_db, "metadata", None), dict) else None - ) - validate_logging_exporter_assignment( - data.metadata, + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_team_admin=( regenerate_team_table is not None @@ -4615,7 +4607,7 @@ async def regenerate_key_fn( # noqa: C901 team_obj=regenerate_team_table, ) ), - existing_metadata=_regen_existing_metadata, + existing_exporters=getattr(_key_in_db, "logging_exporters", None), scope_team_id=getattr(regenerate_team_table, "team_id", None), scope_org_id=getattr(regenerate_team_table, "organization_id", None), ) diff --git a/litellm/proxy/management_endpoints/logging_exporter_validation.py b/litellm/proxy/management_endpoints/logging_exporter_validation.py index fbd7c252392..7776d1e4619 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_validation.py +++ b/litellm/proxy/management_endpoints/logging_exporter_validation.py @@ -172,6 +172,38 @@ def _exporter_value_changes( return requested_metadata.get(LOGGING_EXPORTERS_KEY) != existing +def validate_logging_exporter_field( + requested_exporters: Optional[list], + user_api_key_dict: UserAPIKeyAuth, + *, + caller_is_team_admin: bool = False, + caller_is_org_admin: bool = False, + existing_exporters: Optional[list] = None, + scope_team_id: Optional[str] = None, + scope_org_id: Optional[str] = None, +) -> None: + """Authorize a typed ``logging_exporters`` write (the column-backed field). + + 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, + caller_is_team_admin=caller_is_team_admin, + caller_is_org_admin=caller_is_org_admin, + existing_metadata=existing_metadata, + scope_team_id=scope_team_id, + scope_org_id=scope_org_id, + ) + + def validate_logging_exporter_assignment( metadata: Optional[dict], user_api_key_dict: UserAPIKeyAuth, diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 178c518c8b2..22e675fcdfa 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -199,10 +199,10 @@ async def new_organization( ``` """ from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) - validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict) + validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict) from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, @@ -459,10 +459,10 @@ async def update_organization( data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) - validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict) + 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): diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 463d83c9688..bf94fb6d753 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -992,8 +992,7 @@ async def new_team( _is_user_org_admin_for_org_id, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - LOGGING_EXPORTERS_KEY, - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) from litellm.proxy.management_helpers.audit_logs import ( get_audit_log_changed_by, @@ -1010,9 +1009,9 @@ async def new_team( # the destination org may assign logging exporters at creation time. # Skip the org-admin lookup entirely when the field isn't being # written, to avoid hitting the cache for unrelated /team/new calls. - if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata: - validate_logging_exporter_assignment( - data.metadata, + if data.logging_exporters is not None: + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_org_admin=await _is_user_org_admin_for_org_id( user_api_key_dict=user_api_key_dict, @@ -1663,7 +1662,7 @@ async def update_team( _is_user_org_admin_for_team, ) from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_assignment, + validate_logging_exporter_field, ) from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, @@ -1725,18 +1724,15 @@ async def update_team( # this path. Pass only the org-admin flag so the validator can't # silently grant team-admins if the route gate is ever widened. The # validator no-ops when the effective value doesn't change; pass the - # stored metadata so removal-via-omission gates too (Veria F4). - if isinstance(data.metadata, dict): - existing_team_metadata = ( - existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else None - ) - validate_logging_exporter_assignment( - data.metadata, + # stored column value so a non-admin cannot clear an admin-assigned one. + if data.logging_exporters is not None: + validate_logging_exporter_field( + data.logging_exporters, user_api_key_dict, caller_is_org_admin=await _is_user_org_admin_for_team( user_api_key_dict=user_api_key_dict, team_obj=team_for_auth ), - existing_metadata=existing_team_metadata, + existing_exporters=getattr(existing_team_row, "logging_exporters", None), scope_team_id=getattr(team_for_auth, "team_id", None), scope_org_id=getattr(team_for_auth, "organization_id", None), ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index e21c0016491..7739279df64 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -87,6 +87,7 @@ 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? @@ -142,6 +143,7 @@ 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 @@ -210,6 +212,7 @@ 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) @@ -415,6 +418,7 @@ model LiteLLM_VerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names) model_spend Json @default("{}") model_max_budget Json @default("{}") budget_id String? @@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) model_spend Json @default("{}") model_max_budget Json @default("{}") router_settings Json? @default("{}") diff --git a/schema.prisma b/schema.prisma index e21c0016491..7739279df64 100644 --- a/schema.prisma +++ b/schema.prisma @@ -87,6 +87,7 @@ 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? @@ -142,6 +143,7 @@ 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 @@ -210,6 +212,7 @@ 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) @@ -415,6 +418,7 @@ model LiteLLM_VerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names) model_spend Json @default("{}") model_max_budget Json @default("{}") budget_id String? @@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken { allowed_routes String[] @default([]) policies String[] @default([]) access_group_ids String[] @default([]) + logging_exporters String[] @default([]) model_spend Json @default("{}") model_max_budget Json @default("{}") router_settings Json? @default("{}") diff --git a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py index de6584474e3..6609fb6703a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py +++ b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py @@ -22,6 +22,7 @@ from litellm.proxy.management_endpoints.logging_exporter_validation import ( is_admin_gated_credential_info, validate_credential_access, validate_logging_exporter_assignment, + validate_logging_exporter_field, ) @@ -393,3 +394,60 @@ 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_without_flags_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_scoped_rejection_for_out_of_scope_team(_registry): + """arize-ds is granted to ds-team; a team admin writing in another team's scope + cannot name it, exactly as the metadata path gated it.""" + with pytest.raises(HTTPException) as exc: + validate_logging_exporter_field( + ["arize-ds"], + _non_admin(), + caller_is_team_admin=True, + scope_team_id="platform-team", + ) + assert exc.value.status_code == 403 + + +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"], + ) + + +def test_field_admin_can_assign_out_of_scope(_registry): + """Proxy admin skips the scope check (parity with the metadata path).""" + validate_logging_exporter_field(["arize-ds"], _admin(), scope_team_id="platform-team") diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 5a907d213d0..ac9dd27f190 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4845,24 +4845,46 @@ def _seeded_logging_credentials(): litellm.credential_list = original -def _auth(team_exporters=None, token=None, org_id=None, team_id=None): - return UserAPIKeyAuth( - api_key="hashed-key", - token=token, - org_id=org_id, - team_id=team_id, - team_metadata=({"logging_exporters": team_exporters} if team_exporters else {}), +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. + + 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. + """ + from types import SimpleNamespace + + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy.auth import auth_checks + + 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))) ) @pytest.mark.asyncio -async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials): - # team_metadata is the team's own (not shadowed); resolves without a DB fetch. +async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, monkeypatch): + # team assignment lives on the team's logging_exporters column. from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - destinations, backends = await _resolve_logging_exporters( - _auth(team_exporters=["langfuse-eu"]) - ) + _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" } @@ -4873,35 +4895,13 @@ async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials) async def test_resolve_logging_exporters_unions_key_team_org( _seeded_logging_credentials, monkeypatch ): - # key + org are read from their OWN records (the key's .metadata is team-shadowed), - # team from team_metadata. All three union, deduped. - from types import SimpleNamespace - - import litellm.proxy.proxy_server as proxy_server - from litellm.proxy.auth import auth_checks + # key, team, and org are each read from their OWN logging_exporters column. + # All three union, deduped. from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) - monkeypatch.setattr( - auth_checks, - "get_key_object", - AsyncMock( - return_value=SimpleNamespace(metadata={"logging_exporters": ["arize-prod"]}) - ), - ) - monkeypatch.setattr( - auth_checks, - "get_org_object", - AsyncMock( - return_value=SimpleNamespace( - metadata={"logging_exporters": ["langfuse-eu"]} - ) - ), - ) + _patch_identity(monkeypatch, key=["arize-prod"], team=["langfuse-eu"], org=["langfuse-eu"]) - destinations, backends = await _resolve_logging_exporters( - _auth(team_exporters=["langfuse-eu"], token="hashed-key", org_id="org-1") - ) + 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) @@ -4912,13 +4912,12 @@ async def test_resolve_logging_exporters_unions_key_team_org( @pytest.mark.asyncio async def test_resolve_logging_exporters_carries_arize_project( - _seeded_logging_credentials, + _seeded_logging_credentials, monkeypatch ): from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - destinations, _ = await _resolve_logging_exporters( - _auth(team_exporters=["arize-prod"]) - ) + _patch_identity(monkeypatch, team=["arize-prod"]) + destinations, _ = await _resolve_logging_exporters(_auth()) assert destinations == [ { @@ -4945,20 +4944,19 @@ async def test_resolve_logging_exporters_empty_without_assignment( @pytest.mark.asyncio async def test_resolve_logging_exporters_skips_unknown_and_provider_creds( - _seeded_logging_credentials, + _seeded_logging_credentials, monkeypatch ): from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters # unknown name + a provider credential (not credential_type=logging) -> nothing - destinations, backends = await _resolve_logging_exporters( - _auth(team_exporters=["does-not-exist", "openai-key"]) - ) + _patch_identity(monkeypatch, team=["does-not-exist", "openai-key"]) + destinations, backends = await _resolve_logging_exporters(_auth()) assert destinations == [] and backends == [] @pytest.mark.asyncio async def test_apply_admin_logging_exporters_stamps_and_activates( - _seeded_logging_credentials, + _seeded_logging_credentials, monkeypatch ): from litellm.integrations.otel.plumbing.context import ( _request_destinations, @@ -4966,12 +4964,11 @@ 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"]) token = _request_destinations.set(()) data: dict = {} try: - await _apply_admin_logging_exporters( - data, _auth(team_exporters=["langfuse-eu"]) - ) + await _apply_admin_logging_exporters(data, _auth()) # destinations live under litellm_metadata so the body does not leak an # unknown top-level key to the provider; the top-level key stays absent @@ -5074,7 +5071,7 @@ async def test_client_cannot_control_otel_destinations(_seeded_logging_credentia @pytest.mark.asyncio async def test_apply_admin_logging_exporters_registers_on_failure( - _seeded_logging_credentials, + _seeded_logging_credentials, monkeypatch ): """An admin-owned destination must capture a FAILED upstream call, not only a successful one. @@ -5088,14 +5085,13 @@ 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"]) 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(team_exporters=["langfuse-eu", "arize-prod"]) - ) + await _apply_admin_logging_exporters(data, _auth()) for callback_list in ("success_callback", "failure_callback"): registered = data[callback_list] assert "langfuse_otel" in registered @@ -5176,31 +5172,29 @@ async def test_resolve_grant_does_not_auto_enable( @pytest.mark.asyncio async def test_resolve_name_with_grant_enables( - _seeded_logging_credentials_with_access, + _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 - destinations, _ = await _resolve_logging_exporters( - _auth(team_id="team-eu", team_exporters=["langfuse-eu"]) - ) + _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, + _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 - destinations, _ = await _resolve_logging_exporters( - _auth(team_id="team-other", team_exporters=["langfuse-eu"]) - ) + _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} @@ -5248,11 +5242,7 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch) 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 types import SimpleNamespace - - import litellm.proxy.proxy_server as proxy_server from litellm.models.credentials import CredentialItem - from litellm.proxy.auth import auth_checks from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters original = litellm.credential_list @@ -5267,26 +5257,11 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch) }, ), ] - 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(metadata={})) - ) - monkeypatch.setattr( - auth_checks, - "get_org_object", - AsyncMock(return_value=SimpleNamespace(metadata={})), - ) - # the key's token has no org_id; the team it belongs to is in org-7. - monkeypatch.setattr( - auth_checks, - "get_team_object", - AsyncMock(return_value=SimpleNamespace(organization_id="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", team_exporters=["arize-org"]) - ) + 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