fix(otel/v2): clear the type-discipline gate and restore admin-viewer read parity

The lint job failed on LIT001: the branch added 36 mutable-collection annotations
and the merge picked up staging's ratcheted budget. Re-annotate the new surface
with read-only views (Mapping/Sequence/frozenset/tuple), build the resolver's
union and destination results functionally, and freeze the span-router grouping
at its boundary; the four genuinely mutable LRU caches and in-place merge targets
carry mutable-ok reasons. Ratchet both budgets down by what the branch now fixes.

Also drop the wholesale credential mask for the admin viewer: it existed for the
removed tenant read, and the viewer principle is read parity with the proxy
admin. Both admin-tier readers now go through _get_masked_values exactly as on
staging, and otel_headers joins the masker's sensitive keys so the collector
auth it carries is masked for every reader.
This commit is contained in:
Yucheng Zhu 2026-07-28 11:12:00 -07:00
parent 53d745a4b6
commit 0b0bcb43ee
20 changed files with 183 additions and 153 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [...]}}``

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"LIT001": {
"limit": 23287
"limit": 23280
},
"LIT002": {
"limit": 27473