Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/litellm_pre_call_utils.py: 46%
1376 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import asyncio
2import copy
3import json
4import re
5import time
6from collections import OrderedDict
7from collections.abc import Mapping, MutableMapping, Sequence
8from datetime import datetime
9from types import MappingProxyType
10from typing import TYPE_CHECKING, Any, Final, cast
12from fastapi import HTTPException, Request
13from pydantic import TypeAdapter
14from pydantic import ValidationError as PydanticValidationError
15from starlette.datastructures import Headers
17import litellm
18from litellm._logging import verbose_logger, verbose_proxy_logger
19from litellm._service_logger import ServiceLogging
20from litellm._uuid import uuid
21from litellm.constants import (
22 CLIENT_OUTPUT_CEILING_METADATA_KEY,
23 CONSUMED_REQUEST_TAGS_METADATA_KEY,
24 INTERNAL_CALL_ORIGIN_METADATA_KEY,
25 LITELLM_PROXY_MASTER_KEY_ALIAS,
26 OTEL_SERVICE_NAME_METADATA_KEYS,
27 PRE_CALL_EXECUTED_GUARDRAILS_KEY,
28 ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
29 ROUTING_REQUEST_TAGS_METADATA_KEY,
30 SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
31 SESSION_ID_GENERATED_METADATA_KEY,
32 SESSION_ID_OMITTED_METADATA_KEY,
33 X_LITELLM_DISABLE_CALLBACKS,
34)
35from litellm.litellm_core_utils.core_helpers import is_codex_user_agent
36from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
37from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
38 TRUSTED_CALLBACK_VARS_FIELD,
39 _request_blocked_callback_params,
40 iter_client_callback_metadata_dicts,
41)
42from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
43from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
44from litellm.litellm_core_utils.url_utils import (
45 is_url_destination_allowed_by_host,
46 provider_url_destination_candidates,
47)
48from litellm.proxy._types import (
49 AddTeamCallback,
50 CommonProxyErrors,
51 LitellmDataForBackendLLMCall,
52 LiteLLMRoutes,
53 LitellmUserRoles,
54 ProxyErrorTypes,
55 ProxyException,
56 SpecialHeaders,
57 TeamCallbackMetadata,
58 UserAPIKeyAuth,
59)
60from litellm.proxy.auth.auth_utils import get_request_route
61from litellm.proxy.auth.route_checks import RouteChecks
62from litellm.proxy.common_utils.callback_utils import (
63 decrypt_callback_vars,
64 get_metadata_variable_name_from_kwargs,
65 strip_callback_config,
66)
67from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
68from litellm.proxy.spend_tracking.carried_budget_state import carried_budget_metadata
69from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
71# Cache special headers as a frozenset for O(1) lookup performance
72_SPECIAL_HEADERS_CACHE: Final = frozenset(str(v.value).lower() for v in SpecialHeaders)
74_REDACTED_HEADER_VALUE: Final = "***REDACTED***"
75_CREDENTIAL_HEADER_NAMES: Final = SpecialHeaders.litellm_credential_header_names() | frozenset(
76 {"cookie", "proxy-authorization"}
77)
78_TRANSPORT_ONLY_CREDENTIAL_KEYS: Final = frozenset({"provider_specific_header", "headers", "api_key"})
80# Matches any header of the form x-<something>-session-id (case-insensitive).
81# Excludes the two explicit litellm headers which are handled with higher priority.
82_GENERIC_SESSION_ID_HEADER_RE: Final = re.compile(r"^x-.+-session-id$", re.IGNORECASE)
83_EXPLICIT_SESSION_HEADERS: Final = frozenset({"x-litellm-trace-id", "x-litellm-session-id"})
84# Codex carries its conversation uuid in unprefixed headers, so the
85# x-<vendor>-session-id convention above never matches it. Current builds send
86# ``session-id``/``thread-id``; builds before the codex-api split sent
87# ``session_id``/``conversation_id``. Ordered session before thread.
88_CODEX_SESSION_ID_HEADERS: Final = ("session-id", "session_id", "thread-id", "conversation_id")
89# Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores
90# (covers UUIDs and most common session-id formats).
91_SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
93_SHA256_HEX_RE: Final = re.compile(r"^[0-9a-f]{64}$")
95# W3C Trace Context traceparent header: https://www.w3.org/TR/trace-context/
96# e.g. "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
97_TRACEPARENT_RE: Final = re.compile(r"^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$", re.IGNORECASE)
100def _trace_id_from_traceparent(traceparent: str) -> str | None:
101 """Extract the trace-id from a W3C Trace Context traceparent header, e.g.
102 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" -> the 32-hex
103 trace-id in the middle. An all-zero trace-id is invalid per spec and is
104 rejected, matching how the OpenTelemetry SDK itself treats it."""
105 match: Final = _TRACEPARENT_RE.match(traceparent.strip())
106 if not match:
107 return None
108 trace_id: Final = match.group(1).lower()
109 return trace_id if trace_id != "0" * 32 else None
112def _trace_id_from_otel_span(span: "OtelSpan | None") -> str | None:
113 if span is None: 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true
114 return None
115 try:
116 span_context: Final = span.get_span_context()
117 is_valid: Final = span_context.is_valid
118 trace_id: Final = span_context.trace_id
119 except AttributeError:
120 return None
121 if not is_valid or not isinstance(trace_id, int):
122 return None
123 return format(trace_id, "032x")
126def add_otel_trace_id_to_request(
127 data: dict[str, object], _metadata_variable_name: str, parent_otel_span: "OtelSpan | None"
128) -> None:
129 if data.get("litellm_trace_id"): 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return
131 metadata: Final = data.get(_metadata_variable_name)
132 requester_metadata: Final = data.get("metadata")
133 if any(isinstance(m, dict) and m.get("trace_id") for m in (metadata, requester_metadata)): 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 return
135 trace_id: Final = _trace_id_from_otel_span(parent_otel_span)
136 if trace_id is None: 136 ↛ 138line 136 didn't jump to line 138 because the condition on line 136 was always true
137 return
138 data["litellm_trace_id"] = trace_id # rebind-ok: data is an out-param
139 if isinstance(metadata, dict):
140 metadata["trace_id"] = trace_id
143def _session_id_from_baggage(baggage: str) -> str | None:
144 """Extract a session.id entry from a W3C Baggage header
145 (https://www.w3.org/TR/baggage/), e.g. "session.id=abc-123,user.id=42"."""
146 for pair in baggage.split(","):
147 key, _, value = pair.strip().partition("=")
148 if key.strip() == "session.id" and value.strip():
149 return value.strip()
150 return None
153def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
154 """Only proxy-validated keys are stamped, proven by the unforgeable
155 via_virtual_key marker AND a known non-secret shape: the sha256 hex digest
156 UserAPIKeyAuth stores virtual keys in, or the master key's stable alias.
157 Custom-auth credentials arrive raw (never forward auth material) and hashed
158 JWTs rotate on re-issue (useless as a stable ban id), so both are skipped."""
159 api_key: Final = user_api_key_dict.api_key
160 if not user_api_key_dict.via_virtual_key or api_key is None:
161 return None
162 if api_key == LITELLM_PROXY_MASTER_KEY_ALIAS or _SHA256_HEX_RE.fullmatch(api_key):
163 return api_key
164 return None
167_ANTHROPIC_SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]+$")
170def _sanitize_for_log(value: object) -> str:
171 """
172 Basic log sanitization helper to reduce log-injection risk.
174 Removes newline and carriage-return characters so user-controlled
175 values cannot forge additional log lines when written to text logs.
176 """
177 try:
178 text = str(value)
179 except Exception:
180 # Fallback to repr if str() fails for any reason
181 text = repr(value)
182 # Strip CR/LF characters commonly used for log injection
183 return text.replace("\r", "").replace("\n", "")
186from litellm.router import Router
187from litellm.secret_managers.main import get_secret_bool
188from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS
189from litellm.types.services import ServiceTypes
190from litellm.types.utils import (
191 CustomPricingLiteLLMParams,
192 LlmProviders,
193 ProviderSpecificHeader,
194 StandardCallbackDynamicParams,
195 StandardLoggingUserAPIKeyMetadata,
196 SupportedCacheControls,
197)
199service_logger_obj: Final = ServiceLogging() # used for tracking latency on OTEL
200# Bounded dedup for stale-alias warnings (FIFO eviction when over cap).
201_MAX_STALE_ALIAS_WARNING_KEYS: Final = 10_000
202_STALE_TEAM_ALIAS_WARNING_KEYS: Final[OrderedDict[str, None]] = OrderedDict()
203# Cache the stale alias bypass flag at module load to avoid hot-path secret lookups
204_ENABLE_TEAM_STALE_ALIAS_BYPASS: bool | None = None
207if TYPE_CHECKING: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 from opentelemetry.trace import Span as OtelSpan
210 from litellm.integrations.otel.model.destination import OtelDestination
211 from litellm.proxy.policy_engine.attachment_registry import AttachmentRegistry
212 from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig
213 from litellm.types.proxy.policy_engine import Policy, PolicyMatchContext
215 ProxyConfig = _ProxyConfig
216else:
217 ProxyConfig = Any
218 PolicyMatchContext = Any
221def parse_cache_control(cache_control):
222 cache_dict: Final = {}
223 directives: Final = cache_control.split(", ")
225 for directive in directives:
226 if "=" in directive:
227 key, value = directive.split("=")
228 cache_dict[key] = value
229 else:
230 cache_dict[directive] = True
232 return cache_dict
235LITELLM_METADATA_ROUTES: Final = (
236 "batches",
237 "bedrock",
238 "/v1/messages",
239 "responses",
240 "files",
241)
243LITELLM_TRACE_CONTROL_METADATA_FIELDS: Final = frozenset(
244 {
245 "mask_input",
246 "mask_output",
247 "session_id",
248 "trace_id",
249 "trace_metadata",
250 "trace_name",
251 "trace_release",
252 "trace_user_id",
253 "trace_version",
254 }
255)
257_UNTRUSTED_ROOT_CONTROL_FIELDS: Final = (
258 "weights",
259 "_router_weights",
260 "proxy_server_request",
261 "standard_logging_object",
262 "secret_fields",
263 "mock_response",
264 "mock_tool_calls",
265 "disable_global_guardrails",
266 "disable_global_guardrail",
267 "enable_prompt_caching",
268 "opted_out_global_guardrails",
269 "applied_guardrails",
270 "applied_policies",
271 "policy_sources",
272 "guardrail_scan_ids",
273 "guardrail_scan_metadata",
274 "routing_decision",
275 GATEWAY_INJECTED_CACHE_METADATA_KEY,
276 "pillar_response_headers",
277 "_guardrail_pipelines",
278 "_pipeline_managed_guardrails",
279 # Callback-registration fields. ``callbacks``, ``service_callback``,
280 # and ``logger_fn`` are read by ``litellm.utils.function_setup`` and
281 # appended to process-wide ``litellm.{input,success,failure,_async_*,
282 # service}_callback`` lists / ``litellm.user_logger_fn`` — one request
283 # poisons the worker for every subsequent caller.
284 # ``litellm_disabled_callbacks`` is the inverse primitive: the
285 # legitimate path reads it from key/team metadata, the request-body
286 # version silently turns off admin-configured audit/observability
287 # for the caller's request.
288 "callbacks",
289 "service_callback",
290 "logger_fn",
291 "litellm_disabled_callbacks",
292 # Agentic-loop control fields. These bound or drive an interceptor's agentic
293 # loop (web search, compression, code interpreter) and are server-controlled.
294 # A client-supplied value would forge loop depth/cycle state, mark an
295 # interception as active (triggering sandbox code execution without the
296 # native tool ever being present), force the completed response to be
297 # re-wrapped as a synthetic stream the caller never asked for, or raise the
298 # loop ceiling to drive many upstream model calls and sandbox executions
299 # from a single request.
300 "_agentic_loop_depth",
301 "_agentic_loop_fingerprints",
302 "_code_interpreter_interception_active",
303 "_code_interpreter_interception_converted_stream",
304 "_code_interpreter_interception_sandbox_key",
305 "_code_interpreter_interception_session_scoped",
306 "_headroom_interception_converted_stream",
307 "max_agentic_loops",
308 # Recomputed below from the actual caller-controlled timeout sources (headers and
309 # body fields); a client-forged value here would let a request either dodge cooldown
310 # protection on a real deployment failure or force a false "not caller-controlled"
311 # reading that lets its own bad timeout cool down deployments other tenants rely on.
312 "client_side_timeout",
313)
315_UNTRUSTED_METADATA_CONTROL_FIELDS: Final = (
316 "disable_global_guardrails",
317 "disable_global_guardrail",
318 "opted_out_global_guardrails",
319 "pillar_response_headers",
320 "_pillar_response_headers_trusted",
321 "pillar_flagged",
322 "pillar_scanners",
323 "pillar_evidence",
324 "pillar_evidence_truncated",
325 "pillar_session_id_response",
326 "applied_guardrails",
327 "applied_policies",
328 "policy_sources",
329 "guardrail_scan_ids",
330 "guardrail_scan_metadata",
331 "routing_decision",
332 GATEWAY_INJECTED_CACHE_METADATA_KEY,
333 SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
334 CONSUMED_REQUEST_TAGS_METADATA_KEY,
335 ROUTING_REQUEST_TAGS_METADATA_KEY,
336 INTERNAL_CALL_ORIGIN_METADATA_KEY,
337 "standard_logging_object",
338 "proxy_server_request",
339 "secret_fields",
340 "_guardrail_pipelines",
341 "_pipeline_managed_guardrails",
342 "client_disconnected",
343 "error_information",
344 PRE_CALL_EXECUTED_GUARDRAILS_KEY,
345)
347UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS: Final = frozenset(
348 {
349 "litellm-disable-message-redaction",
350 }
351)
352_CLIENT_MOCK_CONTROL_FIELDS: Final = frozenset({"mock_response", "mock_tool_calls"})
353_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY: Final = "allow_client_mock_response"
354_ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY: Final = "allow_client_message_redaction_opt_out"
356# Per-request pricing parameters mutate cost-tracking output and (via
357# ``litellm.completion`` → ``register_model``) the process-wide
358# ``litellm.model_cost`` map. Both effects belong to deployment configuration,
359# not to user-supplied request bodies, so the proxy strips them before they
360# reach the call path. Built from the Pydantic model so newly-added pricing
361# fields are covered automatically.
362_CLIENT_PRICING_CONTROL_FIELDS: Final = frozenset(CustomPricingLiteLLMParams.model_fields.keys())
363# ``model_info`` carries the same pricing fields when read by
364# ``use_custom_pricing_for_model``; strip from metadata for the same reason.
365# ``standard_logging_guardrail_information`` is proxy-written telemetry summed
366# into response_cost and spend; a client seeding it forges (even negative)
367# guardrail cost.
368_CLIENT_PRICING_METADATA_FIELDS: Final = frozenset({"model_info", "standard_logging_guardrail_information"})
369# ``attempted_fallbacks`` and ``original_model_group`` are written by the router
370# and read by spend logs as fact; a client value has no legitimate meaning and no
371# key or team setting keeps it, so the strip is never gated.
372_ROUTER_RESERVED_METADATA_FIELDS: Final = frozenset(
373 {
374 "attempted_fallbacks",
375 "original_model_group",
376 "request_retry_count",
377 CLIENT_OUTPUT_CEILING_METADATA_KEY,
378 ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
379 }
380)
381_ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY: Final = "allow_client_pricing_override"
383# Request fields whose value, when URL-valued, becomes the outbound destination
384# for a provider call. Letting a proxy caller pin the destination is an SSRF
385# primitive (HuggingFace/Oobabooga `model`, Gemini files `file_id`); guard
386# them centrally so SDK users keep working but proxy users default-deny.
387_URL_DESTINATION_REQUEST_FIELDS: Final = ("model", "file_id")
390def _reject_url_valued_destinations(data: dict[str, object]) -> None:
391 """Reject URL-valued ``model``/``file_id`` unless admin-allowlisted.
393 Some providers (HuggingFace, Oobabooga, Gemini files) accept a URL in the
394 identifier field and use it as the outbound destination. On the proxy that
395 is an SSRF primitive — a low-privilege caller can point traffic at any
396 host the proxy can reach, including internal services. Reject here at the
397 proxy boundary so SDK users (who legitimately pass URL-valued identifiers)
398 are unaffected, while admins can opt specific hosts back in via
399 ``litellm.provider_url_destination_allowed_hosts``.
400 """
401 for field in _URL_DESTINATION_REQUEST_FIELDS:
402 value = data.get(field)
403 if isinstance(value, str):
404 reject_url_valued_destination(field, value)
407def reject_url_valued_destination(field: str, value: str) -> None:
408 """Reject a URL-valued destination identifier unless admin-allowlisted.
410 Operates on one field/value pair. ``_reject_url_valued_destinations`` applies
411 it across ``_URL_DESTINATION_REQUEST_FIELDS`` for a request body.
412 """
413 allowed_hosts: Final = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
414 for candidate in provider_url_destination_candidates(value):
415 if not candidate.lower().startswith(("http://", "https://")): 415 ↛ 417line 415 didn't jump to line 417 because the condition on line 415 was always true
416 continue
417 if is_url_destination_allowed_by_host(candidate, allowed_hosts):
418 continue
419 raise HTTPException(
420 status_code=400,
421 detail={
422 "error": "invalid_request",
423 "param": field,
424 "message": (
425 f"URL-valued '{field}' is not allowed. Configure custom "
426 "endpoints with api_base instead, or add the destination "
427 "host to `provider_url_destination_allowed_hosts` in "
428 "litellm_settings."
429 ),
430 },
431 )
434_METADATA_JSON_TYPE_NAMES: Final[Mapping[type, str]] = MappingProxyType(
435 {bool: "a boolean", int: "an integer", float: "a number", str: "a string", list: "an array"}
436)
439def _invalid_metadata_type_error(field: str, value: object) -> ProxyException:
440 received_type: Final = _METADATA_JSON_TYPE_NAMES.get(type(value), f"a {type(value).__name__}")
441 return ProxyException(
442 message=f"Invalid type for '{field}': expected an object, but got {received_type} instead.",
443 type=ProxyErrorTypes.bad_request_error,
444 param=field,
445 code=400,
446 )
449def _normalized_metadata_object(field: str, value: object) -> Mapping[str, object]:
450 """Return ``value`` as a metadata object or raise a 400 like OpenAI does.
452 A JSON string that parses to an object is accepted because multipart/form-data
453 and ``extra_body`` callers can only send metadata as a string. The caller pops
454 the raw value from the request body before validating so the failure-logging
455 hooks that inspect the body afterwards don't crash on it and mask the 400 as a 500.
456 """
457 if isinstance(value, dict): 457 ↛ 459line 457 didn't jump to line 459 because the condition on line 457 was always true
458 return value
459 if isinstance(value, str) and isinstance((parsed := safe_json_loads(value)), dict):
460 return parsed
461 raise _invalid_metadata_type_error(field=field, value=value)
464def _normalized_metadata_slot(
465 request_data: MutableMapping[str, object], metadata_variable_name: str
466) -> dict[str, object]:
467 """Return the request's metadata slot as a dict, normalising it in place first.
469 Metadata can arrive as a JSON string (multipart/form-data, ``extra_body``). Parsing it here keeps
470 existing entries alive through a merge instead of silently overwriting them with an empty dict.
471 """
472 raw: Final = request_data.get(metadata_variable_name)
473 if isinstance(raw, dict):
474 return raw
475 parsed: Final = safe_json_loads(raw) if isinstance(raw, str) else None
476 normalized: Final[dict[str, object]] = parsed if isinstance(parsed, dict) else {}
477 request_data[metadata_variable_name] = normalized
478 return normalized
481def _strip_untrusted_request_header_controls(
482 headers: Any,
483 *,
484 allow_client_message_redaction_opt_out: bool = False,
485) -> None:
486 if not isinstance(headers, dict): 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true
487 return
489 for header_name in list(headers.keys()):
490 if isinstance(header_name, str) and header_name.lower() in UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true
491 if allow_client_message_redaction_opt_out:
492 continue
493 headers.pop(header_name, None)
496def _is_false_like(value: object) -> bool:
497 if isinstance(value, bool):
498 return value is False
499 if isinstance(value, str):
500 return value.strip().lower() in {"false", "0", "no", "off"}
501 return False
504def _key_or_team_metadata_flag_is_true(
505 user_api_key_dict: UserAPIKeyAuth,
506 metadata_key: str,
507) -> bool:
508 for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
509 if isinstance(admin_metadata, dict) and admin_metadata.get(metadata_key) is True: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 return True
511 return False
514def _key_or_team_allows_client_mock_response(
515 user_api_key_dict: UserAPIKeyAuth,
516) -> bool:
517 return _key_or_team_metadata_flag_is_true(
518 user_api_key_dict=user_api_key_dict,
519 metadata_key=_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY,
520 )
523def _key_or_team_allows_client_message_redaction_opt_out(
524 user_api_key_dict: UserAPIKeyAuth,
525) -> bool:
526 return _key_or_team_metadata_flag_is_true(
527 user_api_key_dict=user_api_key_dict,
528 metadata_key=_ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY,
529 )
532def _key_or_team_allows_client_pricing_override(
533 user_api_key_dict: UserAPIKeyAuth,
534) -> bool:
535 return _key_or_team_metadata_flag_is_true(
536 user_api_key_dict=user_api_key_dict,
537 metadata_key=_ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY,
538 )
541def _strip_client_message_redaction_opt_out(data: dict[str, object]) -> None:
542 stripped: Final[list[str]] = []
543 if "turn_off_message_logging" in data and _is_false_like(data["turn_off_message_logging"]):
544 stripped.append("turn_off_message_logging")
545 data.pop("turn_off_message_logging", None)
546 for slot_label, metadata in iter_client_callback_metadata_dicts(data):
547 if "turn_off_message_logging" in metadata and _is_false_like(metadata["turn_off_message_logging"]):
548 stripped.append(f"{slot_label}.turn_off_message_logging")
549 metadata.pop("turn_off_message_logging", None)
550 if stripped:
551 verbose_proxy_logger.debug(
552 "Stripped client-supplied message-redaction opt-out fields from request body: %s. "
553 "Set `allow_client_message_redaction_opt_out: true` on the key or team metadata "
554 "to keep these values.",
555 ", ".join(stripped),
556 )
559def _strip_client_callback_credentials(
560 data: dict[str, Any], # mutable-ok: strips in place on the request body the pre-call pipeline threads through
561) -> None:
562 """Drop callback credentials and destinations supplied by the caller.
564 ``_request_blocked_callback_params`` (Datadog + GCS credentials, sites and agent
565 hosts) are already ignored when building ``standard_callback_dynamic_params``.
566 Strip them from the body and every client metadata slot as well, so a caller
567 cannot pair its own ``dd_site``/``dd_agent_host`` with the team's admin-configured
568 ``dd_api_key`` and have the resulting logs shipped to a host it controls.
570 ``TRUSTED_CALLBACK_VARS_FIELD`` is proxy-owned; it is cleared here and repopulated
571 from team/key callback settings in ``add_litellm_data_to_request``.
572 """
573 containers: Final = (("body", data), *iter_client_callback_metadata_dicts(data))
574 stripped: Final = tuple(
575 f"{label}.{field}"
576 for label, container in containers
577 for field in _request_blocked_callback_params
578 if field in container
579 )
580 for _, container in containers:
581 for field in _request_blocked_callback_params:
582 container.pop(field, None)
583 data.pop(TRUSTED_CALLBACK_VARS_FIELD, None)
584 if stripped: 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true
585 verbose_proxy_logger.debug(
586 "Stripped client-supplied callback credentials from request: %s. "
587 "Configure these on the team or key callback settings instead.",
588 ", ".join(sorted(stripped)),
589 )
592def _strip_client_pricing_overrides(data: dict[str, object]) -> None:
593 """Drop pricing overrides from the request body and any metadata variant.
595 Skipped only when the calling key/team carries
596 ``allow_client_pricing_override: True`` in its metadata. Emits a
597 ``debug``-level log line naming the dropped fields so operators can
598 trace why a client-supplied pricing override stopped being applied
599 (otherwise the strip is invisible from the caller's perspective).
600 """
601 stripped: Final[list[str]] = []
602 for field in _CLIENT_PRICING_CONTROL_FIELDS:
603 if field in data: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 stripped.append(field)
605 data.pop(field, None)
606 for metadata_key in ("metadata", "litellm_metadata"):
607 metadata = data.get(metadata_key)
608 if not isinstance(metadata, dict):
609 continue
610 for field in _CLIENT_PRICING_METADATA_FIELDS:
611 if field in metadata: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 stripped.append(f"{metadata_key}.{field}")
613 metadata.pop(field, None)
614 if stripped: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 verbose_proxy_logger.debug(
616 "Stripped client-supplied pricing fields from request body: %s. "
617 "Set `allow_client_pricing_override: true` on the key or team "
618 "metadata to keep these values.",
619 ", ".join(stripped),
620 )
623def _strip_router_reserved_metadata(
624 data: dict[str, Any], # mutable-ok: strips in place on the request body the pre-call pipeline threads through
625) -> None:
626 """Drop the router-owned fallback stamps from any client-supplied metadata bucket."""
627 for metadata_key in ("metadata", "litellm_metadata"):
628 if not isinstance(metadata := data.get(metadata_key), dict):
629 continue
630 for field in _ROUTER_RESERVED_METADATA_FIELDS & metadata.keys(): 630 ↛ 631line 630 didn't jump to line 631 because the loop on line 630 never started
631 metadata.pop(field)
632 verbose_proxy_logger.debug(
633 "Stripped router-reserved metadata field from request body: %s.%s", metadata_key, field
634 )
637def _get_metadata_variable_name(request: Request) -> str:
638 """
639 Helper to return what the "metadata" field should be called in the request data
641 For all /thread or /assistant endpoints we need to call this "litellm_metadata"
643 For ALL other endpoints we call this "metadata"
644 """
645 # Inline imports — auth_utils/route_checks participate in a proxy import cycle.
646 from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
648 path: Final = get_request_route(request)
649 if "thread" in path or "assistant" in path:
650 return "litellm_metadata"
652 if any(route in path for route in LITELLM_METADATA_ROUTES):
653 return "litellm_metadata"
655 return "metadata"
658def _promoted_trace_control_fields(
659 requester_metadata: Mapping[str, object],
660 litellm_metadata: Mapping[str, object],
661) -> tuple[tuple[str, object], ...]:
662 """Return the caller's trace-control fields that ``litellm_metadata`` does not already set."""
663 return tuple(
664 (key, value)
665 for key, value in requester_metadata.items()
666 if key in LITELLM_TRACE_CONTROL_METADATA_FIELDS and key not in litellm_metadata
667 )
670def _extract_generic_session_id_from_headers(
671 normalized: dict[str, str],
672) -> str | None:
673 """
674 Scan a normalised (lower-cased keys) header dict for any header that looks
675 like ``x-<vendor>-session-id`` and whose value is a plausible session/trace
676 identifier (alphanumeric + hyphens/underscores, at least 8 chars).
678 The two explicit LiteLLM headers (``x-litellm-trace-id`` /
679 ``x-litellm-session-id``) are excluded here because they are handled with
680 higher priority by the caller.
682 Example: ``x-claude-code-session-id: e96634a3-fa28-4083-b354-55542e2dca01``
683 """
684 for key, value in normalized.items():
685 if ( 685 ↛ 691line 685 didn't jump to line 691 because the condition on line 685 was never true
686 key not in _EXPLICIT_SESSION_HEADERS
687 and _GENERIC_SESSION_ID_HEADER_RE.match(key)
688 and isinstance(value, str)
689 and _SESSION_ID_VALUE_RE.match(value)
690 ):
691 return value
692 return None
695def _extract_codex_session_id_from_headers(
696 normalized: Mapping[str, str],
697) -> str | None:
698 """
699 Read Codex's conversation uuid off one of ``_CODEX_SESSION_ID_HEADERS``.
701 Codex sends no request metadata the Anthropic path could parse and no
702 ``x-``-prefixed session header, so without this every turn of a Codex session
703 falls through to a freshly generated per-call trace id and lands as its own
704 row in the logs instead of grouping.
706 Unprefixed names like ``session-id`` are generic enough that another client
707 could send one meaning something unrelated, and colliding values across
708 callers would merge their traces, so this only applies to callers that
709 identify as Codex.
710 """
711 user_agent: Final = normalized.get("user-agent")
712 if not isinstance(user_agent, str) or not is_codex_user_agent(user_agent): 712 ↛ 714line 712 didn't jump to line 714 because the condition on line 712 was always true
713 return None
714 return next(
715 (
716 value
717 for value in (normalized.get(header) for header in _CODEX_SESSION_ID_HEADERS)
718 if isinstance(value, str) and _SESSION_ID_VALUE_RE.match(value)
719 ),
720 None,
721 )
724def _extract_bare_session_id_from_headers(
725 normalized: Mapping[str, str],
726) -> str | None:
727 """
728 Read a vendor-less ``x-session-id`` header (opencode sends ``X-Session-Id``
729 alongside ``x-session-affinity`` on every turn of a session). Checked after
730 the ``x-<vendor>-session-id`` scan so a more specific header such as
731 opencode's ``x-parent-session-id`` on subagent calls keeps winning.
732 """
733 value: Final = normalized.get("x-session-id")
734 if isinstance(value, str) and _SESSION_ID_VALUE_RE.match(value): 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 return value
736 return None
739def get_chain_id_from_headers(headers: dict[str, str] | None) -> str | None:
740 """
741 Extract chain id for call chaining from request headers.
743 Priority order:
744 1. ``x-litellm-trace-id`` (explicit, highest priority)
745 2. ``x-litellm-session-id`` (explicit)
746 3. Any ``x-<vendor>-session-id`` header whose value looks like a session id
747 (alphanumeric / UUID, at least 8 chars). E.g. ``x-claude-code-session-id``.
748 4. Codex's unprefixed ``session-id`` / ``thread-id``, for Codex callers only.
749 5. A vendor-less ``x-session-id`` header (e.g. opencode), same value rules.
751 Header keys are matched case-insensitively so this works with raw header
752 dicts from any transport.
754 Used by MCP (and other paths that have raw_headers but no Request) to set
755 litellm_trace_id/litellm_session_id for spend logs and logging consistency.
756 """
757 if not headers: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true
758 return None
759 normalized: Final = {k.lower(): v for k, v in headers.items() if isinstance(k, str)}
760 return (
761 normalized.get("x-litellm-trace-id")
762 or normalized.get("x-litellm-session-id")
763 or _extract_generic_session_id_from_headers(normalized)
764 or _extract_codex_session_id_from_headers(normalized)
765 or _extract_bare_session_id_from_headers(normalized)
766 )
769def _get_anthropic_session_id_from_metadata(metadata: object) -> str | None:
770 if not isinstance(metadata, dict):
771 return None
773 user_id: Final = metadata.get("user_id")
774 if isinstance(user_id, dict): 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true
775 session_id = user_id.get("session_id")
776 if isinstance(session_id, str) and _ANTHROPIC_SESSION_ID_VALUE_RE.fullmatch(session_id):
777 return session_id
778 return None
779 if not isinstance(user_id, str): 779 ↛ 782line 779 didn't jump to line 782 because the condition on line 779 was always true
780 return None
782 session_marker: Final = "_session_"
783 session_marker_index: Final = user_id.rfind(session_marker)
784 if session_marker_index == -1:
785 return None
787 session_id = user_id[session_marker_index + len(session_marker) :]
788 if not session_id or not _ANTHROPIC_SESSION_ID_VALUE_RE.fullmatch(session_id):
789 return None
790 return session_id
793def _is_llm_inference_route(request: Request) -> bool:
794 route: Final = get_request_route(request)
795 return RouteChecks.is_llm_api_route(route=route) and not RouteChecks.check_route_access(
796 route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
797 )
800def apply_missing_session_id_policy(
801 data: dict[str, object], # mutable-ok: stamps session ids in place on the request body the pipeline threads through
802 _metadata_variable_name: str,
803 general_settings: Mapping[str, object] | None,
804 request: Request,
805) -> None:
806 for metadata_key in ("metadata", "litellm_metadata"):
807 if isinstance(client_metadata := data.get(metadata_key), dict):
808 client_metadata.pop(SESSION_ID_OMITTED_METADATA_KEY, None)
809 metadata: Final = data.get(_metadata_variable_name)
810 policy: Final = general_settings.get("missing_session_id") if general_settings else None
811 if policy is None or not _is_llm_inference_route(request): 811 ↛ 813line 811 didn't jump to line 813 because the condition on line 811 was always true
812 return
813 if not isinstance(metadata, dict):
814 return
815 if policy == "omit":
816 metadata[SESSION_ID_OMITTED_METADATA_KEY] = True
817 requester_metadata: Final = data.get("metadata")
818 requester_session_id: Final = (
819 requester_metadata.get("session_id") if isinstance(requester_metadata, dict) else None
820 )
821 if (
822 (body_session_id := data.get("litellm_session_id"))
823 and not metadata.get("session_id")
824 and not requester_session_id
825 ):
826 metadata["session_id"] = body_session_id
827 return
828 if data.get("litellm_session_id") or metadata.get("session_id"):
829 return
830 match policy:
831 case "generate":
832 session_id: Final = str(data.get("litellm_trace_id") or metadata.get("trace_id") or uuid.uuid4())
833 data["litellm_session_id"] = session_id # rebind-ok: data is an out-param
834 data.setdefault("litellm_trace_id", session_id)
835 metadata["session_id"] = session_id
836 metadata[SESSION_ID_GENERATED_METADATA_KEY] = True
837 case "reject":
838 raise ProxyException(
839 message=(
840 "Request has no session id. Send an `x-litellm-session-id` header or `metadata.session_id`. "
841 "Required by `general_settings.missing_session_id: reject`."
842 ),
843 type=ProxyErrorTypes.bad_request_error,
844 param="session_id",
845 code=400,
846 )
847 case _:
848 verbose_proxy_logger.warning(
849 "Ignoring unknown general_settings.missing_session_id=%r; expected 'generate', 'reject' or 'omit'",
850 policy,
851 )
854def should_auto_drop_params_for_agentic_cli(user_agent: str, data: dict, proxy_config: ProxyConfig) -> bool:
855 """drop_params defaults to on for agentic CLIs so their client-specific
856 params (e.g. Claude Code's thinking, Codex's service_tier) don't fail
857 requests routed to providers that reject them. An explicit drop_params
858 from the caller or in the operator's ``litellm_settings`` always wins
859 over this default."""
860 from litellm.llms.anthropic.common_utils import is_claude_code_user_agent
862 if not (is_claude_code_user_agent(user_agent) or is_codex_user_agent(user_agent)): 862 ↛ 864line 862 didn't jump to line 864 because the condition on line 862 was always true
863 return False
864 if "drop_params" in data:
865 return False
866 config: Final = getattr(proxy_config, "config", None)
867 litellm_settings: Final = config.get("litellm_settings") if isinstance(config, dict) else None
868 return not (isinstance(litellm_settings, dict) and "drop_params" in litellm_settings)
871def safe_add_api_version_from_query_params(data: dict, request: Request):
872 try:
873 if hasattr(request, "query_params"): 873 ↛ exitline 873 didn't return from function 'safe_add_api_version_from_query_params' because the condition on line 873 was always true
874 query_params: Final = dict(request.query_params)
875 if "api-version" in query_params: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true
876 data["api_version"] = query_params["api-version"]
877 except KeyError:
878 pass
879 except Exception as e:
880 verbose_logger.exception("error checking api version in query params: %s", str(e))
883def convert_key_logging_metadata_to_callback(
884 data: AddTeamCallback,
885 team_callback_settings_obj: TeamCallbackMetadata | None,
886) -> TeamCallbackMetadata:
887 if team_callback_settings_obj is None: 887 ↛ 888line 887 didn't jump to line 888 because the condition on line 887 was never true
888 team_callback_settings_obj = TeamCallbackMetadata()
889 if data.callback_type == "success": 889 ↛ 890line 889 didn't jump to line 890 because the condition on line 889 was never true
890 if team_callback_settings_obj.success_callback is None:
891 team_callback_settings_obj.success_callback = []
893 if data.callback_name not in team_callback_settings_obj.success_callback:
894 team_callback_settings_obj.success_callback.append(data.callback_name)
895 elif data.callback_type == "failure": 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 if team_callback_settings_obj.failure_callback is None:
897 team_callback_settings_obj.failure_callback = []
899 if data.callback_name not in team_callback_settings_obj.failure_callback:
900 team_callback_settings_obj.failure_callback.append(data.callback_name)
901 elif ( 901 ↛ 920line 901 didn't jump to line 920 because the condition on line 901 was always true
902 not data.callback_type or data.callback_type == "success_and_failure"
903 ): # assume 'success_and_failure' = litellm.callbacks
904 if team_callback_settings_obj.success_callback is None: 904 ↛ 905line 904 didn't jump to line 905 because the condition on line 904 was never true
905 team_callback_settings_obj.success_callback = []
906 if team_callback_settings_obj.failure_callback is None: 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true
907 team_callback_settings_obj.failure_callback = []
908 if team_callback_settings_obj.callbacks is None: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true
909 team_callback_settings_obj.callbacks = []
911 if data.callback_name not in team_callback_settings_obj.success_callback:
912 team_callback_settings_obj.success_callback.append(data.callback_name)
914 if data.callback_name not in team_callback_settings_obj.failure_callback:
915 team_callback_settings_obj.failure_callback.append(data.callback_name)
917 if data.callback_name not in team_callback_settings_obj.callbacks:
918 team_callback_settings_obj.callbacks.append(data.callback_name)
920 for var, value in data.callback_vars.items(): 920 ↛ 925line 920 didn't jump to line 925 because the loop on line 920 never started
921 # New Relic routing reads these from the trusted-vars overlay with no
922 # callback-name check, so scope them to the newrelic entry: a team that
923 # put newrelic_* under a different callback never asked for New Relic and
924 # must not export to it.
925 if var.startswith("newrelic_") and data.callback_name != "newrelic":
926 continue
927 if team_callback_settings_obj.callback_vars is None:
928 team_callback_settings_obj.callback_vars = {}
929 team_callback_settings_obj.callback_vars[var] = str(value)
931 return team_callback_settings_obj
934def _get_validated_callback_metadata(item: dict, *, source: str) -> AddTeamCallback | None:
935 try:
936 return AddTeamCallback(**item)
937 except (PydanticValidationError, ValueError) as e:
938 verbose_proxy_logger.warning(
939 "Ignoring invalid %s callback metadata: %s",
940 source,
941 _sanitize_for_log(str(e)),
942 )
943 return None
946class KeyAndTeamLoggingSettings:
947 """
948 Helper class to get the dynamic logging settings for the key and team
949 """
951 @staticmethod
952 def get_key_dynamic_logging_settings(user_api_key_dict: UserAPIKeyAuth):
953 if user_api_key_dict.metadata is not None and "logging" in user_api_key_dict.metadata: 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true
954 return decrypt_callback_vars(user_api_key_dict.metadata).get("logging")
955 return None
957 @staticmethod
958 def get_team_dynamic_logging_settings(user_api_key_dict: UserAPIKeyAuth):
959 if user_api_key_dict.team_metadata is not None and "logging" in user_api_key_dict.team_metadata: 959 ↛ 960line 959 didn't jump to line 960 because the condition on line 959 was never true
960 return decrypt_callback_vars(user_api_key_dict.team_metadata).get("logging")
961 return None
964def _get_dynamic_logging_metadata(
965 user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig
966) -> TeamCallbackMetadata | None:
967 callback_settings_obj: TeamCallbackMetadata | None = None
968 key_dynamic_logging_settings: Final[dict | None] = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(
969 user_api_key_dict
970 )
971 team_dynamic_logging_settings: Final[dict | None] = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(
972 user_api_key_dict
973 )
974 #########################################################################################
975 # Key-based callbacks
976 #########################################################################################
977 if key_dynamic_logging_settings is not None: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 for item in key_dynamic_logging_settings:
979 callback = _get_validated_callback_metadata(item=item, source="key-level")
980 if callback is None:
981 continue
982 callback_settings_obj = convert_key_logging_metadata_to_callback(
983 data=callback,
984 team_callback_settings_obj=callback_settings_obj,
985 )
986 #########################################################################################
987 # Team-based callbacks
988 #########################################################################################
989 elif team_dynamic_logging_settings is not None: 989 ↛ 990line 989 didn't jump to line 990 because the condition on line 989 was never true
990 for item in team_dynamic_logging_settings:
991 callback = _get_validated_callback_metadata(item=item, source="team-level")
992 if callback is None:
993 continue
994 callback_settings_obj = convert_key_logging_metadata_to_callback(
995 data=callback,
996 team_callback_settings_obj=callback_settings_obj,
997 )
998 #########################################################################################
999 # Deprecated format - maintained for backwards compatibility
1000 #########################################################################################
1001 elif user_api_key_dict.team_metadata is not None and "callback_settings" in user_api_key_dict.team_metadata: 1001 ↛ 1002line 1001 didn't jump to line 1002 because the condition on line 1001 was never true
1002 """
1003 callback_settings = {
1004 {
1005 'callback_vars': {'langfuse_public_key': 'pk', 'langfuse_secret_key': 'sk_'},
1006 'failure_callback': [],
1007 'success_callback': ['langfuse', 'langfuse']
1008 }
1009 }
1010 """
1011 team_metadata: Final = decrypt_callback_vars(user_api_key_dict.team_metadata)
1012 callback_settings: Final = team_metadata.get("callback_settings", None) or {}
1013 callback_settings_obj = TeamCallbackMetadata(**callback_settings)
1014 verbose_proxy_logger.debug("Team callback settings activated: %s", callback_settings_obj)
1015 #########################################################################################
1016 # Enter here when configured on the config.yaml file.
1017 #########################################################################################
1018 elif user_api_key_dict.team_id is not None: 1018 ↛ 1019line 1018 didn't jump to line 1019 because the condition on line 1018 was never true
1019 callback_settings_obj = LiteLLMProxyRequestSetup.add_team_based_callbacks_from_config(
1020 team_id=user_api_key_dict.team_id, proxy_config=proxy_config
1021 )
1022 return callback_settings_obj
1025_TENANT_OTEL_PARAMS: Final = TypeAdapter(StandardCallbackDynamicParams)
1028def _tenant_otel_params(callback_vars: Mapping[str, str]) -> StandardCallbackDynamicParams:
1029 try:
1030 return _TENANT_OTEL_PARAMS.validate_python(callback_vars)
1031 except PydanticValidationError:
1032 return StandardCallbackDynamicParams()
1035_NO_REQUEST_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
1038def _dynamically_disabled_backends(
1039 user_api_key_dict: UserAPIKeyAuth,
1040 request_headers: Mapping[str, str] | None,
1041) -> frozenset[str]:
1042 """The callbacks this request turned off, read the way dispatch reads them.
1044 Same sources, precedence, and premium gate ``EnterpriseCallbackControls`` applies
1045 before it skips a callback: the ``x-litellm-disable-callbacks`` header wins over the
1046 key's stored list, team settings are not a source, and a non-premium proxy honours
1047 neither. A destination has to agree with that decision, or a backend the key turned
1048 off would still be exported to, now through the fan-out instead of the callback.
1049 """
1050 from litellm.proxy.proxy_server import premium_user
1052 if litellm.allow_dynamic_callback_disabling is not True or not premium_user:
1053 return frozenset()
1054 header: Final = (request_headers if request_headers is not None else _NO_REQUEST_HEADERS).get(
1055 X_LITELLM_DISABLE_CALLBACKS
1056 )
1057 if header is not None:
1058 return frozenset(name.strip().lower() for name in header.split(","))
1059 metadata: Final = user_api_key_dict.metadata
1060 disabled: Final = metadata.get("litellm_disabled_callbacks") if metadata else None
1061 if not isinstance(disabled, list):
1062 return frozenset()
1063 return frozenset(name.lower() for name in disabled if isinstance(name, str))
1066def resolve_tenant_otel_destinations(
1067 user_api_key_dict: UserAPIKeyAuth,
1068 request_headers: Mapping[str, str] | None = None,
1069) -> "tuple[OtelDestination, ...]":
1070 """The OTLP destinations this request's key or team config overrides its traces to.
1072 Key settings win over team settings outright, the same precedence
1073 ``_get_dynamic_logging_metadata`` applies, so one caller never exports the same
1074 backend to two accounts. An empty key-level list counts as configured, since that
1075 is what disabling a key's callbacks writes. Returns empty when OTEL V2 is off, when
1076 neither level named a destination-capable backend, or when the config is
1077 incomplete, and the request then keeps the operator's own exporters.
1079 Two entries naming the same backend merge their ``callback_vars`` last-wins, the
1080 way ``convert_key_logging_metadata_to_callback`` merges them, so the destination
1081 and the per-request tracer routing cannot read one config two ways.
1083 A ``failure``-only entry is skipped: a destination is resolved during auth, before
1084 the request has an outcome, so honouring the filter would mean holding every span
1085 back until the call finishes. Those entries keep today's behaviour instead, where
1086 the tenant's credentials reach the backend through per-request tracer routing and
1087 the operator's exporter is left alone. Its ``callback_vars`` still take part in the
1088 merge for a backend another entry made eligible, so the destination carries the
1089 same credentials the runtime parser resolves for that request.
1091 A backend the request disabled dynamically, through the key's
1092 ``litellm_disabled_callbacks`` or the ``x-litellm-disable-callbacks`` header in
1093 ``request_headers``, resolves to no destination, so the fan-out never carries the
1094 request tree to that account and the operator's exporter is never suppressed for
1095 it. That leaves the request exactly where it stood before destinations existed:
1096 the OTel V2 logger itself is not on the disable list's class registry, so its own
1097 span still routes to the tenant's credentials the way it did then.
1098 """
1099 from litellm.integrations.otel.model.config import is_otel_v2_enabled
1100 from litellm.integrations.otel.presets.destinations import destination_for
1102 if not is_otel_v2_enabled(): 1102 ↛ 1104line 1102 didn't jump to line 1104 because the condition on line 1102 was always true
1103 return ()
1104 key_entries: Final = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict)
1105 entries: Final = (
1106 key_entries
1107 if key_entries is not None
1108 else KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict)
1109 )
1110 if not entries:
1111 return ()
1112 disabled: Final = _dynamically_disabled_backends(user_api_key_dict, request_headers)
1113 callbacks: Final = tuple(
1114 callback
1115 for item in entries
1116 if (callback := _get_validated_callback_metadata(item=item, source="otel-destination")) is not None
1117 if callback.callback_name.lower() not in disabled
1118 )
1119 return tuple(
1120 destination
1121 for name in dict.fromkeys(
1122 callback.callback_name for callback in callbacks if callback.callback_type != "failure"
1123 )
1124 if (
1125 destination := destination_for(
1126 name,
1127 _tenant_otel_params(
1128 MappingProxyType(
1129 {
1130 var: value
1131 for callback in callbacks
1132 if callback.callback_name == name
1133 for var, value in callback.callback_vars.items()
1134 }
1135 )
1136 ),
1137 _tenant_service_name(user_api_key_dict),
1138 )
1139 )
1140 is not None
1141 )
1144def _tenant_service_name(user_api_key_dict: UserAPIKeyAuth) -> str | None:
1145 """The ``service.name`` this key or team configured, the key winning over its team.
1147 Same fields and same precedence the request-metadata build applies, read straight
1148 off the auth object because destinations resolve during auth, before that metadata
1149 is assembled.
1150 """
1151 sources: Final = (user_api_key_dict.metadata, user_api_key_dict.team_metadata)
1152 return next(
1153 (
1154 stripped
1155 for source in sources
1156 if source
1157 for field in OTEL_SERVICE_NAME_METADATA_KEYS
1158 if isinstance(value := source.get(field), str) and (stripped := value.strip())
1159 ),
1160 None,
1161 )
1164def clean_headers(
1165 headers: Headers,
1166 litellm_key_header_name: str | None = None,
1167 forward_llm_provider_auth_headers: bool = False,
1168 authenticated_with_header: str | None = None,
1169) -> dict:
1170 """
1171 Removes litellm api key from headers
1173 Args:
1174 headers: Request headers
1175 litellm_key_header_name: Custom header name for LiteLLM API key
1176 forward_llm_provider_auth_headers: Whether to forward provider auth headers
1177 authenticated_with_header: Which header was used for LiteLLM authentication
1178 (e.g., "x-litellm-api-key", "authorization", "x-api-key")
1180 Returns:
1181 Cleaned headers dict
1182 """
1183 from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
1185 clean_headers: Final = {}
1186 litellm_key_lower: Final = litellm_key_header_name.lower() if litellm_key_header_name is not None else None
1187 for header, value in headers.items():
1188 header_lower = header.lower()
1190 if header_lower == "authorization" and is_anthropic_oauth_key(value): 1190 ↛ 1191line 1190 didn't jump to line 1191 because the condition on line 1190 was never true
1191 if authenticated_with_header is None or authenticated_with_header.lower() != "authorization":
1192 clean_headers[header] = value
1193 continue
1194 # Special handling for x-api-key: forward it based on authenticated_with_header
1195 elif header_lower == "x-api-key": 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true
1196 if forward_llm_provider_auth_headers and (
1197 authenticated_with_header is None or authenticated_with_header.lower() != "x-api-key"
1198 ):
1199 clean_headers[header] = value
1200 elif forward_llm_provider_auth_headers and header_lower in _SPECIAL_HEADERS_CACHE: 1200 ↛ 1201line 1200 didn't jump to line 1201 because the condition on line 1200 was never true
1201 if litellm_key_lower and header_lower == litellm_key_lower:
1202 continue
1203 if header_lower == "authorization":
1204 continue
1205 # Never forward x-litellm-api-key (it's for proxy auth only)
1206 if header_lower == "x-litellm-api-key":
1207 continue
1208 clean_headers[header] = value
1209 # Check if header should be excluded: either in special headers cache or matches custom litellm key
1210 elif header_lower not in _SPECIAL_HEADERS_CACHE and (
1211 litellm_key_lower is None or header_lower != litellm_key_lower
1212 ):
1213 clean_headers[header] = value
1214 return clean_headers
1217def _is_credential_header(header: str) -> bool:
1218 """Whether `header` carries a caller credential rather than request context."""
1219 return header.lower() in _CREDENTIAL_HEADER_NAMES
1222def redact_credential_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
1223 """Return a copy of `headers` with credential-bearing values masked.
1225 `clean_headers` deliberately preserves some credential headers so they can be
1226 forwarded to the upstream provider; an Anthropic subscription OAuth token in
1227 `Authorization`, or a client-supplied provider key in `x-api-key`. Those values
1228 must never reach a logging callback or a spend log, so every observability-facing
1229 copy of the header dict is built through this helper while the copy that is
1230 forwarded upstream keeps the real values.
1232 The returned object is a plain dict; guardrail hooks stamp their own headers onto
1233 the stored copy and the logging callbacks JSON-serialize it.
1234 """
1235 return {
1236 header: (_REDACTED_HEADER_VALUE if _is_credential_header(header) else value)
1237 for header, value in headers.items()
1238 }
1241class LiteLLMProxyRequestSetup:
1242 @staticmethod
1243 def _get_timeout_from_request(headers: dict) -> float | None:
1244 """
1245 Workaround for client request from Vercel's AI SDK.
1247 Allow's user to set a timeout in the request headers.
1249 Example:
1251 ```js
1252 const openaiProvider = createOpenAI({
1253 baseURL: liteLLM.baseURL,
1254 apiKey: liteLLM.apiKey,
1255 compatibility: "compatible",
1256 headers: {
1257 "x-litellm-timeout": "90"
1258 },
1259 });
1260 ```
1261 """
1262 timeout_header: Final = headers.get("x-litellm-timeout", None)
1263 if timeout_header is not None: 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true
1264 return float(timeout_header)
1265 return None
1267 @staticmethod
1268 def _get_stream_timeout_from_request(headers: dict) -> float | None:
1269 """
1270 Get the `stream_timeout` from the request headers.
1271 """
1272 stream_timeout_header: Final = headers.get("x-litellm-stream-timeout", None)
1273 if stream_timeout_header is not None: 1273 ↛ 1274line 1273 didn't jump to line 1274 because the condition on line 1273 was never true
1274 return float(stream_timeout_header)
1275 return None
1277 @staticmethod
1278 def _get_keepalive_seconds_from_request(headers: Mapping[str, str]) -> float | None:
1279 """
1280 Get `keepalive_seconds` from the request headers, for clients (e.g. the
1281 Vercel AI SDK) that can set custom headers more easily than extra body
1282 fields. Subject to the same deployment-level allow_client_keepalive_override
1283 gate as the request body field: see _resolve_keepalive_seconds.
1284 """
1285 keepalive_seconds_header: Final = headers.get("x-litellm-keepalive-seconds", None)
1286 if keepalive_seconds_header is not None: 1286 ↛ 1287line 1286 didn't jump to line 1287 because the condition on line 1286 was never true
1287 return float(keepalive_seconds_header)
1288 return None
1290 @staticmethod
1291 def _get_num_retries_from_request(headers: dict) -> int | None:
1292 """
1293 Workaround for client request from Vercel's AI SDK.
1294 """
1295 num_retries_header: Final = headers.get("x-litellm-num-retries", None)
1296 if num_retries_header is not None: 1296 ↛ 1297line 1296 didn't jump to line 1297 because the condition on line 1296 was never true
1297 return int(num_retries_header)
1298 return None
1300 @staticmethod
1301 def _get_spend_logs_metadata_from_request_headers(headers: dict) -> dict | None:
1302 """
1303 Get the `spend_logs_metadata` from the request headers.
1304 """
1305 from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
1307 spend_logs_metadata_header: Final = headers.get("x-litellm-spend-logs-metadata", None)
1308 if spend_logs_metadata_header is not None: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true
1309 return safe_json_loads(spend_logs_metadata_header)
1310 return None
1312 @staticmethod
1313 def _get_forwardable_headers(
1314 headers: Headers | dict,
1315 ):
1316 """
1317 Get the headers that should be forwarded to the LLM Provider.
1319 Looks for any `x-` headers and sends them to the LLM Provider.
1321 [07/09/2025] - Support 'anthropic-beta' header as well.
1322 """
1323 forwarded_headers: Final = {}
1324 for header, value in headers.items():
1325 if (
1326 header.lower().startswith("x-")
1327 and not header.lower().startswith("x-stainless")
1328 or header.lower().startswith("anthropic-beta")
1329 ): # causes openai sdk to fail
1330 forwarded_headers[header] = value
1332 return forwarded_headers
1334 @staticmethod
1335 def _get_case_insensitive_header(headers: dict, key: str) -> str | None:
1336 """
1337 Get a case-insensitive header from the headers dictionary.
1338 """
1339 for header, value in headers.items():
1340 if header.lower() == key.lower():
1341 return value
1342 return None
1344 @staticmethod
1345 def add_internal_user_from_user_mapping(
1346 general_settings: dict | None,
1347 user_api_key_dict: UserAPIKeyAuth,
1348 headers: dict,
1349 ) -> UserAPIKeyAuth:
1350 if general_settings is None: 1350 ↛ 1351line 1350 didn't jump to line 1351 because the condition on line 1350 was never true
1351 return user_api_key_dict
1352 user_header_mapping: Final = general_settings.get("user_header_mappings")
1353 if not user_header_mapping: 1353 ↛ 1355line 1353 didn't jump to line 1355 because the condition on line 1353 was always true
1354 return user_api_key_dict
1355 header_name: Final = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(user_header_mapping)
1356 if not header_name:
1357 return user_api_key_dict
1358 header_value: Final = LiteLLMProxyRequestSetup._get_case_insensitive_header(headers, header_name)
1359 if header_value:
1360 user_api_key_dict.user_id = header_value
1361 return user_api_key_dict
1362 return user_api_key_dict
1364 @staticmethod
1365 def get_user_from_headers(headers: dict, general_settings: dict | None = None) -> str | None:
1366 """
1367 Get the user from the specified header if `general_settings.user_header_name` is set.
1368 """
1369 if general_settings is None: 1369 ↛ 1370line 1369 didn't jump to line 1370 because the condition on line 1369 was never true
1370 return None
1372 header_name: Final = general_settings.get("user_header_name")
1373 if header_name is None or header_name == "": 1373 ↛ 1376line 1373 didn't jump to line 1376 because the condition on line 1373 was always true
1374 return None
1376 if not isinstance(header_name, str):
1377 raise TypeError(f"Expected user_header_name to be a str but got {type(header_name)}")
1379 user: Final = LiteLLMProxyRequestSetup._get_case_insensitive_header(headers, header_name)
1380 if user is not None:
1381 verbose_logger.info('found user "%s" in header "%s"', user, header_name)
1383 return user
1385 @staticmethod
1386 def get_openai_org_id_from_headers(headers: dict, general_settings: dict | None = None) -> str | None:
1387 """
1388 Get the OpenAI Org ID from the headers.
1389 """
1390 if general_settings is not None and general_settings.get("forward_openai_org_id") is not True: 1390 ↛ 1392line 1390 didn't jump to line 1392 because the condition on line 1390 was always true
1391 return None
1392 for header, value in headers.items():
1393 if header.lower() == "openai-organization":
1394 verbose_logger.info("found openai org id: %s, sending to llm", value)
1395 return value
1396 return None
1398 @staticmethod
1399 def add_headers_to_llm_call(headers: dict, user_api_key_dict: UserAPIKeyAuth) -> dict:
1400 """
1401 Add headers to the LLM call
1403 - Checks request headers for forwardable headers
1404 - Checks if user information should be added to the headers
1405 """
1407 returned_headers: Final = LiteLLMProxyRequestSetup._get_forwardable_headers(headers)
1409 if litellm.add_user_information_to_llm_headers is True:
1410 litellm_logging_metadata_headers: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
1411 user_api_key_dict=user_api_key_dict
1412 )
1413 for k, v in litellm_logging_metadata_headers.items():
1414 if v is None:
1415 continue
1416 # httpx requires header values to be str or bytes; coerce numbers/bools
1417 # to str and JSON-encode dict/list (e.g. user_api_key_spend is float,
1418 # user_api_key_auth_metadata is dict). See #27458.
1419 if isinstance(v, (dict, list)):
1420 returned_headers[f"x-litellm-{k}"] = json.dumps(v)
1421 elif isinstance(v, (str, bytes)):
1422 returned_headers[f"x-litellm-{k}"] = v
1423 else:
1424 returned_headers[f"x-litellm-{k}"] = str(v)
1426 return returned_headers
1428 @staticmethod
1429 def add_headers_to_llm_call_by_model_group(data: dict, headers: dict, user_api_key_dict: UserAPIKeyAuth) -> dict:
1430 """
1431 Add headers to the LLM call by model group
1432 """
1433 from litellm.proxy.auth.auth_checks import _check_model_access_helper
1434 from litellm.proxy.proxy_server import llm_router
1436 data_model: Final = data.get("model")
1438 if ( 1438 ↛ 1450line 1438 didn't jump to line 1450 because the condition on line 1438 was never true
1439 data_model is not None
1440 and litellm.model_group_settings is not None
1441 and litellm.model_group_settings.forward_client_headers_to_llm_api is not None
1442 and _check_model_access_helper(
1443 model=data_model,
1444 llm_router=llm_router,
1445 models=litellm.model_group_settings.forward_client_headers_to_llm_api,
1446 team_model_aliases=user_api_key_dict.team_model_aliases,
1447 team_id=user_api_key_dict.team_id,
1448 ) # handles aliases, wildcards, etc.
1449 ):
1450 _headers: Final = LiteLLMProxyRequestSetup.add_headers_to_llm_call(headers, user_api_key_dict)
1451 if _headers != {}:
1452 data["headers"] = _headers
1453 return data
1455 @staticmethod
1456 def get_internal_user_header_from_mapping(user_header_mapping) -> str | None:
1457 if not user_header_mapping:
1458 return None
1459 items: Final = user_header_mapping if isinstance(user_header_mapping, list) else [user_header_mapping]
1460 for item in items:
1461 if not isinstance(item, dict):
1462 continue
1463 role = item.get("litellm_user_role")
1464 header_name = item.get("header_name")
1465 if role is None or not header_name:
1466 continue
1467 if str(role).lower() == str(LitellmUserRoles.INTERNAL_USER).lower():
1468 return header_name
1469 return None
1471 @staticmethod
1472 def add_litellm_data_for_backend_llm_call(
1473 *,
1474 headers: dict,
1475 request_data: Mapping[str, object],
1476 user_api_key_dict: UserAPIKeyAuth,
1477 general_settings: dict[str, Any] | None = None,
1478 ) -> LitellmDataForBackendLLMCall:
1479 """
1480 - Adds user from headers
1481 - Adds forwardable headers
1482 - Adds org id
1483 """
1484 data: Final = LitellmDataForBackendLLMCall()
1486 if general_settings and general_settings.get("forward_client_headers_to_llm_api") is True: 1486 ↛ 1487line 1486 didn't jump to line 1487 because the condition on line 1486 was never true
1487 _headers: Final = LiteLLMProxyRequestSetup.add_headers_to_llm_call(headers, user_api_key_dict)
1488 if _headers != {}:
1489 data["headers"] = _headers
1490 _organization: Final = LiteLLMProxyRequestSetup.get_openai_org_id_from_headers(headers, general_settings)
1491 if _organization is not None: 1491 ↛ 1492line 1491 didn't jump to line 1492 because the condition on line 1491 was never true
1492 data["organization"] = _organization
1494 header_timeout: Final = LiteLLMProxyRequestSetup._get_timeout_from_request(headers)
1495 if header_timeout is not None: 1495 ↛ 1496line 1495 didn't jump to line 1496 because the condition on line 1495 was never true
1496 data["timeout"] = header_timeout
1498 header_stream_timeout: Final = LiteLLMProxyRequestSetup._get_stream_timeout_from_request(headers)
1499 if header_stream_timeout is not None: 1499 ↛ 1500line 1499 didn't jump to line 1500 because the condition on line 1499 was never true
1500 data["stream_timeout"] = header_stream_timeout
1502 # Router._get_timeout resolves the effective per-attempt timeout from any of
1503 # kwargs["timeout"], kwargs["request_timeout"], or kwargs["stream_timeout"], and a
1504 # caller can supply any of those via the request body as well as the headers above.
1505 # A deliberately tiny value can force a 408 on every deployment in a fallback chain,
1506 # so this marker (never trusted verbatim from the client; stripped above) must cover
1507 # every source cooldown_handlers._trigger_cooldown_for_failed_deployment needs to
1508 # distinguish from a real deployment health signal.
1509 if (
1510 header_timeout is not None
1511 or header_stream_timeout is not None
1512 or request_data.get("timeout") is not None
1513 or request_data.get("request_timeout") is not None
1514 or request_data.get("stream_timeout") is not None
1515 ):
1516 data["client_side_timeout"] = True
1518 num_retries: Final = LiteLLMProxyRequestSetup._get_num_retries_from_request(headers)
1519 if num_retries is not None: 1519 ↛ 1520line 1519 didn't jump to line 1520 because the condition on line 1519 was never true
1520 data["num_retries"] = num_retries
1522 keepalive_seconds: Final = LiteLLMProxyRequestSetup._get_keepalive_seconds_from_request(headers)
1523 if keepalive_seconds is not None: 1523 ↛ 1524line 1523 didn't jump to line 1524 because the condition on line 1523 was never true
1524 data["keepalive_seconds"] = keepalive_seconds
1526 return data
1528 @staticmethod
1529 def add_litellm_metadata_from_request_headers(
1530 headers: dict,
1531 data: dict,
1532 _metadata_variable_name: str,
1533 ) -> dict:
1534 """
1535 Add litellm metadata from request headers
1537 Relevant issue: https://github.com/BerriAI/litellm/issues/14008
1538 """
1539 from litellm.proxy._types import LitellmMetadataFromRequestHeaders
1541 metadata_from_headers: Final = LitellmMetadataFromRequestHeaders()
1542 spend_logs_metadata: Final = LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers)
1543 if spend_logs_metadata is not None: 1543 ↛ 1544line 1543 didn't jump to line 1544 because the condition on line 1543 was never true
1544 metadata_from_headers["spend_logs_metadata"] = spend_logs_metadata
1546 #########################################################################################
1547 # Finally update the requests metadata with the `metadata_from_headers`
1548 #########################################################################################
1550 agent_id_from_header: Final = headers.get("x-litellm-agent-id")
1551 # Explicit litellm headers take precedence; fall back to any x-*-session-id header.
1552 chain_id: Final = get_chain_id_from_headers(dict(headers))
1554 if agent_id_from_header: 1554 ↛ 1555line 1554 didn't jump to line 1555 because the condition on line 1554 was never true
1555 metadata_from_headers["agent_id"] = agent_id_from_header
1556 verbose_proxy_logger.debug("Extracted agent_id from header: %s", agent_id_from_header)
1558 if chain_id: 1558 ↛ 1559line 1558 didn't jump to line 1559 because the condition on line 1558 was never true
1559 metadata_from_headers["trace_id"] = chain_id
1560 metadata_from_headers["session_id"] = chain_id
1561 data["litellm_session_id"] = chain_id
1562 data["litellm_trace_id"] = chain_id
1563 verbose_proxy_logger.debug("Extracted chain_id from header (trace-id/session-id): %s", chain_id)
1564 else:
1565 body_metadata: Final = data.get("metadata")
1566 session_id: Final = _get_anthropic_session_id_from_metadata(body_metadata)
1567 if session_id: 1567 ↛ 1568line 1567 didn't jump to line 1568 because the condition on line 1567 was never true
1568 metadata_from_headers["session_id"] = session_id
1569 data["litellm_session_id"] = session_id
1570 if isinstance(body_metadata, dict) and isinstance(body_metadata.get("user_id"), dict):
1571 body_metadata["user_id"] = session_id
1572 verbose_proxy_logger.debug("Extracted session_id from Anthropic metadata.user_id")
1574 # Last-resort fallback: the W3C standards for trace/session propagation
1575 # (https://www.w3.org/TR/trace-context/, https://www.w3.org/TR/baggage/).
1576 # Lower priority than everything above - only fires when neither the
1577 # explicit litellm headers nor the Anthropic-metadata path found
1578 # anything - but lets a caller's existing traceparent/baggage headers
1579 # (from real OTel instrumentation) correlate with litellm's own logs
1580 # instead of generating an unrelated trace_id.
1581 normalized_headers: Final = MappingProxyType({k.lower(): v for k, v in headers.items() if isinstance(k, str)})
1582 if "litellm_trace_id" not in data: 1582 ↛ 1592line 1582 didn't jump to line 1592 because the condition on line 1582 was always true
1583 traceparent: Final = normalized_headers.get("traceparent")
1584 if isinstance(traceparent, str): 1584 ↛ 1585line 1584 didn't jump to line 1585 because the condition on line 1584 was never true
1585 trace_id_from_traceparent: Final = _trace_id_from_traceparent(traceparent)
1586 if trace_id_from_traceparent:
1587 metadata_from_headers["trace_id"] = trace_id_from_traceparent
1588 data["litellm_trace_id"] = trace_id_from_traceparent # rebind-ok: data is an out-param
1589 verbose_proxy_logger.debug(
1590 "Extracted trace_id from W3C traceparent header: %s", trace_id_from_traceparent
1591 )
1592 if "litellm_session_id" not in data: 1592 ↛ 1601line 1592 didn't jump to line 1601 because the condition on line 1592 was always true
1593 baggage: Final = normalized_headers.get("baggage")
1594 if isinstance(baggage, str): 1594 ↛ 1595line 1594 didn't jump to line 1595 because the condition on line 1594 was never true
1595 session_id_from_baggage: Final = _session_id_from_baggage(baggage)
1596 if session_id_from_baggage:
1597 metadata_from_headers["session_id"] = session_id_from_baggage
1598 data["litellm_session_id"] = session_id_from_baggage # rebind-ok: data is an out-param
1599 verbose_proxy_logger.debug("Extracted session_id from W3C baggage header")
1601 if isinstance(data[_metadata_variable_name], dict): 1601 ↛ 1603line 1601 didn't jump to line 1603 because the condition on line 1601 was always true
1602 data[_metadata_variable_name].update(metadata_from_headers)
1603 return data
1605 @staticmethod
1606 def get_logged_api_key(user_api_key_dict: UserAPIKeyAuth) -> str | None:
1607 if user_api_key_dict.is_session_token and user_api_key_dict.key_alias: 1607 ↛ 1608line 1607 didn't jump to line 1608 because the condition on line 1607 was never true
1608 return user_api_key_dict.key_alias
1609 return user_api_key_dict.api_key
1611 @staticmethod
1612 def get_sanitized_user_information_from_key(
1613 user_api_key_dict: UserAPIKeyAuth,
1614 ) -> StandardLoggingUserAPIKeyMetadata:
1615 stripped_metadata: Final = strip_callback_config(user_api_key_dict.metadata)
1616 auth_metadata: Final = cast("dict[str, str] | None", stripped_metadata) # cast-ok: metadata is free-form JSON
1617 user_api_key_logged_metadata: Final = StandardLoggingUserAPIKeyMetadata(
1618 user_api_key_hash=LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict),
1619 user_api_key_alias=user_api_key_dict.key_alias,
1620 user_api_key_spend=user_api_key_dict.spend,
1621 user_api_key_max_budget=user_api_key_dict.max_budget,
1622 user_api_key_user_spend=user_api_key_dict.user_spend,
1623 user_api_key_user_max_budget=user_api_key_dict.user_max_budget,
1624 user_api_key_team_spend=user_api_key_dict.team_spend,
1625 user_api_key_team_max_budget=user_api_key_dict.team_max_budget,
1626 user_api_key_team_id=user_api_key_dict.team_id,
1627 user_api_key_project_id=user_api_key_dict.project_id,
1628 user_api_key_project_alias=user_api_key_dict.project_alias,
1629 user_api_key_user_id=user_api_key_dict.user_id,
1630 user_api_key_org_id=user_api_key_dict.org_id,
1631 user_api_key_org_alias=user_api_key_dict.organization_alias,
1632 user_api_key_team_alias=user_api_key_dict.team_alias,
1633 user_api_key_end_user_id=user_api_key_dict.end_user_id,
1634 user_api_key_user_email=user_api_key_dict.user_email,
1635 user_api_key_request_route=user_api_key_dict.request_route,
1636 user_api_key_budget_reset_at=(
1637 user_api_key_dict.budget_reset_at.isoformat() if user_api_key_dict.budget_reset_at else None
1638 ),
1639 user_api_key_auth_metadata=auth_metadata,
1640 )
1641 return user_api_key_logged_metadata
1643 @staticmethod
1644 def add_user_api_key_auth_to_request_metadata(
1645 data: dict,
1646 user_api_key_dict: UserAPIKeyAuth,
1647 _metadata_variable_name: str,
1648 ) -> dict:
1649 """
1650 Adds the `UserAPIKeyAuth` object to the request metadata.
1651 """
1652 user_api_key_logged_metadata: Final = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
1653 user_api_key_dict=user_api_key_dict
1654 )
1655 data[_metadata_variable_name].update(user_api_key_logged_metadata)
1656 data[_metadata_variable_name]["user_api_key"] = LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict)
1658 # Key-owned agent_id for spend attribution; keep existing (e.g. from header) if key has none
1659 _key_agent_id: Final = getattr(user_api_key_dict, "agent_id", None)
1660 _existing_agent_id: Final = data[_metadata_variable_name].get("agent_id")
1661 _resolved_agent_id: Final = _key_agent_id or _existing_agent_id
1662 data[_metadata_variable_name]["agent_id"] = _resolved_agent_id
1664 data[_metadata_variable_name]["user_api_end_user_max_budget"] = getattr(
1665 user_api_key_dict, "end_user_max_budget", None
1666 )
1667 if user_api_key_dict.budget_reservation is not None: 1667 ↛ 1668line 1667 didn't jump to line 1668 because the condition on line 1667 was never true
1668 data[_metadata_variable_name]["user_api_key_budget_reservation"] = user_api_key_dict.budget_reservation
1669 if user_api_key_dict.matched_model_access_groups: 1669 ↛ 1670line 1669 didn't jump to line 1670 because the condition on line 1669 was never true
1670 data[_metadata_variable_name][MODEL_ACCESS_GROUP_METADATA_KEY] = (
1671 user_api_key_dict.matched_model_access_groups
1672 )
1673 # UserAPIKeyAuth object for MCP server access control
1674 data[_metadata_variable_name]["user_api_key_auth"] = user_api_key_dict.model_copy(
1675 update={
1676 "metadata": strip_callback_config(user_api_key_dict.metadata),
1677 "team_metadata": strip_callback_config(user_api_key_dict.team_metadata),
1678 "project_metadata": strip_callback_config(user_api_key_dict.project_metadata),
1679 "organization_metadata": strip_callback_config(user_api_key_dict.organization_metadata),
1680 }
1681 )
1682 return data
1684 @staticmethod
1685 def add_management_endpoint_metadata_to_request_metadata(
1686 data: dict,
1687 management_endpoint_metadata: dict,
1688 _metadata_variable_name: str,
1689 ) -> dict:
1690 """
1691 Adds the `UserAPIKeyAuth` metadata to the request metadata.
1693 ignore any sensitive fields like logging, api_key, etc.
1694 """
1695 if _metadata_variable_name not in data: 1695 ↛ 1696line 1695 didn't jump to line 1696 because the condition on line 1695 was never true
1696 return data
1697 from litellm.proxy._types import (
1698 LiteLLM_ManagementEndpoint_MetadataFields,
1699 LiteLLM_ManagementEndpoint_MetadataFields_Premium,
1700 )
1702 # ignore any special fields
1703 added_metadata: Final = {
1704 k: v
1705 for k, v in (strip_callback_config(management_endpoint_metadata) or {}).items()
1706 if k not in (LiteLLM_ManagementEndpoint_MetadataFields_Premium + LiteLLM_ManagementEndpoint_MetadataFields)
1707 }
1708 if data[_metadata_variable_name].get("user_api_key_auth_metadata") is None: 1708 ↛ 1709line 1708 didn't jump to line 1709 because the condition on line 1708 was never true
1709 data[_metadata_variable_name]["user_api_key_auth_metadata"] = {}
1710 data[_metadata_variable_name]["user_api_key_auth_metadata"].update(added_metadata)
1711 return data
1713 @staticmethod
1714 def add_key_level_controls(key_metadata: dict | None, data: dict, _metadata_variable_name: str):
1715 if key_metadata is None: 1715 ↛ 1716line 1715 didn't jump to line 1716 because the condition on line 1715 was never true
1716 return data
1717 if "cache" in key_metadata: 1717 ↛ 1718line 1717 didn't jump to line 1718 because the condition on line 1717 was never true
1718 data["cache"] = {}
1719 if isinstance(key_metadata["cache"], dict):
1720 for k, v in key_metadata["cache"].items():
1721 if k in SupportedCacheControls:
1722 data["cache"][k] = v
1724 ## KEY-LEVEL SPEND LOGS / TAGS
1725 if "tags" in key_metadata and key_metadata["tags"] is not None: 1725 ↛ 1726line 1725 didn't jump to line 1726 because the condition on line 1725 was never true
1726 data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags(
1727 request_tags=data[_metadata_variable_name].get("tags"),
1728 tags_to_add=key_metadata["tags"],
1729 )
1730 if "disable_global_guardrails" in key_metadata and isinstance(key_metadata["disable_global_guardrails"], bool): 1730 ↛ 1731line 1730 didn't jump to line 1731 because the condition on line 1730 was never true
1731 data[_metadata_variable_name]["disable_global_guardrails"] = key_metadata["disable_global_guardrails"]
1732 if "spend_logs_metadata" in key_metadata and isinstance(key_metadata["spend_logs_metadata"], dict): 1732 ↛ 1733line 1732 didn't jump to line 1733 because the condition on line 1732 was never true
1733 if "spend_logs_metadata" in data[_metadata_variable_name] and isinstance(
1734 data[_metadata_variable_name]["spend_logs_metadata"], dict
1735 ):
1736 for key, value in key_metadata["spend_logs_metadata"].items():
1737 if (
1738 key not in data[_metadata_variable_name]["spend_logs_metadata"]
1739 ): # don't override k-v pair sent by request (user request)
1740 data[_metadata_variable_name]["spend_logs_metadata"][key] = value
1741 else:
1742 data[_metadata_variable_name]["spend_logs_metadata"] = key_metadata["spend_logs_metadata"]
1744 ## KEY-LEVEL DISABLE FALLBACKS
1745 if "disable_fallbacks" in key_metadata and isinstance(key_metadata["disable_fallbacks"], bool): 1745 ↛ 1746line 1745 didn't jump to line 1746 because the condition on line 1745 was never true
1746 data["disable_fallbacks"] = key_metadata["disable_fallbacks"]
1748 if isinstance(key_metadata.get("enable_prompt_caching"), bool): 1748 ↛ 1749line 1748 didn't jump to line 1749 because the condition on line 1748 was never true
1749 data["enable_prompt_caching"] = key_metadata["enable_prompt_caching"] # rebind-ok: data is an out-param
1751 ## KEY-LEVEL METADATA
1752 data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
1753 data=data,
1754 management_endpoint_metadata=key_metadata,
1755 _metadata_variable_name=_metadata_variable_name,
1756 )
1757 return data
1759 @staticmethod
1760 def _merge_tags(request_tags: list | None, tags_to_add: list | None) -> list:
1761 """
1762 Helper function to merge two lists of tags, ensuring no duplicates.
1764 Args:
1765 request_tags (Optional[list]): List of tags from the original request
1766 tags_to_add (Optional[list]): List of tags to add
1768 Returns:
1769 list: Combined list of unique tags
1770 """
1771 final_tags: Final = []
1773 if request_tags and isinstance(request_tags, list):
1774 final_tags.extend(request_tags)
1776 if tags_to_add and isinstance(tags_to_add, list):
1777 for tag in tags_to_add:
1778 if tag not in final_tags:
1779 final_tags.append(tag)
1781 return final_tags
1783 @staticmethod
1784 def add_team_based_callbacks_from_config(
1785 team_id: str,
1786 proxy_config: ProxyConfig,
1787 ) -> TeamCallbackMetadata | None:
1788 """
1789 Add team-based callbacks from the config
1790 """
1791 team_config: Final = proxy_config.load_team_config(team_id=team_id)
1792 if not isinstance(team_config, dict) or len(team_config) == 0:
1793 return None
1795 callback_vars_dict = {**team_config.get("callback_vars", team_config)}
1796 callback_vars_dict.pop("team_id", None)
1797 callback_vars_dict.pop("success_callback", None)
1798 callback_vars_dict.pop("failure_callback", None)
1799 callback_vars_dict = {
1800 key: (
1801 litellm.utils.get_secret(value, default_value=value) or value if isinstance(value, str) else str(value)
1802 )
1803 for key, value in callback_vars_dict.items()
1804 }
1806 return TeamCallbackMetadata(
1807 success_callback=team_config.get("success_callback", None),
1808 failure_callback=team_config.get("failure_callback", None),
1809 callback_vars=callback_vars_dict,
1810 )
1812 @staticmethod
1813 def add_request_tag_to_metadata(
1814 llm_router: Router | None,
1815 headers: dict,
1816 data: dict,
1817 ) -> list[str] | None:
1818 tags = None
1820 # Check request headers for tags
1821 if "x-litellm-tags" in headers: 1821 ↛ 1822line 1821 didn't jump to line 1822 because the condition on line 1821 was never true
1822 if isinstance(headers["x-litellm-tags"], str):
1823 _tags: Final = headers["x-litellm-tags"].split(",")
1824 tags = [tag.strip() for tag in _tags]
1825 elif isinstance(headers["x-litellm-tags"], list):
1826 tags = headers["x-litellm-tags"]
1827 # Check request body for tags
1828 if "tags" in data and isinstance(data["tags"], list): 1828 ↛ 1829line 1828 didn't jump to line 1829 because the condition on line 1828 was never true
1829 tags = data["tags"]
1831 return tags
1833 @staticmethod
1834 def pre_seed_litellm_metadata_for_route(
1835 request_data: dict,
1836 route: str,
1837 ) -> None:
1838 """Pre-seed ``litellm_metadata`` for routes that track tags there.
1840 Routes in ``LITELLM_METADATA_ROUTES`` (e.g. Bedrock, ``/v1/messages``,
1841 responses, batches, files) store request-scoped tag metadata in
1842 ``litellm_metadata`` rather than the provider-facing ``metadata``
1843 field. ``get_metadata_variable_name_from_kwargs`` picks the target
1844 based on whether ``litellm_metadata`` is present, so it must be
1845 seeded BEFORE any tag merge runs; otherwise header tags from
1846 ``apply_client_tag_policy_pre_auth`` land in ``metadata`` while
1847 key tags from ``apply_key_tags_pre_auth`` and the read in
1848 ``_tag_max_budget_check`` resolve to ``litellm_metadata``, leaving
1849 header tags invisible to per-tag budget enforcement.
1850 """
1851 if any(metadata_route in route for metadata_route in LITELLM_METADATA_ROUTES):
1852 request_data.setdefault("litellm_metadata", {})
1854 @staticmethod
1855 def apply_key_tags_pre_auth(
1856 request_data: dict,
1857 user_api_key_dict: UserAPIKeyAuth,
1858 ) -> None:
1859 """Merge key metadata tags into request_data before _tag_max_budget_check."""
1860 key_metadata: Final = user_api_key_dict.metadata
1861 if not key_metadata:
1862 return
1864 key_tags: Final = key_metadata.get("tags")
1865 if not key_tags or not isinstance(key_tags, list): 1865 ↛ 1868line 1865 didn't jump to line 1868 because the condition on line 1865 was always true
1866 return
1868 _metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
1869 metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
1871 existing_tags: Final = metadata.get("tags")
1872 metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags(
1873 request_tags=existing_tags if isinstance(existing_tags, list) else None,
1874 tags_to_add=key_tags,
1875 )
1877 @staticmethod
1878 def apply_client_tag_policy_pre_auth(
1879 request: Request,
1880 request_data: dict,
1881 user_api_key_dict: UserAPIKeyAuth,
1882 ) -> None:
1883 """
1884 Merge ``x-litellm-tags`` header tags into ``request_data`` BEFORE
1885 auth budget gates run, so ``_tag_max_budget_check`` (which only
1886 inspects ``request_data``) sees them. Without this, header-tagged
1887 requests silently bypass per-tag budget enforcement.
1889 Why: ``add_litellm_data_to_request`` runs the equivalent merge
1890 post-auth, after ``_tag_max_budget_check`` has already executed.
1891 Header-supplied tags merged there are invisible to that check.
1892 Running the merge here closes that gap; the post-auth merge in
1893 ``add_litellm_data_to_request`` remains as defense-in-depth.
1895 How to apply: invoked from the auth chain just before
1896 ``common_checks``. Mutates ``request_data`` in place; idempotent
1897 when followed by ``add_litellm_data_to_request``.
1898 """
1899 # No allow_client_tags opt-in: caller-supplied tags always flow
1900 # into metadata.tags (see add_litellm_data_to_request). The pre-auth
1901 # merge mirrors that so _tag_max_budget_check sees the same tags.
1902 headers: Final = _safe_get_request_headers(request=request)
1903 raw_header_tags: Final = headers.get("x-litellm-tags")
1904 if not raw_header_tags: 1904 ↛ 1907line 1904 didn't jump to line 1907 because the condition on line 1904 was always true
1905 return
1907 if isinstance(raw_header_tags, str):
1908 header_tags: list[str] = [t.strip() for t in raw_header_tags.split(",") if t.strip()]
1909 elif isinstance(raw_header_tags, list):
1910 header_tags = [t for t in raw_header_tags if isinstance(t, str) and t]
1911 else:
1912 return
1914 if not header_tags:
1915 return
1917 # Match the metadata key that get_tags_from_request_body will read
1918 # from (litellm_metadata vs metadata) so the merged tags are visible
1919 # to _tag_max_budget_check.
1920 _metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
1921 metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
1923 existing_tags: Final = metadata.get("tags")
1924 metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags(
1925 request_tags=existing_tags if isinstance(existing_tags, list) else None,
1926 tags_to_add=header_tags,
1927 )
1930def refresh_proxy_server_request_body_snapshot(
1931 data: MutableMapping[str, object],
1932 *,
1933 guardrails_applied: bool = False,
1934) -> None:
1935 """
1936 Re-snapshot ``data["proxy_server_request"]["body"]`` from the current state of ``data``.
1938 ``add_litellm_data_to_request`` takes the initial snapshot before guardrails
1939 (pre_call_hook) run. A guardrail that masks PII/PCI in place (e.g. Presidio)
1940 mutates ``data`` afterward, so callers that persist ``proxy_server_request.body``
1941 for audit/spend-tracking purposes must call this again post-guardrail, or the
1942 persisted body silently bypasses whatever masking the guardrail applied.
1944 By the time a caller refreshes post-guardrail, ``litellm.utils.function_setup``
1945 has already stamped ``data["litellm_logging_obj"]`` with a live (non-serializable)
1946 ``Logging`` instance, so it must be excluded here the same way ``secret_fields``
1947 and ``proxy_server_request`` are.
1948 """
1949 from litellm.integrations.shadow_eval_logger import GuardrailRequestSnapshot
1950 from litellm.litellm_core_utils.litellm_logging import Logging
1952 logging_obj: Final = data.get("litellm_logging_obj")
1953 if isinstance(logging_obj, Logging):
1954 logging_obj.shadow_eval_request_snapshot = None
1955 proxy_server_request: Final = data.get("proxy_server_request")
1956 if not isinstance(proxy_server_request, dict): 1956 ↛ 1957line 1956 didn't jump to line 1957 because the condition on line 1956 was never true
1957 return
1958 _body_snapshot_exclude: Final = (
1959 frozenset({"secret_fields", "proxy_server_request", "litellm_logging_obj"}) | _TRANSPORT_ONLY_CREDENTIAL_KEYS
1960 )
1961 body: Final = { # mutable-ok: audit JSON serialization requires a dict with shared nested messages
1962 k: v for k, v in data.items() if k not in _body_snapshot_exclude
1963 }
1964 proxy_server_request["body"] = body
1965 if guardrails_applied and isinstance(logging_obj, Logging):
1966 metadata: Final = data.get(get_metadata_variable_name_from_kwargs(data))
1967 logging_obj.shadow_eval_request_snapshot = GuardrailRequestSnapshot.capture(
1968 body, metadata if isinstance(metadata, Mapping) else MappingProxyType({})
1969 )
1972async def add_litellm_data_to_request(
1973 data: dict,
1974 request: Request,
1975 user_api_key_dict: UserAPIKeyAuth,
1976 proxy_config: ProxyConfig,
1977 general_settings: dict[str, Any] | None = None,
1978 version: str | None = None,
1979):
1980 """
1981 Adds LiteLLM-specific data to the request.
1983 Args:
1984 data (dict): The data dictionary to be modified.
1985 request (Request): The incoming request.
1986 user_api_key_dict (UserAPIKeyAuth): The user API key dictionary.
1987 general_settings (Optional[Dict[str, Any]], optional): General settings. Defaults to None.
1988 version (Optional[str], optional): Version. Defaults to None.
1990 Returns:
1991 dict: The modified data dictionary.
1993 """
1995 from litellm.proxy.proxy_server import llm_router, premium_user
1996 from litellm.types.proxy.litellm_pre_call_utils import RedactedDict, SecretFields
1998 # Strip internal-only keys from user input before the proxy sets its own.
1999 # These keys are injected by the proxy itself below — user-supplied values
2000 # must not be trusted.
2001 _allow_client_mock_response: Final = _key_or_team_allows_client_mock_response(user_api_key_dict)
2002 _allow_client_message_redaction_opt_out = _key_or_team_allows_client_message_redaction_opt_out(user_api_key_dict)
2003 for _internal_key in _UNTRUSTED_ROOT_CONTROL_FIELDS:
2004 if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS: 2004 ↛ 2005line 2004 didn't jump to line 2005 because the condition on line 2004 was never true
2005 continue
2006 data.pop(_internal_key, None)
2007 _reject_url_valued_destinations(data)
2008 _raw_metadata_by_field: Final = {
2009 _metadata_field: data.pop(_metadata_field)
2010 for _metadata_field in ("metadata", "litellm_metadata")
2011 if data.get(_metadata_field) is not None
2012 }
2013 for _metadata_field, _raw_metadata in _raw_metadata_by_field.items():
2014 data[_metadata_field] = _normalized_metadata_object(_metadata_field, _raw_metadata)
2015 # Strip spoofable auth metadata from user-supplied metadata dict
2016 _user_metadata = data.get("metadata")
2017 if isinstance(_user_metadata, dict):
2018 for _mk in list(_user_metadata.keys()):
2019 if _mk.startswith("user_api_key_"):
2020 del _user_metadata[_mk]
2022 _raw_headers: Final[dict[str, str]] = RedactedDict(_safe_get_request_headers(request))
2024 forward_llm_auth = False
2025 if general_settings: 2025 ↛ 2027line 2025 didn't jump to line 2027 because the condition on line 2025 was always true
2026 forward_llm_auth = general_settings.get("forward_llm_provider_auth_headers", False)
2027 if not forward_llm_auth: 2027 ↛ 2031line 2027 didn't jump to line 2031 because the condition on line 2027 was always true
2028 forward_llm_auth = getattr(litellm, "forward_llm_provider_auth_headers", False)
2029 # Determine which header was used for authentication
2030 # This enables forwarding provider keys (e.g., x-api-key) when they weren't used for LiteLLM auth
2031 authenticated_with_header = None
2032 if "x-litellm-api-key" in request.headers:
2033 # If x-litellm-api-key is present, it was used for auth
2034 authenticated_with_header = "x-litellm-api-key"
2035 elif "authorization" in request.headers: 2035 ↛ 2037line 2035 didn't jump to line 2037 because the condition on line 2035 was never true
2036 # Authorization header was used for auth
2037 authenticated_with_header = "authorization"
2038 else:
2039 # x-api-key or another header was used for auth
2040 authenticated_with_header = "x-api-key"
2042 _headers: Final[dict[str, str]] = clean_headers(
2043 request.headers,
2044 litellm_key_header_name=(
2045 general_settings.get("litellm_key_header_name") if general_settings is not None else None
2046 ),
2047 forward_llm_provider_auth_headers=forward_llm_auth,
2048 authenticated_with_header=authenticated_with_header,
2049 )
2050 _strip_untrusted_request_header_controls(
2051 _headers,
2052 allow_client_message_redaction_opt_out=_allow_client_message_redaction_opt_out,
2053 )
2054 from litellm.proxy._experimental.mcp_server.utils import upstream_credential_headers
2056 _mcp_credential_headers: Final = upstream_credential_headers(_headers)
2057 _logging_safe_headers: Final = redact_credential_headers(
2058 MappingProxyType(
2059 {name: value for name, value in _headers.items() if name.lower() not in _mcp_credential_headers}
2060 )
2061 )
2062 verbose_proxy_logger.debug("Request Headers: %s", _logging_safe_headers)
2063 verbose_proxy_logger.debug("Raw Headers: %s", _raw_headers)
2065 if forward_llm_auth and "x-api-key" in _headers: 2065 ↛ 2066line 2065 didn't jump to line 2066 because the condition on line 2065 was never true
2066 data["api_key"] = _headers["x-api-key"]
2067 verbose_proxy_logger.debug(
2068 "Setting client-provided x-api-key as api_key parameter (will override deployment key)"
2069 )
2071 ##########################################################
2072 # Init - Proxy Server Request
2073 # we do this as soon as entering so we track the original request
2074 ##########################################################
2075 # Track arrival time for queue time metric. Prefer the timestamp stamped at
2076 # the top of user_api_key_auth (request.state.litellm_received_at): by the
2077 # time this function runs, auth has already completed, so time.time() here
2078 # would silently exclude the entire auth phase from the queue-time window.
2079 # Falls back to time.time() for callers that never went through
2080 # user_api_key_auth. The body snapshot is filled in after the
2081 # admin-injection strip below so the audit / spend-tracking consumers of
2082 # proxy_server_request["body"] see the cleaned metadata rather than
2083 # attacker-forged user_api_key_* fields.
2084 _litellm_received_at: Final[datetime | None] = getattr(request.state, "litellm_received_at", None)
2085 arrival_time: Final = _litellm_received_at.timestamp() if _litellm_received_at is not None else time.time()
2086 data["proxy_server_request"] = {
2087 "url": str(request.url),
2088 "method": request.method,
2089 "headers": _logging_safe_headers,
2090 "body": None, # filled in post-strip; see below
2091 "credential_fields": tuple(sorted(name for name in _TRANSPORT_ONLY_CREDENTIAL_KEYS if name in data)),
2092 "arrival_time": arrival_time, # Track when request arrived at proxy
2093 }
2095 safe_add_api_version_from_query_params(data, request)
2096 _metadata_variable_name: Final = _get_metadata_variable_name(request)
2097 if data.get(_metadata_variable_name, None) is None:
2098 data[_metadata_variable_name] = {}
2100 data.update(
2101 LiteLLMProxyRequestSetup.add_litellm_data_for_backend_llm_call(
2102 headers=_headers,
2103 request_data=data,
2104 user_api_key_dict=user_api_key_dict,
2105 general_settings=general_settings,
2106 )
2107 )
2109 LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
2110 headers=_headers,
2111 data=data,
2112 _metadata_variable_name=_metadata_variable_name,
2113 )
2114 add_otel_trace_id_to_request(
2115 data=data,
2116 _metadata_variable_name=_metadata_variable_name,
2117 parent_otel_span=user_api_key_dict.parent_otel_span
2118 if user_api_key_dict.parent_otel_span is not None
2119 else getattr(request.state, "parent_otel_span", None),
2120 )
2121 apply_missing_session_id_policy(
2122 data=data,
2123 _metadata_variable_name=_metadata_variable_name,
2124 general_settings=general_settings,
2125 request=request,
2126 )
2128 # Expose request headers under the metadata field for guardrails (fixes #17477)
2129 if _metadata_variable_name in data and isinstance(data[_metadata_variable_name], dict): 2129 ↛ 2133line 2129 didn't jump to line 2133 because the condition on line 2129 was always true
2130 data[_metadata_variable_name]["headers"] = _logging_safe_headers
2132 # check for forwardable headers
2133 data = LiteLLMProxyRequestSetup.add_headers_to_llm_call_by_model_group(
2134 data=data, headers=_headers, user_api_key_dict=user_api_key_dict
2135 )
2137 user_api_key_dict = LiteLLMProxyRequestSetup.add_internal_user_from_user_mapping(
2138 general_settings, user_api_key_dict, _headers
2139 )
2141 # Parse user info from headers (fallback to general_settings.user_header_name)
2142 user: Final = LiteLLMProxyRequestSetup.get_user_from_headers(_headers, general_settings)
2143 if user is not None: 2143 ↛ 2144line 2143 didn't jump to line 2144 because the condition on line 2143 was never true
2144 if user_api_key_dict.end_user_id is None:
2145 user_api_key_dict.end_user_id = user
2146 if "user" not in data:
2147 data["user"] = user
2149 if litellm.overwrite_user_with_key_hash is True: 2149 ↛ 2150line 2149 didn't jump to line 2150 because the condition on line 2149 was never true
2150 stampable_hash: Final = _stampable_key_hash(user_api_key_dict)
2151 if stampable_hash is not None:
2152 data["user"] = stampable_hash
2154 data["secret_fields"] = SecretFields(raw_headers=_raw_headers)
2156 ## Dynamic api version (Azure OpenAI endpoints) ##
2157 try:
2158 query_params: Final = request.query_params
2159 # Convert query parameters to a dictionary (optional)
2160 query_dict = dict(query_params)
2161 except KeyError:
2162 query_dict = {}
2164 ## check for api version in query params
2165 dynamic_api_version: Final[str | None] = query_dict.get("api-version")
2167 if dynamic_api_version is not None: # only pass, if set 2167 ↛ 2168line 2167 didn't jump to line 2168 because the condition on line 2167 was never true
2168 data["api_version"] = dynamic_api_version
2170 ## Forward any LLM API Provider specific headers in extra_headers
2171 add_provider_specific_headers_to_request(data=data, headers=_headers)
2173 ## Cache Controls
2174 cache_control_header: Final = _headers.get("Cache-Control", None)
2175 if cache_control_header: 2175 ↛ 2176line 2175 didn't jump to line 2176 because the condition on line 2175 was never true
2176 cache_dict: Final = parse_cache_control(cache_control_header)
2177 data["ttl"] = cache_dict.get("s-maxage")
2179 # requester_metadata is snapshotted AFTER the strip below so
2180 # downstream consumers (e.g. PANW guardrail reading user_ip /
2181 # profile_id) don't see attacker-injected admin slots preserved in
2182 # the deepcopy.
2184 # Strip internal pipeline state and admin-injection slots from user input.
2185 # Runs AFTER the string-to-dict parse above so JSON-string metadata (sent
2186 # via multipart/form-data or extra_body) cannot smuggle admin fields past
2187 # the isinstance(dict) guard.
2188 #
2189 # The proxy populates a family of ``user_api_key_*`` fields below
2190 # (user_api_key_metadata, user_api_key_user_id, user_api_key_alias,
2191 # user_api_key_spend, user_api_key_team_metadata, …) into
2192 # data[_metadata_variable_name]. Because the proxy only writes to ONE of
2193 # the two metadata dicts, a caller pre-populating any of these keys on
2194 # the OTHER metadata dict would have their forged values surface in
2195 # guardrails, spend tracking, audit logs, and identity resolution. Strip
2196 # by prefix so new ``user_api_key_*`` fields added in the future are
2197 # covered without per-key maintenance.
2198 for _meta_key in ("metadata", "litellm_metadata"):
2199 _user_meta = data.get(_meta_key)
2200 if isinstance(_user_meta, dict):
2201 _strip_untrusted_request_header_controls(
2202 _user_meta.get("headers"),
2203 allow_client_message_redaction_opt_out=(_allow_client_message_redaction_opt_out),
2204 )
2205 for _k in [
2206 k for k in _user_meta if k.startswith("user_api_key_") or k in _UNTRUSTED_METADATA_CONTROL_FIELDS
2207 ]:
2208 _user_meta.pop(_k, None)
2210 # Strip pricing overrides AFTER the litellm_metadata string-to-dict parse
2211 # above, for the same reason as the user_api_key_* strip — JSON-string
2212 # metadata (sent via multipart/form-data or extra_body) wouldn't be a
2213 # dict yet at the earlier strip point and the isinstance(dict) guard
2214 # would silently skip the field.
2215 if not _key_or_team_allows_client_pricing_override(user_api_key_dict): 2215 ↛ 2217line 2215 didn't jump to line 2217 because the condition on line 2215 was always true
2216 _strip_client_pricing_overrides(data)
2217 _strip_router_reserved_metadata(data)
2219 # Same reason as the strips above: runs after the metadata string-to-dict parse
2220 # so JSON-string metadata cannot smuggle callback credentials past the dict guard.
2221 _strip_client_callback_credentials(data)
2223 if not _allow_client_message_redaction_opt_out and litellm.turn_off_message_logging is True: 2223 ↛ 2224line 2223 didn't jump to line 2224 because the condition on line 2223 was never true
2224 _strip_client_message_redaction_opt_out(data)
2226 # Fill in the proxy_server_request body snapshot now that metadata has
2227 # been parsed. Consumers (standard_logging_payload, lago,
2228 # spend_tracking_utils, streaming_iterator) read `body` to audit the
2229 # request; taking the snapshot here ensures they see cleaned metadata.
2230 #
2231 # Exclude:
2232 # - secret_fields: contains raw_headers with Authorization tokens; must
2233 # never be persisted in spend logs or any other audit trail.
2234 # - proxy_server_request: already a key on `data` at this point (set
2235 # earlier in this function); including it would make the snapshot
2236 # self-reference — body.proxy_server_request.body would be the same
2237 # dict as body, producing an infinite traversal loop for any consumer
2238 # that walks the structure.
2239 refresh_proxy_server_request_body_snapshot(data)
2241 # Snapshot the requester-supplied metadata for downstream consumers.
2242 # Taking the deepcopy after the user_api_key_* / _pipeline_managed_guardrails
2243 # strip above prevents those proxy-internal slots — if a caller forged
2244 # them — from leaking into requester_metadata where guardrails and audit
2245 # paths may read from it.
2246 if "metadata" in data and isinstance(data["metadata"], dict):
2247 data[_metadata_variable_name]["requester_metadata"] = copy.deepcopy(data["metadata"])
2248 if _metadata_variable_name == "litellm_metadata": 2248 ↛ 2249line 2248 didn't jump to line 2249 because the condition on line 2248 was never true
2249 data[_metadata_variable_name].update(
2250 _promoted_trace_control_fields(
2251 requester_metadata=data[_metadata_variable_name]["requester_metadata"],
2252 litellm_metadata=data[_metadata_variable_name],
2253 )
2254 )
2256 # Merge litellm_metadata into the metadata variable (preserving existing
2257 # values). Runs after the user_api_key_* / _pipeline_managed_guardrails
2258 # strip above so those proxy-internal slots — if a caller forged them
2259 # into litellm_metadata — cannot cross-contaminate the admin-authoritative
2260 # metadata dict.
2261 if "litellm_metadata" in data and isinstance(data["litellm_metadata"], dict):
2262 for key, value in data["litellm_metadata"].items():
2263 if key not in data[_metadata_variable_name]: 2263 ↛ 2264line 2263 didn't jump to line 2264 because the condition on line 2263 was never true
2264 data[_metadata_variable_name][key] = value
2265 if _metadata_variable_name == "metadata": 2265 ↛ 2266line 2265 didn't jump to line 2266 because the condition on line 2265 was never true
2266 data["metadata"]["tags"] = LiteLLMProxyRequestSetup._merge_tags( # pyright: ignore[reportPrivateUsage] # same-module helper, budget blocks the unsuppressed idiom sibling call sites use
2267 request_tags=data["metadata"].get("tags"),
2268 tags_to_add=data["litellm_metadata"].get("tags"),
2269 )
2270 if _metadata_variable_name == "metadata":
2271 data.pop("litellm_metadata", None)
2273 data = LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
2274 data=data,
2275 user_api_key_dict=user_api_key_dict,
2276 _metadata_variable_name=_metadata_variable_name,
2277 )
2278 data[_metadata_variable_name]["litellm_api_version"] = version
2280 if general_settings is not None: 2280 ↛ 2286line 2280 didn't jump to line 2286 because the condition on line 2280 was always true
2281 data[_metadata_variable_name]["global_max_parallel_requests"] = general_settings.get(
2282 "global_max_parallel_requests", None
2283 )
2285 ### KEY-LEVEL Controls
2286 key_metadata: Final = user_api_key_dict.metadata
2287 data = LiteLLMProxyRequestSetup.add_key_level_controls(
2288 key_metadata=key_metadata,
2289 data=data,
2290 _metadata_variable_name=_metadata_variable_name,
2291 )
2292 ## TEAM-LEVEL SPEND LOGS/TAGS
2293 team_metadata: Final = user_api_key_dict.team_metadata or {}
2294 if "tags" in team_metadata and team_metadata["tags"] is not None: 2294 ↛ 2295line 2294 didn't jump to line 2295 because the condition on line 2294 was never true
2295 data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags(
2296 request_tags=data[_metadata_variable_name].get("tags"),
2297 tags_to_add=team_metadata["tags"],
2298 )
2299 if "disable_global_guardrails" in team_metadata and isinstance(team_metadata["disable_global_guardrails"], bool): 2299 ↛ 2300line 2299 didn't jump to line 2300 because the condition on line 2299 was never true
2300 data[_metadata_variable_name]["disable_global_guardrails"] = team_metadata["disable_global_guardrails"]
2301 if "opted_out_global_guardrails" in team_metadata and isinstance( 2301 ↛ 2304line 2301 didn't jump to line 2304 because the condition on line 2301 was never true
2302 team_metadata["opted_out_global_guardrails"], list
2303 ):
2304 data[_metadata_variable_name]["opted_out_global_guardrails"] = team_metadata["opted_out_global_guardrails"]
2305 if "spend_logs_metadata" in team_metadata and isinstance(team_metadata["spend_logs_metadata"], dict): 2305 ↛ 2306line 2305 didn't jump to line 2306 because the condition on line 2305 was never true
2306 if "spend_logs_metadata" in data[_metadata_variable_name] and isinstance(
2307 data[_metadata_variable_name]["spend_logs_metadata"], dict
2308 ):
2309 for key, value in team_metadata["spend_logs_metadata"].items():
2310 if (
2311 key not in data[_metadata_variable_name]["spend_logs_metadata"]
2312 ): # don't override k-v pair sent by request (user request)
2313 data[_metadata_variable_name]["spend_logs_metadata"][key] = value
2314 else:
2315 data[_metadata_variable_name]["spend_logs_metadata"] = team_metadata["spend_logs_metadata"]
2317 ## PROJECT-LEVEL TAGS
2318 project_metadata: Final = user_api_key_dict.project_metadata or {}
2319 if "tags" in project_metadata and project_metadata["tags"] is not None: 2319 ↛ 2320line 2319 didn't jump to line 2320 because the condition on line 2319 was never true
2320 data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags(
2321 request_tags=data[_metadata_variable_name].get("tags"),
2322 tags_to_add=project_metadata["tags"],
2323 )
2325 # inherited_tags: every tag key/team/project policy contributed, read
2326 # directly from those three sources rather than snapshotted off the shared
2327 # "tags" list. A pre-auth pass (apply_client_tag_policy_pre_auth, run from
2328 # user_api_key_auth for _tag_max_budget_check) may already have merged the
2329 # caller's own header tags into that same list before this function ever
2330 # runs, so a snapshot taken here -- at any point in this function -- would
2331 # misattribute caller-supplied tags as policy-backed. tag_based_routing.py's
2332 # allow_fail_open reads this (rather than subtracting caller_tags from the
2333 # final merged set) so a caller can't strip an inherited "!"/"&"
2334 # constraint's protection just by resubmitting its exact value alongside a
2335 # conflicting one.
2336 _key_tags: Final = (key_metadata or MappingProxyType({})).get("tags") or ()
2337 _team_tags: Final = team_metadata.get("tags") or ()
2338 _project_tags: Final = project_metadata.get("tags") or ()
2339 data[_metadata_variable_name]["inherited_tags"] = tuple( # rebind-ok: matches this file's data[...] mutation idiom
2340 dict.fromkeys((*_key_tags, *_team_tags, *_project_tags))
2341 )
2343 ## TEAM-LEVEL METADATA
2344 data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
2345 data=data,
2346 management_endpoint_metadata=team_metadata,
2347 _metadata_variable_name=_metadata_variable_name,
2348 )
2350 # A key's OTel service name outranks its team's, so the key's values are
2351 # re-applied after the last-writer-wins team metadata merge above
2352 _key_otel_service_names: Final = {
2353 field: value
2354 for field, value in (key_metadata or {}).items()
2355 if field in OTEL_SERVICE_NAME_METADATA_KEYS and isinstance(value, str) and value.strip()
2356 }
2357 data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
2358 data=data,
2359 management_endpoint_metadata=_key_otel_service_names,
2360 _metadata_variable_name=_metadata_variable_name,
2361 )
2363 # Team spend, budget - used by prometheus.py
2364 data[_metadata_variable_name]["user_api_key_team_max_budget"] = user_api_key_dict.team_max_budget
2365 data[_metadata_variable_name]["user_api_key_team_spend"] = user_api_key_dict.team_spend
2366 data[_metadata_variable_name]["user_api_key_team_model_max_budget"] = user_api_key_dict.team_model_max_budget
2367 data[_metadata_variable_name]["user_api_key_request_route"] = user_api_key_dict.request_route
2369 # API Key spend, budget - used by prometheus.py
2370 data[_metadata_variable_name]["user_api_key_spend"] = user_api_key_dict.spend
2371 data[_metadata_variable_name]["user_api_key_max_budget"] = user_api_key_dict.max_budget
2372 data[_metadata_variable_name]["user_api_key_model_max_budget"] = user_api_key_dict.model_max_budget
2373 data[_metadata_variable_name]["user_api_key_end_user_model_max_budget"] = (
2374 user_api_key_dict.end_user_model_max_budget
2375 )
2377 # User spend, budget - used by prometheus.py
2378 # Follow same pattern as team and API key budgets
2379 data[_metadata_variable_name]["user_api_key_user_spend"] = user_api_key_dict.user_spend
2380 data[_metadata_variable_name]["user_api_key_user_max_budget"] = user_api_key_dict.user_max_budget
2381 user_model_budget: Final = user_api_key_dict.user_model_max_budget
2382 data[_metadata_variable_name]["user_api_key_user_model_max_budget"] = user_model_budget # rebind-ok: out-param
2383 data[_metadata_variable_name].update(carried_budget_metadata(user_api_key_dict))
2385 data[_metadata_variable_name]["user_api_key_metadata"] = strip_callback_config(user_api_key_dict.metadata)
2386 data[_metadata_variable_name]["user_api_key_team_metadata"] = strip_callback_config(user_api_key_dict.team_metadata)
2387 data[_metadata_variable_name]["user_api_key_object_permission_id"] = getattr(
2388 user_api_key_dict, "object_permission_id", None
2389 )
2390 data[_metadata_variable_name]["user_api_key_team_object_permission_id"] = getattr(
2391 user_api_key_dict, "team_object_permission_id", None
2392 )
2393 data[_metadata_variable_name]["headers"] = _logging_safe_headers
2394 data[_metadata_variable_name]["endpoint"] = str(request.url)
2395 # Carry the proxy-receive instant via metadata (like `endpoint`) so the
2396 # OTel layer can compute pre-request latency, including on the failure
2397 # path after the logging object is popped.
2398 data[_metadata_variable_name]["litellm_received_at"] = getattr(request.state, "litellm_received_at", None)
2399 data[_metadata_variable_name]["llm_api_timing_windows"] = ()
2401 # OTEL Controls / Tracing
2402 # Add the OTEL Parent Trace before sending it LiteLLM
2403 data[_metadata_variable_name]["litellm_parent_otel_span"] = user_api_key_dict.parent_otel_span
2404 _add_otel_traceparent_to_data(data, request=request)
2406 ### END-USER SPECIFIC PARAMS ###
2407 if user_api_key_dict.allowed_model_region is not None: 2407 ↛ 2408line 2407 didn't jump to line 2408 because the condition on line 2407 was never true
2408 data["allowed_model_region"] = user_api_key_dict.allowed_model_region
2409 start_time: Final = time.time()
2410 ## [Enterprise Only]
2411 # Add User-IP Address
2412 requester_ip_address = ""
2413 if True: # Always set the IP Address if available
2414 # logic for tracking IP Address
2416 # logic for tracking IP Address
2417 if ( 2417 ↛ 2424line 2417 didn't jump to line 2424 because the condition on line 2417 was never true
2418 general_settings is not None
2419 and general_settings.get("use_x_forwarded_for") is True
2420 and request is not None
2421 and hasattr(request, "headers")
2422 and "x-forwarded-for" in request.headers
2423 ):
2424 requester_ip_address = request.headers["x-forwarded-for"]
2425 elif ( 2425 ↛ 2432line 2425 didn't jump to line 2432 because the condition on line 2425 was always true
2426 request is not None
2427 and hasattr(request, "client")
2428 and hasattr(request.client, "host")
2429 and request.client is not None
2430 ):
2431 requester_ip_address = request.client.host
2432 data[_metadata_variable_name]["requester_ip_address"] = requester_ip_address
2434 # Add User-Agent
2435 user_agent = ""
2436 if request is not None and hasattr(request, "headers") and "user-agent" in request.headers:
2437 user_agent = request.headers["user-agent"]
2438 data[_metadata_variable_name]["user_agent"] = user_agent
2440 if should_auto_drop_params_for_agentic_cli(user_agent, data, proxy_config): 2440 ↛ 2441line 2440 didn't jump to line 2441 because the condition on line 2440 was never true
2441 data["drop_params"] = True
2443 # Merge caller-supplied tags (x-litellm-tags header, data["tags"] root-level)
2444 # into request metadata for tag-based routing and spend attribution.
2445 tags: Final = LiteLLMProxyRequestSetup.add_request_tag_to_metadata(
2446 llm_router=llm_router,
2447 headers=_headers,
2448 data=data,
2449 )
2451 if tags is not None: 2451 ↛ 2452line 2451 didn't jump to line 2452 because the condition on line 2451 was never true
2452 data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags(
2453 request_tags=data[_metadata_variable_name].get("tags"),
2454 tags_to_add=tags,
2455 )
2457 _caller_body_metadata: Final = data.get("metadata") if _metadata_variable_name != "metadata" else None
2458 _caller_body_tags: Final = (
2459 _caller_body_metadata.get("tags")
2460 if isinstance(_caller_body_metadata, dict) and isinstance(_caller_body_metadata.get("tags"), list)
2461 else None
2462 )
2463 if _caller_body_tags: 2463 ↛ 2464line 2463 didn't jump to line 2464 because the condition on line 2463 was never true
2464 data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags( # rebind-ok: matches file idiom
2465 request_tags=data[_metadata_variable_name].get("tags"),
2466 tags_to_add=_caller_body_tags,
2467 )
2469 # caller_tags: exactly what this request itself supplied (x-litellm-tags header,
2470 # body "tags", or body "metadata.tags" on litellm_metadata routes), never
2471 # anything from key/team/project metadata. Read directly from the header and
2472 # body values here, the same way inherited_tags above is read directly from
2473 # key/team/project metadata -- neither is derived by inspecting the shared
2474 # "tags" list, which a pre-auth pass (apply_client_tag_policy_pre_auth) may
2475 # have already merged caller header tags into before this function runs.
2476 data[_metadata_variable_name]["caller_tags"] = tuple( # rebind-ok: matches file idiom
2477 dict.fromkeys((*(tags or ()), *(_caller_body_tags or ())))
2478 )
2480 # Team Callbacks controls
2481 callback_settings_obj: Final = _get_dynamic_logging_metadata(
2482 user_api_key_dict=user_api_key_dict, proxy_config=proxy_config
2483 )
2484 if callback_settings_obj is not None: 2484 ↛ 2485line 2484 didn't jump to line 2485 because the condition on line 2484 was never true
2485 data["success_callback"] = callback_settings_obj.success_callback
2486 data["failure_callback"] = callback_settings_obj.failure_callback
2488 if callback_settings_obj.callback_vars is not None:
2489 # unpack callback_vars in data
2490 for k, v in callback_settings_obj.callback_vars.items():
2491 data[k] = v
2492 # Callbacks that must not honour request-supplied credentials read this
2493 # proxy-owned field instead of the raw request kwargs.
2494 data[TRUSTED_CALLBACK_VARS_FIELD] = callback_settings_obj.callback_vars
2496 # Add disabled callbacks from key metadata
2497 if user_api_key_dict.metadata and "litellm_disabled_callbacks" in user_api_key_dict.metadata: 2497 ↛ 2498line 2497 didn't jump to line 2498 because the condition on line 2497 was never true
2498 disabled_callbacks: Final = user_api_key_dict.metadata["litellm_disabled_callbacks"]
2499 if disabled_callbacks and isinstance(disabled_callbacks, list):
2500 data["litellm_disabled_callbacks"] = disabled_callbacks
2502 # Guardrails from key/team metadata and policy engine
2503 await move_guardrails_to_metadata(
2504 data=data,
2505 _metadata_variable_name=_metadata_variable_name,
2506 user_api_key_dict=user_api_key_dict,
2507 )
2509 # Save pre-alias model name for credential override lookup
2510 _pre_alias_model: Final = data.get("model")
2512 # Team Model Aliases
2513 _update_model_if_team_alias_exists(
2514 data=data,
2515 user_api_key_dict=user_api_key_dict,
2516 )
2518 # Key Model Aliases
2519 _update_model_if_key_alias_exists(
2520 data=data,
2521 user_api_key_dict=user_api_key_dict,
2522 )
2524 verbose_proxy_logger.debug("[PROXY] returned data from litellm_pre_call_utils: %s", data)
2526 # Team/Project credential overrides from model_config
2527 # Placed after the debug log to avoid leaking credential secrets in logs
2528 _apply_credential_overrides_from_model_config(
2529 data=data,
2530 user_api_key_dict=user_api_key_dict,
2531 pre_alias_model_name=_pre_alias_model,
2532 llm_router=llm_router,
2533 )
2535 ## ENFORCED PARAMS CHECK
2536 # loop through each enforced param
2537 # example enforced_params ['user', 'metadata', 'metadata.generation_name']
2538 _enforced_params_check(
2539 request_body=data,
2540 general_settings=general_settings,
2541 user_api_key_dict=user_api_key_dict,
2542 premium_user=premium_user,
2543 )
2545 end_time: Final = time.time()
2546 asyncio.create_task(
2547 service_logger_obj.async_service_success_hook(
2548 service=ServiceTypes.PROXY_PRE_CALL,
2549 duration=end_time - start_time,
2550 call_type="add_litellm_data_to_request",
2551 start_time=start_time,
2552 end_time=end_time,
2553 parent_otel_span=user_api_key_dict.parent_otel_span,
2554 )
2555 )
2557 return data
2560def _warn_stale_team_alias_once(warning_key: str, message: str, *args: str) -> None:
2561 if warning_key in _STALE_TEAM_ALIAS_WARNING_KEYS:
2562 return
2563 _STALE_TEAM_ALIAS_WARNING_KEYS[warning_key] = None
2564 while len(_STALE_TEAM_ALIAS_WARNING_KEYS) > _MAX_STALE_ALIAS_WARNING_KEYS:
2565 _STALE_TEAM_ALIAS_WARNING_KEYS.popitem(last=False)
2566 verbose_proxy_logger.warning(message, *args)
2569def _update_model_if_team_alias_exists(
2570 data: dict,
2571 user_api_key_dict: UserAPIKeyAuth,
2572) -> None:
2573 """
2574 Update the model if the team alias exists
2576 If a alias map has been set on a team, then we want to make the request with the model the team alias is pointing to
2578 eg.
2579 - user calls `gpt-4o`
2580 - team.model_alias_map = {
2581 "gpt-4o": "gpt-4o-team-1"
2582 }
2583 - requested_model = "gpt-4o-team-1"
2585 Note: model_aliases for team models are deprecated. This function only applies
2586 to legacy non-team-scoped aliases. Team-scoped deployments use team_public_model_name
2587 and are resolved via map_team_model in route_llm_request.
2589 An alias that targets a team-scoped internal name (``model_name_{team_id}_{uuid}``)
2590 with no live deployment behind it is never applied: the deployment was deleted, so
2591 the rewrite could only fail with an error naming a model the caller never sent.
2592 Keeping the requested model name lets it resolve against the deployments that still
2593 exist (e.g. a gateway-level model group shared with the team).
2594 """
2595 _model: Final = data.get("model")
2596 if not _model or not user_api_key_dict.team_model_aliases or _model not in user_api_key_dict.team_model_aliases: 2596 ↛ 2599line 2596 didn't jump to line 2599 because the condition on line 2596 was always true
2597 return
2599 from litellm.proxy.proxy_server import llm_router
2601 # Skip alias rewrite if this model resolves to team-specific deployments
2602 # (team models use team_public_model_name, not model_aliases)
2603 aliased_target: Final = user_api_key_dict.team_model_aliases[_model]
2605 # Optional bypass for stale aliases from pre-PR deployments:
2606 # only enabled via feature flag to preserve backwards compatibility.
2607 # Cached at module level to avoid hot-path secret lookups on every request.
2608 global _ENABLE_TEAM_STALE_ALIAS_BYPASS
2609 if _ENABLE_TEAM_STALE_ALIAS_BYPASS is None:
2610 _ENABLE_TEAM_STALE_ALIAS_BYPASS = get_secret_bool("LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS", False)
2611 enable_stale_alias_bypass: Final = _ENABLE_TEAM_STALE_ALIAS_BYPASS
2612 # Check if the alias points to a team-scoped UUID name
2613 # (format: "model_name_{team_id}_{uuid}")
2614 is_stale_team_alias: Final = aliased_target.startswith(f"model_name_{user_api_key_dict.team_id}_")
2615 if is_stale_team_alias and llm_router:
2616 if aliased_target not in llm_router.model_name_to_deployment_indices:
2617 _warn_stale_team_alias_once(
2618 f"deleted:{user_api_key_dict.team_id}:{_model}:{aliased_target}",
2619 "Team model alias for model='%s', team_id='%s' targets '%s', which has no live "
2620 "deployment. Routing with the requested model name instead; remove the stale "
2621 "entry from the team's model_aliases to silence this warning.",
2622 _sanitize_for_log(_model),
2623 _sanitize_for_log(user_api_key_dict.team_id),
2624 _sanitize_for_log(aliased_target),
2625 )
2626 return
2627 # This is a stale alias from pre-PR deployments.
2628 # Check if current team deployments exist for the public name.
2629 key: Final = (user_api_key_dict.team_id, _model)
2630 if key in llm_router.team_model_to_deployment_indices:
2631 if enable_stale_alias_bypass:
2632 # Team deployments exist; skip stale alias
2633 return
2634 _warn_stale_team_alias_once(
2635 f"{user_api_key_dict.team_id}:{_model}:{aliased_target}",
2636 "Stale team model alias detected for model='%s', team_id='%s'. "
2637 "New sibling deployments may be unreachable. "
2638 "Set LITELLM_ENABLE_TEAM_STALE_ALIAS_BYPASS=true to enable "
2639 "team-scoped sibling routing.",
2640 _sanitize_for_log(_model),
2641 _sanitize_for_log(user_api_key_dict.team_id),
2642 )
2644 data["model"] = aliased_target
2647def _update_model_if_key_alias_exists(
2648 data: dict,
2649 user_api_key_dict: UserAPIKeyAuth,
2650) -> None:
2651 """
2652 Update the model if the key alias exists
2654 If an alias map has been set on a key, then we want to make the request with the model the key alias is pointing to
2656 eg.
2657 - user calls `modelAlias`
2658 - key.aliases = {
2659 "modelAlias": "xai/grok-4-fast-non-reasoning"
2660 }
2661 - requested_model = "xai/grok-4-fast-non-reasoning"
2662 """
2663 _model: Final = data.get("model")
2664 if ( 2664 ↛ 2670line 2664 didn't jump to line 2670 because the condition on line 2664 was never true
2665 _model
2666 and user_api_key_dict.aliases
2667 and isinstance(user_api_key_dict.aliases, dict)
2668 and _model in user_api_key_dict.aliases
2669 ):
2670 data["model"] = user_api_key_dict.aliases[_model]
2673def _apply_credential_overrides_from_model_config(
2674 data: dict,
2675 user_api_key_dict: UserAPIKeyAuth,
2676 pre_alias_model_name: str | None = None,
2677 llm_router: Router | None = None,
2678) -> None:
2679 """
2680 Walk the model_config precedence chain in team/project metadata.
2681 If a matching credential is found, set api_base/api_key/api_version on data
2682 so they override deployment defaults in the router.
2684 Precedence (highest to lowest):
2685 1. Clientside credentials (already in data — skip if present)
2686 2. Project model-specific override
2687 3. Project default override (defaultconfig)
2688 4. Team model-specific override
2689 5. Team default override (defaultconfig)
2690 6. Deployment default (no action needed)
2691 """
2692 # Feature flag gate — disabled by default, opt in with litellm.enable_model_config_credential_overrides = True
2693 if not litellm.enable_model_config_credential_overrides: 2693 ↛ 2697line 2693 didn't jump to line 2697 because the condition on line 2693 was always true
2694 return
2696 # Respect clientside credentials — highest precedence
2697 if data.get("api_base") is not None or data.get("api_key") is not None:
2698 return
2700 model_name: Final = data.get("model")
2701 if not model_name:
2702 return
2704 project_metadata: Final = user_api_key_dict.project_metadata or {}
2705 team_metadata: Final = user_api_key_dict.team_metadata or {}
2707 project_model_config: Final = project_metadata.get("model_config")
2708 team_model_config: Final = team_metadata.get("model_config")
2710 if not project_model_config and not team_model_config:
2711 return
2713 # Extract provider hint from model name (e.g. "azure/gpt-4" -> "azure").
2714 # When the user-facing name has no provider prefix, fall back to the
2715 # deployment's litellm_params so multi-provider defaultconfig entries
2716 # don't silently match the first dict key (#27516).
2717 provider: str | None = None
2718 if "/" in model_name:
2719 provider = model_name.split("/", 1)[0]
2720 elif llm_router is not None:
2721 provider = _resolve_provider_from_deployment(
2722 llm_router=llm_router,
2723 model_name=model_name,
2724 pre_alias_model_name=pre_alias_model_name,
2725 )
2727 credential_name: Final = _resolve_credential_from_model_config(
2728 model_name=model_name,
2729 project_model_config=project_model_config,
2730 team_model_config=team_model_config,
2731 pre_alias_model_name=pre_alias_model_name,
2732 provider=provider,
2733 )
2735 if not credential_name:
2736 return
2738 credential_values: Final = CredentialAccessor.get_credential_values(credential_name)
2739 if not credential_values:
2740 _safe_cred = str(credential_name).replace("\n", "").replace("\r", "")
2741 verbose_proxy_logger.warning(
2742 "model_config references credential '%s' but it was not found or has no values",
2743 _safe_cred,
2744 )
2745 return
2747 # Apply credential overrides only for keys not already in the request
2748 for key in ("api_base", "api_key", "api_version"):
2749 if key in credential_values and key not in data:
2750 data[key] = credential_values[key]
2752 _safe_model: Final = str(model_name).replace("\n", "").replace("\r", "")
2753 _safe_cred = str(credential_name).replace("\n", "").replace("\r", "")
2754 verbose_proxy_logger.debug(
2755 "Applied credential override '%s' for model '%s'",
2756 _safe_cred,
2757 _safe_model,
2758 )
2761def _resolve_provider_from_deployment(
2762 llm_router: Router,
2763 model_name: str,
2764 pre_alias_model_name: str | None = None,
2765) -> str | None:
2766 """
2767 Resolve a provider hint from the deployment's litellm_params when the
2768 user-facing model name has no provider prefix.
2770 Tries the post-alias name first (the resolved model group), then the
2771 pre-alias name. Returns None if no deployment is found or the deployment
2772 has no usable provider info.
2773 """
2774 candidates: Final = [model_name]
2775 if pre_alias_model_name and pre_alias_model_name != model_name:
2776 candidates.append(pre_alias_model_name)
2778 for name in candidates:
2779 try:
2780 deployment = llm_router.get_deployment_by_model_group_name(model_group_name=name)
2781 except Exception:
2782 deployment = None
2783 if deployment is None:
2784 continue
2786 litellm_params: object = getattr(deployment, "litellm_params", None)
2787 if litellm_params is None:
2788 continue
2790 custom_provider = getattr(litellm_params, "custom_llm_provider", None)
2791 if isinstance(custom_provider, str) and custom_provider:
2792 return custom_provider
2794 deployment_model = getattr(litellm_params, "model", "")
2795 if isinstance(deployment_model, str) and "/" in deployment_model:
2796 return deployment_model.split("/", 1)[0]
2798 return None
2801def _resolve_credential_from_model_config(
2802 model_name: str,
2803 project_model_config: dict | None,
2804 team_model_config: dict | None,
2805 pre_alias_model_name: str | None = None,
2806 provider: str | None = None,
2807) -> str | None:
2808 """
2809 Walk the precedence chain and return the first matching credential name.
2811 Checks (in order):
2812 1. project_model_config[model_name][provider] — project model-specific
2813 2. project_model_config[pre_alias_model_name][provider] — project pre-alias
2814 3. project_model_config["defaultconfig"][provider] — project default
2815 4. team_model_config[model_name][provider] — team model-specific
2816 5. team_model_config[pre_alias_model_name][provider] — team pre-alias
2817 6. team_model_config["defaultconfig"][provider] — team default
2819 When a model-specific entry exists but contains no litellm_credentials,
2820 the function falls through to defaultconfig. This is intentional —
2821 an entry without litellm_credentials is treated as incomplete config,
2822 not as an explicit "no override" signal.
2823 """
2824 # Build the list of model names to try (post-alias first, then pre-alias)
2825 model_names_to_try: Final = [model_name]
2826 if pre_alias_model_name and pre_alias_model_name != model_name:
2827 model_names_to_try.append(pre_alias_model_name)
2829 for model_config in (project_model_config, team_model_config):
2830 if not model_config or not isinstance(model_config, dict):
2831 continue
2833 # Model-specific check (try resolved name, then pre-alias name)
2834 for name in model_names_to_try:
2835 model_entry = model_config.get(name)
2836 if model_entry:
2837 credential_name = _extract_credential_from_entry(model_entry, provider=provider)
2838 if credential_name:
2839 return credential_name
2840 _safe_name = str(name).replace("\n", "").replace("\r", "")
2841 verbose_proxy_logger.debug(
2842 "model_config entry '%s' found but has no litellm_credentials, trying next candidate",
2843 _safe_name,
2844 )
2846 # Default check
2847 default_entry = model_config.get("defaultconfig")
2848 if default_entry:
2849 credential_name = _extract_credential_from_entry(default_entry, provider=provider)
2850 if credential_name:
2851 return credential_name
2853 return None
2856def _extract_credential_from_entry(entry: dict, provider: str | None = None) -> str | None:
2857 """
2858 Extract litellm_credentials from a model_config entry.
2860 Entry structure: {"azure": {"litellm_credentials": "name"}, ...}
2862 When provider is given (e.g. "azure"), tries an exact provider match first.
2863 Falls back to the first credential found across all provider keys.
2864 """
2865 if not isinstance(entry, dict):
2866 return None
2868 # Prefer exact provider match when provider hint is available
2869 if provider and provider in entry:
2870 provider_config = entry[provider]
2871 if isinstance(provider_config, dict):
2872 credential_name = provider_config.get("litellm_credentials")
2873 if credential_name:
2874 return credential_name
2876 # Fall back to first available provider
2877 for provider_config in entry.values():
2878 if isinstance(provider_config, dict):
2879 credential_name = provider_config.get("litellm_credentials")
2880 if credential_name:
2881 return credential_name
2882 return None
2885def _get_enforced_params(general_settings: dict | None, user_api_key_dict: UserAPIKeyAuth) -> list | None:
2886 enforced_params: list | None = None
2887 if general_settings is not None: 2887 ↛ 2898line 2887 didn't jump to line 2898 because the condition on line 2887 was always true
2888 enforced_params = general_settings.get("enforced_params")
2889 if ( 2889 ↛ 2893line 2889 didn't jump to line 2893 because the condition on line 2889 was never true
2890 "service_account_settings" in general_settings
2891 and check_if_token_is_service_account(user_api_key_dict) is True
2892 ):
2893 service_account_settings: Final = general_settings["service_account_settings"]
2894 if "enforced_params" in service_account_settings:
2895 if enforced_params is None:
2896 enforced_params = []
2897 enforced_params.extend(service_account_settings["enforced_params"])
2898 if user_api_key_dict.metadata.get("enforced_params", None) is not None: 2898 ↛ 2899line 2898 didn't jump to line 2899 because the condition on line 2898 was never true
2899 if enforced_params is None:
2900 enforced_params = []
2901 enforced_params.extend(user_api_key_dict.metadata["enforced_params"])
2902 return enforced_params
2905def check_if_token_is_service_account(valid_token: UserAPIKeyAuth) -> bool:
2906 """
2907 Checks if the token is a service account
2909 Returns:
2910 bool: True if token is a service account
2912 """
2913 if valid_token.metadata:
2914 if "service_account_id" in valid_token.metadata:
2915 return True
2916 return False
2919def _enforced_params_check(
2920 request_body: dict,
2921 general_settings: dict | None,
2922 user_api_key_dict: UserAPIKeyAuth,
2923 premium_user: bool,
2924) -> bool:
2925 """
2926 If enforced params are set, check if the request body contains the enforced params.
2927 """
2928 enforced_params: Final[list | None] = _get_enforced_params(
2929 general_settings=general_settings, user_api_key_dict=user_api_key_dict
2930 )
2931 if enforced_params is None: 2931 ↛ 2933line 2931 didn't jump to line 2933 because the condition on line 2931 was always true
2932 return True
2933 if enforced_params and premium_user is not True:
2934 raise ValueError(
2935 f"Enforced Params is an Enterprise feature. Enforced Params: {enforced_params}. {CommonProxyErrors.not_premium_user.value}"
2936 )
2938 for enforced_param in enforced_params:
2939 _enforced_params = enforced_param.split(".")
2940 if len(_enforced_params) == 1:
2941 if _enforced_params[0] not in request_body:
2942 raise ValueError(
2943 f"BadRequest please pass param={_enforced_params[0]} in request body. This is a required param"
2944 )
2945 elif len(_enforced_params) == 2:
2946 # this is a scenario where user requires request['metadata']['generation_name'] to exist
2947 if _enforced_params[0] not in request_body:
2948 raise ValueError(
2949 f"BadRequest please pass param={_enforced_params[0]} in request body. This is a required param"
2950 )
2951 if _enforced_params[1] not in request_body[_enforced_params[0]]:
2952 raise ValueError(
2953 f"BadRequest please pass param=[{_enforced_params[0]}][{_enforced_params[1]}] in request body. This is a required param"
2954 )
2955 return True
2958def _add_guardrails_from_key_or_team_metadata(
2959 key_metadata: dict | None,
2960 team_metadata: dict | None,
2961 data: dict,
2962 metadata_variable_name: str,
2963 project_metadata: dict | None = None,
2964) -> None:
2965 """
2966 Helper add guardrails from key, team, or project metadata to request data
2968 Key guardrails are set first, then team and project guardrails are appended (without duplicates).
2970 Args:
2971 key_metadata: The key metadata dictionary to check for guardrails
2972 team_metadata: The team metadata dictionary to check for guardrails
2973 data: The request data to update
2974 metadata_variable_name: The name of the metadata field in data
2975 project_metadata: The project metadata dictionary to check for guardrails
2977 """
2978 from litellm.proxy.utils import _premium_user_check
2980 # Initialize guardrails set (avoiding duplicates)
2981 combined_guardrails: Final = set()
2983 # Add key-level guardrails first
2984 if key_metadata and "guardrails" in key_metadata: 2984 ↛ 2985line 2984 didn't jump to line 2985 because the condition on line 2984 was never true
2985 if isinstance(key_metadata["guardrails"], list) and len(key_metadata["guardrails"]) > 0:
2986 _premium_user_check()
2987 combined_guardrails.update(key_metadata["guardrails"])
2989 # Add team-level guardrails (set automatically handles duplicates)
2990 if team_metadata and "guardrails" in team_metadata: 2990 ↛ 2991line 2990 didn't jump to line 2991 because the condition on line 2990 was never true
2991 if isinstance(team_metadata["guardrails"], list) and len(team_metadata["guardrails"]) > 0:
2992 _premium_user_check()
2993 combined_guardrails.update(team_metadata["guardrails"])
2995 # Add project-level guardrails (set automatically handles duplicates)
2996 if project_metadata and "guardrails" in project_metadata: 2996 ↛ 2997line 2996 didn't jump to line 2997 because the condition on line 2996 was never true
2997 if isinstance(project_metadata["guardrails"], list) and len(project_metadata["guardrails"]) > 0:
2998 _premium_user_check()
2999 combined_guardrails.update(project_metadata["guardrails"])
3001 # Set combined guardrails in metadata as list
3002 if combined_guardrails: 3002 ↛ 3003line 3002 didn't jump to line 3003 because the condition on line 3002 was never true
3003 data[metadata_variable_name]["guardrails"] = list(combined_guardrails)
3006def _add_guardrails_from_policies_in_metadata(
3007 key_metadata: dict | None,
3008 team_metadata: dict | None,
3009 data: dict,
3010 metadata_variable_name: str,
3011 project_metadata: dict | None = None,
3012) -> None:
3013 """
3014 Helper to resolve guardrails from policies attached to key/team/project metadata.
3016 This function:
3017 1. Gets policy names from key, team, and project metadata
3018 2. Resolves guardrails from those policies (including inheritance)
3019 3. Adds resolved guardrails to request metadata
3021 Args:
3022 key_metadata: The key metadata dictionary to check for policies
3023 team_metadata: The team metadata dictionary to check for policies
3024 data: The request data to update
3025 metadata_variable_name: The name of the metadata field in data
3026 project_metadata: The project metadata dictionary to check for policies
3027 """
3028 from litellm._logging import verbose_proxy_logger
3029 from litellm.proxy.policy_engine.policy_registry import get_policy_registry
3030 from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
3031 from litellm.proxy.utils import _premium_user_check
3032 from litellm.types.proxy.policy_engine import PolicyMatchContext
3034 # Collect policy names from key and team metadata
3035 policy_names: Final[set] = set()
3037 # Add key-level policies first
3038 if key_metadata and "policies" in key_metadata: 3038 ↛ 3039line 3038 didn't jump to line 3039 because the condition on line 3038 was never true
3039 if isinstance(key_metadata["policies"], list) and len(key_metadata["policies"]) > 0:
3040 _premium_user_check()
3041 policy_names.update(key_metadata["policies"])
3043 # Add team-level policies
3044 if team_metadata and "policies" in team_metadata: 3044 ↛ 3045line 3044 didn't jump to line 3045 because the condition on line 3044 was never true
3045 if isinstance(team_metadata["policies"], list) and len(team_metadata["policies"]) > 0:
3046 _premium_user_check()
3047 policy_names.update(team_metadata["policies"])
3049 # Add project-level policies
3050 if project_metadata and "policies" in project_metadata: 3050 ↛ 3051line 3050 didn't jump to line 3051 because the condition on line 3050 was never true
3051 if isinstance(project_metadata["policies"], list) and len(project_metadata["policies"]) > 0:
3052 _premium_user_check()
3053 policy_names.update(project_metadata["policies"])
3055 if not policy_names: 3055 ↛ 3058line 3055 didn't jump to line 3058 because the condition on line 3055 was always true
3056 return
3058 verbose_proxy_logger.debug("Policy engine: resolving guardrails from key/team policies: %s", policy_names)
3060 # Check if policy registry is initialized
3061 registry: Final = get_policy_registry()
3062 if not registry.is_initialized():
3063 verbose_proxy_logger.debug("Policy engine not initialized, skipping policy resolution from metadata")
3064 return
3066 # Build context for policy resolution (model from request data)
3067 context: Final = PolicyMatchContext(model=data.get("model"))
3069 # Get all policies from registry
3070 all_policies: Final = registry.get_all_policies()
3072 # Resolve guardrails from the specified policies
3073 resolved_guardrails: Final[set] = set()
3074 for policy_name in policy_names:
3075 if registry.has_policy(policy_name):
3076 resolved_policy = PolicyResolver.resolve_policy_guardrails(
3077 policy_name=policy_name,
3078 policies=all_policies,
3079 context=context,
3080 )
3081 resolved_guardrails.update(resolved_policy.guardrails)
3082 verbose_proxy_logger.debug(
3083 "Policy engine: resolved guardrails from policy '%s': %s", policy_name, resolved_policy.guardrails
3084 )
3085 else:
3086 verbose_proxy_logger.warning("Policy engine: policy '%s' not found in registry", policy_name)
3088 if not resolved_guardrails:
3089 return
3091 # Add resolved guardrails to request metadata
3092 if metadata_variable_name not in data:
3093 data[metadata_variable_name] = {}
3095 existing_guardrails = data[metadata_variable_name].get("guardrails", [])
3096 if not isinstance(existing_guardrails, list):
3097 existing_guardrails = []
3099 # Combine existing guardrails with policy-resolved guardrails (no duplicates)
3100 combined: Final = set(existing_guardrails)
3101 combined.update(resolved_guardrails)
3102 data[metadata_variable_name]["guardrails"] = list(combined)
3104 # Store applied policies in metadata for tracking
3105 if "applied_policies" not in data[metadata_variable_name]:
3106 data[metadata_variable_name]["applied_policies"] = []
3107 data[metadata_variable_name]["applied_policies"].extend(list(policy_names))
3109 verbose_proxy_logger.debug(
3110 "Policy engine: added guardrails from key/team policies to request metadata: %s", list(resolved_guardrails)
3111 )
3114def add_guardrails_from_auth_metadata(
3115 user_api_key_dict: UserAPIKeyAuth,
3116 data: dict, # mutable-ok: writes guardrails into the live request dict, same contract as the helpers it wraps
3117 metadata_variable_name: str,
3118) -> None:
3119 """Resolve key, team, and project guardrails, direct and via policies, onto the request metadata."""
3120 _add_guardrails_from_key_or_team_metadata(
3121 key_metadata=user_api_key_dict.metadata,
3122 team_metadata=user_api_key_dict.team_metadata,
3123 project_metadata=user_api_key_dict.project_metadata,
3124 data=data,
3125 metadata_variable_name=metadata_variable_name,
3126 )
3127 _add_guardrails_from_policies_in_metadata(
3128 key_metadata=user_api_key_dict.metadata,
3129 team_metadata=user_api_key_dict.team_metadata,
3130 project_metadata=user_api_key_dict.project_metadata,
3131 data=data,
3132 metadata_variable_name=metadata_variable_name,
3133 )
3136async def move_guardrails_to_metadata(
3137 data: dict,
3138 _metadata_variable_name: str,
3139 user_api_key_dict: UserAPIKeyAuth,
3140):
3141 """
3142 Helper to add guardrails from request to metadata
3144 - If guardrails set on API Key metadata then sets guardrails on request metadata
3145 - If guardrails not set on API key, then checks request metadata
3146 - Adds guardrails from policies attached to key/team metadata
3147 - Adds guardrails from policy engine based on team/key/model context
3148 - Moves include_guardrail_response into request metadata before provider dispatch
3149 """
3150 if "include_guardrail_response" in data: 3150 ↛ 3151line 3150 didn't jump to line 3151 because the condition on line 3150 was never true
3151 data[_metadata_variable_name]["include_guardrail_response"] = data.pop("include_guardrail_response") is True
3153 # Early-out: skip all guardrails processing when nothing is configured
3154 key_metadata: Final = user_api_key_dict.metadata
3155 team_metadata: Final = user_api_key_dict.team_metadata
3156 project_metadata: Final = user_api_key_dict.project_metadata or {}
3158 has_key_config: Final = key_metadata and ("guardrails" in key_metadata or "policies" in key_metadata)
3159 has_team_config: Final = team_metadata and ("guardrails" in team_metadata or "policies" in team_metadata)
3160 has_project_config = project_metadata and ("guardrails" in project_metadata or "policies" in project_metadata)
3161 has_request_config: Final = "guardrails" in data or "guardrail_config" in data or "policies" in data
3163 # Only check policy engine if no local config (avoid import + registry lookup)
3164 if not (has_key_config or has_team_config or has_project_config or has_request_config):
3165 from litellm.proxy.policy_engine.policy_registry import get_policy_registry
3167 if not get_policy_registry().is_initialized(): 3167 ↛ 3169line 3167 didn't jump to line 3169 because the condition on line 3167 was never true
3168 # Nothing configured anywhere - clean up request body fields and return
3169 data.pop("policies", None)
3170 return
3172 add_guardrails_from_auth_metadata(
3173 user_api_key_dict=user_api_key_dict,
3174 data=data,
3175 metadata_variable_name=_metadata_variable_name,
3176 )
3178 #########################################################################################
3179 # Add guardrails from policy engine based on team/key/model context
3180 #########################################################################################
3181 await add_guardrails_from_policy_engine(
3182 data=data,
3183 metadata_variable_name=_metadata_variable_name,
3184 user_api_key_dict=user_api_key_dict,
3185 )
3187 #########################################################################################
3188 # User's might send "guardrails" in the request body, we need to add them to the request metadata.
3189 # Since downstream logic requires "guardrails" to be in the request metadata
3190 #########################################################################################
3191 if "guardrails" in data:
3192 request_body_guardrails: Final = data.pop("guardrails")
3193 if "guardrails" in data[_metadata_variable_name] and isinstance( 3193 ↛ 3196line 3193 didn't jump to line 3196 because the condition on line 3193 was never true
3194 data[_metadata_variable_name]["guardrails"], list
3195 ):
3196 data[_metadata_variable_name]["guardrails"].extend(request_body_guardrails)
3197 else:
3198 data[_metadata_variable_name]["guardrails"] = request_body_guardrails
3200 #########################################################################################
3201 if "guardrail_config" in data: 3201 ↛ 3202line 3201 didn't jump to line 3202 because the condition on line 3201 was never true
3202 request_body_guardrail_config: Final = data.pop("guardrail_config")
3203 if "guardrail_config" in data[_metadata_variable_name] and isinstance(
3204 data[_metadata_variable_name]["guardrail_config"], dict
3205 ):
3206 data[_metadata_variable_name]["guardrail_config"].update(request_body_guardrail_config)
3207 else:
3208 data[_metadata_variable_name]["guardrail_config"] = request_body_guardrail_config
3211def _is_policy_version_id(s: str) -> bool:
3212 """Return True if string is a policy version ID (starts with policy_<uuid> prefix)."""
3213 from litellm.proxy.policy_engine.policy_registry import POLICY_VERSION_ID_PREFIX
3215 return isinstance(s, str) and s.startswith(POLICY_VERSION_ID_PREFIX)
3218def _extract_policy_id(s: str) -> str | None:
3219 """Extract raw UUID from policy_<uuid> string, or None if not a valid version ID."""
3220 from litellm.proxy.policy_engine.policy_registry import POLICY_VERSION_ID_PREFIX
3222 if not _is_policy_version_id(s):
3223 return None
3224 return s[len(POLICY_VERSION_ID_PREFIX) :].strip() or None
3227def _match_and_track_policies(
3228 data: dict,
3229 context: "PolicyMatchContext",
3230 request_body_policies: Sequence[str],
3231 policies_override: dict[str, "Policy"] | None = None,
3232 attachment_registry_override: "AttachmentRegistry | None" = None,
3233) -> tuple[list[str], dict[str, str]]:
3234 """
3235 Match policies via attachments and request body, track them in metadata.
3237 Returns:
3238 Tuple of (applied_policy_names, policy_reasons)
3239 """
3240 from litellm._logging import verbose_proxy_logger
3241 from litellm.proxy.common_utils.callback_utils import (
3242 add_policy_sources_to_metadata,
3243 add_policy_to_applied_policies_header,
3244 )
3245 from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry
3246 from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
3248 # Get matching policies via attachments (with match reasons for attribution)
3249 attachment_registry: Final = (
3250 attachment_registry_override if attachment_registry_override is not None else get_attachment_registry()
3251 )
3252 matches_with_reasons: Final = attachment_registry.get_attached_policies_with_reasons(
3253 context, PolicyMatcher.policy_applies(context, policies_override)
3254 )
3255 matching_policy_names: Final = [m["policy_name"] for m in matches_with_reasons]
3256 policy_reasons: Final = {m["policy_name"]: m["matched_via"] for m in matches_with_reasons}
3258 verbose_proxy_logger.debug("Policy engine: matched policies via attachments: %s", matching_policy_names)
3260 # Combine attachment-based policies with dynamic request body policies
3261 request_body_policies_list: Final = (
3262 tuple(request_body_policies) if request_body_policies and isinstance(request_body_policies, list) else ()
3263 )
3264 all_policy_names: Final = tuple(dict.fromkeys((*matching_policy_names, *request_body_policies_list)))
3265 if request_body_policies_list: 3265 ↛ 3266line 3265 didn't jump to line 3266 because the condition on line 3265 was never true
3266 verbose_proxy_logger.debug("Policy engine: added dynamic policies from request body: %s", request_body_policies)
3268 if not all_policy_names:
3269 return [], {}
3271 # Filter to only policies whose conditions match the context
3272 applied_policy_names: Final = PolicyMatcher.get_policies_with_matching_conditions(
3273 policy_names=list(all_policy_names),
3274 context=context,
3275 policies=policies_override,
3276 )
3278 verbose_proxy_logger.debug("Policy engine: applied policies (conditions matched): %s", applied_policy_names)
3280 # Track applied policies in metadata for response headers
3281 for policy_name in applied_policy_names:
3282 add_policy_to_applied_policies_header(request_data=data, policy_name=policy_name)
3284 # Track policy attribution sources for x-litellm-policy-sources header
3285 applied_reasons: Final = {name: policy_reasons[name] for name in applied_policy_names if name in policy_reasons}
3286 add_policy_sources_to_metadata(request_data=data, policy_sources=applied_reasons)
3288 return applied_policy_names, policy_reasons
3291def _apply_resolved_guardrails_to_metadata(
3292 data: dict,
3293 metadata_variable_name: str,
3294 context: "PolicyMatchContext",
3295 policy_names: list[str] | None = None,
3296 policies: dict[str, "Policy"] | None = None,
3297) -> None:
3298 """Apply resolved guardrails and pipelines to request metadata."""
3299 from litellm._logging import verbose_proxy_logger
3300 from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
3302 # Resolve guardrails from matching policies
3303 resolved_guardrails: Final = PolicyResolver.resolve_guardrails_for_context(
3304 context=context,
3305 policies=policies,
3306 policy_names=policy_names,
3307 )
3309 verbose_proxy_logger.debug("Policy engine: resolved guardrails: %s", resolved_guardrails)
3311 # Resolve pipelines from matching policies
3312 pipelines: Final = PolicyResolver.resolve_pipelines_for_context(
3313 context=context,
3314 policies=policies,
3315 policy_names=policy_names,
3316 )
3318 # Add resolved guardrails to request metadata
3319 if metadata_variable_name not in data: 3319 ↛ 3320line 3319 didn't jump to line 3320 because the condition on line 3319 was never true
3320 data[metadata_variable_name] = {}
3322 # Record the pipelines and the guardrails they step; the hook loops skip those per pipeline mode
3323 if pipelines: 3323 ↛ 3324line 3323 didn't jump to line 3324 because the condition on line 3323 was never true
3324 pipeline_managed_guardrails: Final = PolicyResolver.get_pipeline_managed_guardrails(pipelines)
3325 data[metadata_variable_name]["_guardrail_pipelines"] = pipelines
3326 data[metadata_variable_name]["_pipeline_managed_guardrails"] = pipeline_managed_guardrails
3327 verbose_proxy_logger.debug(
3328 "Policy engine: resolved %s pipeline(s), managed guardrails: %s",
3329 len(pipelines),
3330 pipeline_managed_guardrails,
3331 )
3333 if not resolved_guardrails and not pipelines:
3334 return
3336 existing_guardrails: Final = data[metadata_variable_name].get("guardrails", [])
3337 existing_guardrails_list: Final = existing_guardrails if isinstance(existing_guardrails, list) else []
3339 # Combine existing guardrails with policy-resolved guardrails (no duplicates)
3340 combined: Final = list(dict.fromkeys((*existing_guardrails_list, *resolved_guardrails)))
3341 data[metadata_variable_name]["guardrails"] = combined
3343 verbose_proxy_logger.debug("Policy engine: added guardrails to request metadata: %s", combined)
3346async def add_guardrails_from_policy_engine(
3347 data: dict,
3348 metadata_variable_name: str,
3349 user_api_key_dict: UserAPIKeyAuth,
3350) -> None:
3351 """
3352 Add guardrails from the policy engine based on request context.
3354 This function:
3355 1. Extracts "policies" from request body (if present) for dynamic policy application
3356 2. Supports policy_<uuid> in policies to execute a specific version (e.g. published)
3357 3. Gets matching policies based on team_alias, key_alias, and model (via attachments)
3358 4. Combines dynamic policies with attachment-based policies
3359 5. Resolves guardrails from all policies (including inheritance)
3360 6. Adds guardrails to request metadata
3361 7. Tracks applied policies in metadata for response headers
3362 8. Removes "policies" from request body so it's not forwarded to LLM provider
3364 Args:
3365 data: The request data to update
3366 metadata_variable_name: The name of the metadata field in data
3367 user_api_key_dict: The user's API key authentication info
3368 """
3369 from litellm._logging import verbose_proxy_logger
3370 from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body
3371 from litellm.proxy.policy_engine.policy_registry import get_policy_registry
3372 from litellm.types.proxy.policy_engine import PolicyMatchContext
3374 # Extract dynamic policies from request body (if present)
3375 request_body_policies_raw: Final = data.pop("policies", None)
3377 registry: Final = get_policy_registry()
3378 verbose_proxy_logger.debug(
3379 "Policy engine: registry initialized=%s, policy_count=%s",
3380 registry.is_initialized(),
3381 len(registry.get_all_policies()),
3382 )
3383 if not registry.is_initialized(): 3383 ↛ 3384line 3383 didn't jump to line 3384 because the condition on line 3383 was never true
3384 verbose_proxy_logger.debug("Policy engine not initialized, skipping policy matching")
3385 return
3387 # Extract tags and build context
3388 all_tags: Final = get_tags_from_request_body(data) or None
3389 _team_alias: Final = user_api_key_dict.team_alias
3390 _key_alias: Final = user_api_key_dict.key_alias
3391 context: Final = PolicyMatchContext(
3392 team_alias=_team_alias if isinstance(_team_alias, str) else None,
3393 key_alias=_key_alias if isinstance(_key_alias, str) else None,
3394 model=data.get("model"),
3395 tags=all_tags,
3396 )
3398 verbose_proxy_logger.debug(
3399 "Policy engine: matching policies for context team_alias=%s, key_alias=%s, model=%s, tags=%s",
3400 context.team_alias,
3401 context.key_alias,
3402 context.model,
3403 context.tags,
3404 )
3406 # Separate policy names from policy version IDs (policy_<uuid>)
3407 request_body_names: Final[list[str]] = []
3408 request_body_version_ids: Final[list[str]] = []
3409 if request_body_policies_raw and isinstance(request_body_policies_raw, list): 3409 ↛ 3410line 3409 didn't jump to line 3410 because the condition on line 3409 was never true
3410 for item in request_body_policies_raw:
3411 if not isinstance(item, str):
3412 continue
3413 if _is_policy_version_id(item):
3414 policy_id = _extract_policy_id(item)
3415 if policy_id:
3416 request_body_version_ids.append(policy_id)
3417 else:
3418 request_body_names.append(item)
3420 # Resolve policy versions by ID from in-memory cache (populated by sync job; no DB in hot path)
3421 merged_policies: Final[dict[str, Policy]] = dict(registry.get_all_policies())
3422 fetched_policy_names: Final[list[str]] = []
3423 for policy_id in request_body_version_ids: 3423 ↛ 3424line 3423 didn't jump to line 3424 because the loop on line 3423 never started
3424 result = registry.get_policy_by_id_for_request(policy_id=policy_id)
3425 if result is not None:
3426 pname, policy = result
3427 merged_policies[pname] = policy
3428 fetched_policy_names.append(pname)
3429 verbose_proxy_logger.debug("Policy engine: loaded version by ID policy_%s -> %s", policy_id, pname)
3430 else:
3431 verbose_proxy_logger.debug("Policy engine: policy version %s not found in cache, skipping", policy_id)
3433 # Build request body list: names + policy names from fetched versions
3434 request_body_policies: Final = request_body_names + fetched_policy_names
3436 # Match and track policies (with merged_policies when we have version overrides)
3437 applied_policy_names, _ = _match_and_track_policies(
3438 data,
3439 context,
3440 request_body_policies,
3441 policies_override=merged_policies if request_body_version_ids else None,
3442 )
3444 # Resolve and apply guardrails. Use applied_policy_names so request-body policies
3445 # (names + version IDs) are included. Use merged_policies when we have version overrides.
3446 _apply_resolved_guardrails_to_metadata(
3447 data,
3448 metadata_variable_name,
3449 context,
3450 policy_names=applied_policy_names if applied_policy_names else None,
3451 policies=merged_policies if request_body_version_ids else None,
3452 )
3455_ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join(
3456 (
3457 LlmProviders.ANTHROPIC.value,
3458 LlmProviders.BEDROCK.value,
3459 LlmProviders.BEDROCK_MANTLE.value,
3460 LlmProviders.VERTEX_AI.value,
3461 )
3462)
3463_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = LlmProviders.ANTHROPIC.value
3466def add_provider_specific_headers_to_request(
3467 data: dict,
3468 headers: dict,
3469):
3470 from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
3472 anthropic_api_headers: Final = {header: headers[header] for header in ANTHROPIC_API_HEADERS if header in headers}
3473 anthropic_oauth_credential_headers: Final = {
3474 header: value
3475 for header, value in headers.items()
3476 if header.lower() == "authorization" and is_anthropic_oauth_key(value)
3477 }
3479 scoped_headers: Final = [
3480 ProviderSpecificHeader(custom_llm_provider=providers, extra_headers=extra_headers)
3481 for providers, extra_headers in (
3482 (_ANTHROPIC_API_HEADER_PROVIDERS, anthropic_api_headers),
3483 (_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS, anthropic_oauth_credential_headers),
3484 )
3485 if extra_headers
3486 ]
3488 if scoped_headers: 3488 ↛ 3489line 3488 didn't jump to line 3489 because the condition on line 3488 was never true
3489 data["provider_specific_header"] = scoped_headers[0] if len(scoped_headers) == 1 else scoped_headers
3492def _add_otel_traceparent_to_data(data: dict, request: Request):
3493 from litellm.proxy.proxy_server import open_telemetry_logger
3495 if data is None: 3495 ↛ 3496line 3495 didn't jump to line 3496 because the condition on line 3495 was never true
3496 return
3497 if open_telemetry_logger is None: 3497 ↛ 3502line 3497 didn't jump to line 3502 because the condition on line 3497 was always true
3498 # if user is not use OTEL don't send extra_headers
3499 # relevant issue: https://github.com/BerriAI/litellm/issues/4448
3500 return
3502 if litellm.forward_traceparent_to_llm_provider is True:
3503 if request.headers:
3504 if "traceparent" in request.headers:
3505 # we want to forward this to the LLM Provider
3506 # Relevant issue: https://github.com/BerriAI/litellm/issues/4419
3507 # pass this in extra_headers
3508 if "extra_headers" not in data:
3509 data["extra_headers"] = {}
3510 _exra_headers: Final = data["extra_headers"]
3511 if "traceparent" not in _exra_headers:
3512 _exra_headers["traceparent"] = request.headers["traceparent"]