mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
53d745a4b6
commit
0b0bcb43ee
20 changed files with 183 additions and 153 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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``)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [...]}}``
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"LIT001": {
|
||||
"limit": 23287
|
||||
"limit": 23280
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 27473
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue