diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index c92491dd42b..df768b98326 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -13,20 +13,10 @@ from opentelemetry.trace import Span, Tracer, get_current_span, use_span import litellm from litellm._logging import verbose_logger from litellm.integrations.custom_logger import CustomLogger -from litellm.integrations.otel.model.baggage import promoted_baggage -from litellm.integrations.otel.model.config import OpenTelemetryV2Config -from litellm.integrations.otel.plumbing.context import ( - is_recordable_span, - mcp_message_transport_span, - request_root_span, - resolve_mcp_span_context, - resolve_parent_context, - resolve_request_span_context, - set_request_baggage, - set_request_root_span, -) from litellm.integrations.otel.emitter import SpanEmitter, stamp_error from litellm.integrations.otel.mappers import resolve_mappers +from litellm.integrations.otel.model.baggage import promoted_baggage +from litellm.integrations.otel.model.config import OpenTelemetryV2Config from litellm.integrations.otel.model.metadata import ( LLMCallEvent, RequestIdentity, @@ -42,6 +32,18 @@ from litellm.integrations.otel.model.payloads import ( is_mcp_list_tools, is_mcp_tool_call, ) +from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service +from litellm.integrations.otel.model.utils import to_ns +from litellm.integrations.otel.plumbing.context import ( + is_recordable_span, + mcp_message_transport_span, + request_root_span, + resolve_mcp_span_context, + resolve_parent_context, + resolve_request_span_context, + set_request_baggage, + set_request_root_span, +) from litellm.integrations.otel.plumbing.events import GenAIEventRecorder from litellm.integrations.otel.plumbing.metrics import ( GenAIMetricRecorder, @@ -56,8 +58,6 @@ from litellm.integrations.otel.plumbing.providers import ( resolve_meter_provider, ) from litellm.integrations.otel.plumbing.routing import TenantTracerCache -from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service -from litellm.integrations.otel.model.utils import to_ns if TYPE_CHECKING: from litellm.integrations.otel.model.destination import OtelDestination @@ -165,12 +165,12 @@ class OpenTelemetryV2(CustomLogger): event_recorder=self._init_events(logger_provider), ) self._tenant_tracers = TenantTracerCache(self.config, callback_name, LITELLM_TRACER_NAME) - self._open_llm_calls: "OrderedDict[str, _LLMCallSpan]" = OrderedDict() + self._open_llm_calls: OrderedDict[str, _LLMCallSpan] = OrderedDict() # call_ids for which the LLM-call span has already been emitted; lets # _close_llm_call no-op on duplicate callbacks (success + failure both # firing, or success firing twice) instead of double-exporting the # deferred-emit span. Bounded LRU, same size as _open_llm_calls. - self._closed_call_ids: "OrderedDict[str, None]" = OrderedDict() + self._closed_call_ids: OrderedDict[str, None] = OrderedDict() # mutable-ok: bounded LRU of emitted call ids self._init_otel_logger_on_litellm_proxy() def _init_metrics(self, meter_provider: Any | None) -> "GenAIMetricRecorder | None": diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py index abdc548fbce..51eff030807 100644 --- a/litellm/integrations/otel/model/destination.py +++ b/litellm/integrations/otel/model/destination.py @@ -7,6 +7,8 @@ request's identity chain, and the v2 logger exports through it. Every OTEL backe per-backend field mapping lives in ``litellm.integrations.otel.presets.destinations``. """ +from collections.abc import Mapping + from pydantic import BaseModel, ConfigDict, Field @@ -14,8 +16,8 @@ class OtelDestination(BaseModel): model_config = ConfigDict(frozen=True) endpoint: str - headers: dict[str, str] = Field(default_factory=dict) - resource_attributes: dict[str, str] = Field(default_factory=dict) + headers: Mapping[str, str] = Field(default_factory=dict) + resource_attributes: Mapping[str, str] = Field(default_factory=dict) # The OTEL backend (callback_name) this destination belongs to, so a request # that fans out across backends routes each destination to the logger that # owns its attribute vocabulary. None for the legacy single-destination path. diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index f6733527eb6..f8917d5ab71 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -1,5 +1,6 @@ """Provider / exporter factory + the Baggage span processor.""" +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Callable, Iterable from opentelemetry import _logs, baggage, metrics @@ -136,7 +137,7 @@ def default_otlp_kind_for_backend(callback_name: "str | None") -> str: return "otlp_grpc" if callback_name in _GRPC_BACKENDS else "otlp_http" -def destination_resource_attrs(destination: "OtelDestination") -> dict[str, str]: +def destination_resource_attrs(destination: "OtelDestination") -> Mapping[str, str]: """The backend-required Resource attributes a destination carries on every span. Backend-agnostic: each backend's destination builder (``presets.destinations``) diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index e36d5f4b10a..15768523cc5 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -62,7 +62,9 @@ class TenantTracerCache: self._config = config self._callback_name = callback_name self._tracer_name = tracer_name - self._providers: OrderedDict[tuple[object, ...], TracerProvider] = OrderedDict() + self._providers: OrderedDict[tuple[object, ...], TracerProvider] = ( + OrderedDict() + ) # mutable-ok: bounded LRU tracer-provider cache def _evict_if_full(self) -> None: """Drop the least-recently-used provider when over capacity, without a @@ -98,7 +100,7 @@ class TenantTracerCache: def _group_by_resource( self, destinations: "tuple[OtelDestination, ...]" - ) -> "list[tuple[tuple[tuple[str, str], ...], list[OtelDestination]]]": + ) -> "tuple[tuple[tuple[tuple[str, str], ...], tuple[OtelDestination, ...]], ...]": """Destinations grouped by their backend-required Resource attributes. The key is a stable sorted tuple of ``destination_resource_attrs`` items. @@ -110,16 +112,18 @@ class TenantTracerCache: destination_resource_attrs, ) - groups: OrderedDict[tuple[tuple[str, str], ...], list[OtelDestination]] = OrderedDict() + groups: OrderedDict[tuple[tuple[str, str], ...], list[OtelDestination]] = ( + OrderedDict() + ) # mutable-ok: insertion-order grouping accumulator, frozen before return for destination in destinations: key = tuple(sorted(destination_resource_attrs(destination).items())) groups.setdefault(key, []).append(destination) - return sorted(groups.items()) + return tuple((key, tuple(group)) for key, group in sorted(groups.items())) def _tracer_for_group( self, resource_key: "tuple[tuple[str, str], ...]", - group: "list[OtelDestination]", + group: "tuple[OtelDestination, ...]", *, include_base: bool, ) -> Tracer: @@ -310,7 +314,9 @@ class TenantFanOutSpanProcessor(SpanProcessor): def __init__(self, owner_callback_name: str | None) -> None: self._owner = owner_callback_name - self._processors: OrderedDict[tuple, SpanProcessor] = OrderedDict() + self._processors: OrderedDict[tuple, SpanProcessor] = ( + OrderedDict() + ) # mutable-ok: bounded LRU span-processor cache def on_start(self, span: Span, parent_context: Context | None = None) -> None: return None diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index 2b0ed6b1a5d..01a7c1b905d 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -27,7 +27,7 @@ from litellm.integrations.weave.weave_otel import _get_weave_authorization_heade LOGGING_CREDENTIAL_NAME_KEY = LITELLM_LOGGING_CREDENTIAL_NAME_KEY -def _parse_header_string(raw: str) -> dict[str, str]: +def _parse_header_string(raw: str) -> Mapping[str, str]: pairs = (item.split("=", 1) for item in raw.split(",") if "=" in item) return {key.strip(): value.strip() for key, value in pairs} @@ -103,7 +103,7 @@ def _generic_destination(values: Mapping[str, str]) -> OtelDestination | None: return OtelDestination(endpoint=endpoint, headers=_parse_header_string(values.get("otel_headers", ""))) -_ADAPTERS: dict[str, Callable[[Mapping[str, str]], OtelDestination | None]] = { +_ADAPTERS: Mapping[str, Callable[[Mapping[str, str]], OtelDestination | None]] = { "langfuse_otel": _langfuse_destination, "arize": _arize_destination, "weave_otel": _weave_destination, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 9e7222660e7..540697f8969 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -37,11 +37,6 @@ from litellm import ( turn_off_message_logging, ) from litellm._logging import _is_debugging_on, _redact_string, verbose_logger -from litellm.exceptions import ( - BudgetExceededError, - validate_rate_limit_category, - validate_rate_limit_type, -) from litellm._uuid import uuid from litellm.batches.batch_utils import _handle_completed_batch from litellm.caching.caching import DualCache, InMemoryCache @@ -56,6 +51,11 @@ from litellm.cost_calculator import ( RealtimeAPITokenUsageProcessor, _select_model_name_for_cost_calc, ) +from litellm.exceptions import ( + BudgetExceededError, + validate_rate_limit_category, + validate_rate_limit_type, +) from litellm.integrations.agentops import AgentOps from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook from litellm.integrations.arize.arize import ArizeLogger @@ -3418,6 +3418,7 @@ def _get_masked_values( "credentials", "password", "passwd", + "otel_headers", ] def _mask_value(v: Any) -> Any: diff --git a/litellm/models/credentials.py b/litellm/models/credentials.py index 5f1c69f35d3..add62e2c39d 100644 --- a/litellm/models/credentials.py +++ b/litellm/models/credentials.py @@ -5,6 +5,8 @@ These are the canonical credential types for the proxy. They live in the model layer; ``litellm.types.utils`` re-exports them for backwards compatibility. """ +from collections.abc import Mapping + from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -18,7 +20,7 @@ class CredentialItem(CredentialBase): class CreateCredentialItem(CredentialBase): - credential_values: dict | None = None + credential_values: Mapping[str, object] | None = None model_id: str | None = None @model_validator(mode="before") @@ -39,8 +41,8 @@ class UpdateCredentialItem(BaseModel): """ credential_name: str | None = None - credential_values: dict | None = None - credential_info: dict | None = None + credential_values: Mapping[str, object] | None = None + credential_info: Mapping[str, object] | None = None class CredentialAccess(BaseModel): diff --git a/litellm/models/organization.py b/litellm/models/organization.py index 0df2248350e..a38e330595f 100644 --- a/litellm/models/organization.py +++ b/litellm/models/organization.py @@ -5,6 +5,7 @@ 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 @@ -22,7 +23,7 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase): spend: float = 0.0 metadata: Optional[dict] = None models: List[str] = [] - logging_exporters: list[str] | None = None + logging_exporters: Sequence[str] | None = None model_spend: Optional[dict] = {} created_by: str updated_by: str diff --git a/litellm/models/team.py b/litellm/models/team.py index f0dcd1b68f4..644f27c9eb3 100644 --- a/litellm/models/team.py +++ b/litellm/models/team.py @@ -7,6 +7,7 @@ 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 @@ -92,7 +93,7 @@ class LiteLLM_TeamTable(TeamBase): model_spend: Optional[dict] = {} model_max_budget: Optional[dict] = {} policies: Optional[List[str]] = None - logging_exporters: list[str] | None = 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 diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index f15c9c03092..86e9da613f5 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -5,6 +5,7 @@ Canonical definition for ``litellm_verificationtoken``. Re-exported from ``litellm.proxy._types`` for backwards compatibility. """ +from collections.abc import Sequence from datetime import datetime from typing import Dict, List, Optional, Union @@ -54,7 +55,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: list[str] | None = None + logging_exporters: Sequence[str] | None = 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 e24e3267aff..25c5f2adf73 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1,6 +1,7 @@ import enum import json import os +from collections.abc import Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Union @@ -1083,7 +1084,7 @@ class AllowedVectorStoreIndexItem(LiteLLMPydanticObjectBase): class KeyRequestBase(GenerateRequestBase): key: Optional[str] = None budget_id: Optional[str] = None - logging_exporters: list[str] | None = None + logging_exporters: Sequence[str] | None = None tags: Optional[List[str]] = None disable_global_guardrails: Optional[bool] = None throttle_on_budget_exceeded: Optional[bool] = None @@ -1778,7 +1779,7 @@ class NewTeamRequest(TeamBase): tags: Optional[list] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None - logging_exporters: list[str] | None = 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 +1846,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): model_aliases: Optional[dict] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None - logging_exporters: list[str] | None = 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 +2001,7 @@ class NewOrganizationRequest(LiteLLM_BudgetTable): models: List = [] budget_id: Optional[str] = None metadata: Optional[dict] = None - logging_exporters: list[str] | None = 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 +2802,7 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable): spend: Optional[float] = None metadata: Optional[dict] = None models: Optional[List[str]] = None - logging_exporters: list[str] | None = 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 +2842,7 @@ class OrganizationUpdateRequestV2(LiteLLMPydanticObjectBase): max_parallel_requests: int | None = None model_max_budget: dict | None = None budget_duration: str | None = None - logging_exporters: list[str] | None = None + logging_exporters: Sequence[str] | None = None object_permission: LiteLLM_ObjectPermissionBase | None = None diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index dd27c8040fd..26605fddee6 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -2,6 +2,7 @@ CRUD endpoints for storing reusable credentials. """ +from collections.abc import Mapping from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response @@ -13,7 +14,6 @@ from litellm.litellm_core_utils.litellm_logging import _get_masked_values from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper - from litellm.proxy.management_endpoints.logging_exporter_validation import ( validate_credential_access, ) @@ -146,8 +146,8 @@ async def get_credentials( Proxy-admin only (a proxy-admin-viewer may read). Credentials, including admin-owned logging destinations, are managed exclusively by the proxy admin; - tenants never read them over the API. Values are masked for the admin and - fully redacted for the admin-viewer. + tenants never read them over the API. Secret values are masked for both + admin-tier readers, exactly as they were before this feature. """ try: is_proxy_admin = _is_proxy_admin(user_api_key_dict) @@ -160,11 +160,7 @@ async def get_credentials( masked_credentials = [ { "credential_name": credential.credential_name, - "credential_values": ( - _get_masked_values(credential.credential_values) - if is_proxy_admin - else dict.fromkeys(credential.credential_values, "********") - ), + "credential_values": _get_masked_values(credential.credential_values), "credential_info": credential.credential_info, } for credential in litellm.credential_list @@ -329,7 +325,10 @@ def update_db_credential( return merged_credential -def _merge_credential_info(into: dict, patch: dict) -> None: +def _merge_credential_info( + into: dict, # mutable-ok: the merge target, updated in place for the DB write and the cache sync + patch: Mapping[str, object], +) -> None: """Merge ``patch`` into ``into`` in place, with surgical access subfields. A top-level dict.update would let a patch like ``{access: {teams: [...]}}`` diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 8223096892e..91c15489f72 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -4,6 +4,7 @@ import json import re import time from collections import OrderedDict +from collections.abc import Awaitable, Sequence from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from fastapi import HTTPException, Request @@ -111,6 +112,7 @@ if TYPE_CHECKING: from litellm.models.credentials import CredentialItem from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig from litellm.types.proxy.policy_engine import PolicyMatchContext + from litellm.types.utils import OtelDestinationParams ProxyConfig = _ProxyConfig else: @@ -620,7 +622,7 @@ 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) -> set: +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 key via @@ -641,64 +643,67 @@ async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_i prisma_client = proxy_server.prisma_client if prisma_client is None: - return set() + return frozenset() cache = proxy_server.user_api_key_cache span = getattr(user_api_key_dict, "parent_otel_span", None) - names: set = set() - def _add(obj: object) -> None: + def _assigned(obj: object) -> tuple[str, ...]: assigned = getattr(obj, "logging_exporters", None) if isinstance(assigned, (list, tuple)): - names.update(str(name) for name in assigned) + return tuple(str(name) for name in assigned) + return () - if user_api_key_dict.token: + async def _level(lookup: "Awaitable[object]") -> tuple[str, ...]: try: - _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, - ) - ) + return _assigned(await lookup) except Exception: # noqa: BLE001 # best-effort identity enrichment; a failed lookup must not block the request - pass + return () - if user_api_key_dict.team_id: - try: - _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, - ) + key_names = ( + await _level( + 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, ) - except Exception: # noqa: BLE001 # best-effort identity enrichment; a failed lookup must not block the request - 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, - ) + ) + if user_api_key_dict.token + else () + ) + 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, ) - except Exception: # noqa: BLE001 # best-effort identity enrichment; a failed lookup must not block the request - pass - - return names + ) + 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((*key_names, *team_names, *org_names)) async def _resolve_logging_exporters( user_api_key_dict: UserAPIKeyAuth, -) -> "tuple[list, list]": +) -> "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 @@ -758,7 +763,7 @@ async def _resolve_logging_exporters( ) for backend, destination in built } - destinations = [ + destinations: tuple[OtelDestinationParams, ...] = tuple( { "callback_name": backend, "endpoint": destination.endpoint, @@ -766,8 +771,8 @@ async def _resolve_logging_exporters( "resource_attributes": destination.resource_attributes, } for backend, destination in deduped.values() - ] - backends = list(dict.fromkeys(backend for backend, _ in deduped.values())) + ) + backends = tuple(dict.fromkeys(backend for backend, _ in deduped.values())) return destinations, backends @@ -784,7 +789,7 @@ def _request_destination_from_raw(item: object) -> "OtelDestination | None": return None -def _set_request_otel_destinations(destinations: list) -> None: +def _set_request_otel_destinations(destinations: Sequence[object]) -> None: from litellm.integrations.otel.plumbing.context import set_request_destinations set_request_destinations( @@ -793,9 +798,9 @@ def _set_request_otel_destinations(destinations: list) -> None: async def _apply_admin_logging_exporters( - data: dict, + data: dict, # mutable-ok: registers the resolved backends on data's success/failure_callback in place user_api_key_dict: UserAPIKeyAuth, - cached_destinations: "list | None" = None, + cached_destinations: "Sequence[object] | None" = None, ) -> None: """Anchor the resolved fan-out destinations on the request context and activate their backends. @@ -811,8 +816,8 @@ async def _apply_admin_logging_exporters( resolver runs here. """ if cached_destinations is not None: - destinations = list(cached_destinations) - backends = list( + destinations = tuple(cached_destinations) + backends = tuple( dict.fromkeys( str(d["callback_name"]) for d in destinations if isinstance(d, dict) and d.get("callback_name") ) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d509d9647d4..7bd7cf0e174 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -18,7 +18,7 @@ import os import re import secrets import traceback -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, cast @@ -3621,7 +3621,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: list | None = None, # admin-owned OTEL destinations (credential names) + logging_exporters: Sequence[str] | None = 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 diff --git a/litellm/proxy/management_endpoints/logging_exporter_validation.py b/litellm/proxy/management_endpoints/logging_exporter_validation.py index 5ffc9d6643b..5b586dfbd8d 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_validation.py +++ b/litellm/proxy/management_endpoints/logging_exporter_validation.py @@ -7,6 +7,8 @@ the destination's own ``credential_info.access``; the resolver (``litellm_pre_call_utils``) evaluates that at request time. """ +from collections.abc import Mapping, Sequence + from fastapi import HTTPException, status import litellm @@ -15,7 +17,7 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth LOGGING_EXPORTERS_KEY = "logging_exporters" -def validate_credential_access(credential_info: dict | None) -> None: +def validate_credential_access(credential_info: Mapping[str, object] | None) -> None: """Validate ``credential_info.access`` shape when the write sets one. No-op when ``access`` is absent. Otherwise it must be an object whose ``global`` (if @@ -50,7 +52,7 @@ def validate_credential_access(credential_info: dict | None) -> None: ) -def _logging_credentials_by_name() -> dict[str, dict]: +def _logging_credentials_by_name() -> Mapping[str, Mapping[str, object]]: return { credential.credential_name: (credential.credential_info or {}) for credential in litellm.credential_list @@ -58,8 +60,8 @@ def _logging_credentials_by_name() -> dict[str, dict]: } -def _logging_credential_names() -> set[str]: - return set(_logging_credentials_by_name()) +def _logging_credential_names() -> frozenset[str]: + return frozenset(_logging_credentials_by_name()) def _validate_exporters_shape_and_names(exporters: object) -> None: @@ -84,8 +86,8 @@ def _validate_exporters_shape_and_names(exporters: object) -> None: def _exporter_value_changes( - requested_metadata: dict | None, - existing_metadata: dict | None, + requested_metadata: Mapping[str, object] | None, + existing_metadata: Mapping[str, object] | None, ) -> bool: """True if the effective ``metadata.logging_exporters`` value would change. @@ -111,14 +113,17 @@ def _exporter_value_changes( return True if not new_has and existing_has: return True - return requested_metadata.get(LOGGING_EXPORTERS_KEY) != existing + 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: list | None, + requested_exporters: Sequence[str] | None, user_api_key_dict: UserAPIKeyAuth, *, - existing_exporters: list | None = None, + existing_exporters: Sequence[str] | None = None, ) -> None: """Authorize a typed ``logging_exporters`` write (proxy-admin only). @@ -139,10 +144,10 @@ def validate_logging_exporter_field( def validate_logging_exporter_assignment( - metadata: dict | None, + metadata: Mapping[str, object] | None, user_api_key_dict: UserAPIKeyAuth, *, - existing_metadata: dict | None = None, + existing_metadata: Mapping[str, object] | None = None, ) -> None: """Validate a ``metadata.logging_exporters`` write on key / team / org endpoints. diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 808ca7c03a5..c09ccfb4c69 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -10,6 +10,7 @@ from typing import ( Literal, Mapping, Optional, + Sequence, Union, get_args, ) @@ -3035,7 +3036,7 @@ class OtelDestinationParams(TypedDict, total=False): callback_name: str endpoint: str - headers: Dict[str, str] + headers: Mapping[str, str] class StandardCallbackDynamicParams(TypedDict, total=False): @@ -3089,7 +3090,7 @@ class StandardCallbackDynamicParams(TypedDict, total=False): # assigned to the request's identity chain (key/team/user/org), fanned out to. # Never request-settable: absent from the request-read whitelist in # initialize_dynamic_callback_params, so a request body/metadata cannot set it. - otel_destinations: Optional[List[OtelDestinationParams]] + otel_destinations: Optional[Sequence[OtelDestinationParams]] class CustomPricingLiteLLMParams(BaseModel): @@ -3729,11 +3730,11 @@ class RawRequestTypedDict(TypedDict, total=False): error: Optional[str] -from litellm.models.credentials import CredentialBase as CredentialBase # noqa: E402 -from litellm.models.credentials import CredentialItem as CredentialItem # noqa: E402 from litellm.models.credentials import ( # noqa: E402 CreateCredentialItem as CreateCredentialItem, ) +from litellm.models.credentials import CredentialBase as CredentialBase # noqa: E402 +from litellm.models.credentials import CredentialItem as CredentialItem # noqa: E402 from litellm.models.credentials import ( # noqa: E402 # at file end to avoid a circular import with litellm.models.credentials UpdateCredentialItem as UpdateCredentialItem, ) diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 087bddc8c24..e5dd022143c 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -24,7 +24,7 @@ "limit": 130 }, "ANN401": { - "limit": 2014 + "limit": 2013 }, "ASYNC230": { "limit": 14 @@ -60,7 +60,7 @@ "limit": 4 }, "BLE001": { - "limit": 2901 + "limit": 2900 }, "C401": { "limit": 11 @@ -123,7 +123,7 @@ "limit": 52 }, "I001": { - "limit": 270 + "limit": 267 }, "LOG015": { "limit": 8 @@ -237,7 +237,7 @@ "limit": 41 }, "RUF022": { - "limit": 84 + "limit": 83 }, "RUF023": { "limit": 5 @@ -306,7 +306,7 @@ "limit": 9 }, "TID251": { - "limit": 2651 + "limit": 2650 }, "TRY002": { "limit": 548 @@ -354,15 +354,15 @@ "limit": 4 }, "UP035": { - "limit": 2231 + "limit": 2230 }, "UP036": { "limit": 4 }, "UP037": { - "limit": 105 + "limit": 103 }, "UP045": { - "limit": 17820 + "limit": 17816 } } diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index 5edc3d7316c..d89144dba26 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -423,14 +423,17 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch): generic = next( c for c in response["credentials"] if c["credential_name"] == "generic-otel" ) - assert generic["credential_values"]["otel_headers"] == raw_headers + # otel_headers carries the collector auth token, so the masker treats it as a + # secret key: readable prefix only, never the full value. + assert generic["credential_values"]["otel_headers"] != raw_headers + assert "collector-secret" not in str(response) @pytest.mark.asyncio -async def test_get_credentials_admin_viewer_gets_full_list_fully_masked(monkeypatch): +async def test_get_credentials_admin_viewer_reads_same_masked_list_as_admin(monkeypatch): """PROXY_ADMIN_VIEW_ONLY keeps read parity with PROXY_ADMIN on this endpoint: - the full credential list, including provider credentials, with every stored - value constant-masked so the read-only role receives no usable secret.""" + the identical credential list through the identical masker, with no raw + secret in either response.""" raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret" monkeypatch.setattr( litellm, @@ -438,7 +441,7 @@ async def test_get_credentials_admin_viewer_gets_full_list_fully_masked(monkeypa [ CredentialItem( credential_name="openai", - credential_values={"api_key": "sk-secret"}, + credential_values={"api_key": "sk-secret-value"}, credential_info={"custom_llm_provider": "openai"}, ), CredentialItem( @@ -451,21 +454,22 @@ async def test_get_credentials_admin_viewer_gets_full_list_fully_masked(monkeypa ), ], ) - response = await endpoints.get_credentials( + viewer_response = await endpoints.get_credentials( request=MagicMock(), fastapi_response=MagicMock(), user_api_key_dict=UserAPIKeyAuth( api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY ), ) - names = sorted(c["credential_name"] for c in response["credentials"]) - assert names == ["generic-otel", "openai"] - assert all( - value == "********" - for c in response["credentials"] - for value in c["credential_values"].values() + admin_response = await endpoints.get_credentials( + request=MagicMock(), + fastapi_response=MagicMock(), + user_api_key_dict=_admin(), ) - assert "collector-secret" not in str(response) - assert "sk-secret" not in str(response) + assert viewer_response == admin_response + names = sorted(c["credential_name"] for c in viewer_response["credentials"]) + assert names == ["generic-otel", "openai"] + assert "collector-secret" not in str(viewer_response) + assert "sk-secret-value" not in str(viewer_response) 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 1e516cba31d..367bda5801c 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -5228,7 +5228,7 @@ async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, assert {d["endpoint"] for d in destinations} == { "https://cloud.langfuse.com/api/public/otel" } - assert backends == ["langfuse_otel"] + assert backends == ("langfuse_otel",) @pytest.mark.asyncio @@ -5259,7 +5259,7 @@ async def test_resolve_logging_exporters_carries_arize_project( _patch_identity(monkeypatch, team=["arize-prod"]) destinations, _ = await _resolve_logging_exporters(_auth()) - assert destinations == [ + assert destinations == ( { "callback_name": "arize", "endpoint": "https://otlp.arize.com/v1", @@ -5268,8 +5268,8 @@ async def test_resolve_logging_exporters_carries_arize_project( "model_id": "tenant-arize", "arize.project.name": "tenant-arize", }, - } - ] + }, + ) @pytest.mark.asyncio @@ -5279,7 +5279,7 @@ async def test_resolve_logging_exporters_empty_without_assignment( from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters destinations, backends = await _resolve_logging_exporters(_auth()) - assert destinations == [] and backends == [] + assert destinations == () and backends == () @pytest.mark.asyncio @@ -5291,7 +5291,7 @@ async def test_resolve_logging_exporters_skips_unknown_and_provider_creds( # 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 == [] + assert destinations == () and backends == () @pytest.mark.asyncio @@ -5560,7 +5560,7 @@ async def test_resolve_auto_enable_empty_access_is_deny_all(monkeypatch): try: for auth in (_auth(team_id="team-x"), _auth(org_id="org-y"), _auth()): destinations, _ = await _resolve_logging_exporters(auth) - assert destinations == [] + assert destinations == () finally: litellm.credential_list = original @@ -5597,7 +5597,7 @@ async def test_resolve_logging_exporters_access_default_deny( destinations, backends = await _resolve_logging_exporters( _auth(team_id="team-eu", org_id="org-eu") ) - assert destinations == [] and backends == [] + assert destinations == () and backends == () @pytest.mark.asyncio diff --git a/type-discipline-budget.json b/type-discipline-budget.json index d56d5a6e305..a655768d22d 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 23287 + "limit": 23280 }, "LIT002": { "limit": 27473