diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index b37be2b5bc..93ae28bf2a 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -925,7 +925,7 @@ To enable session continuity for Responses API in your LiteLLM proxy, set `optio Notes: - User-key affinity is keyed on `metadata.user_api_key_hash` (the API key hash). The OpenAI `user` request parameter is an end-user identifier and is intentionally not used for deployment affinity. -- Session-ID affinity is keyed on `metadata.session_id`. For proxy requests, this can be passed via the `x-litellm-session-id` HTTP header. For Python SDK requests, you can pass it via `litellm_metadata={"session_id": "value"}` in request args. +- Session-ID affinity is keyed on `metadata.session_id`. For proxy requests, this can be passed via the `x-litellm-session-id` or `x-litellm-trace-id` HTTP header (they are interchangeable for call chaining). For Python SDK requests, you can pass it via `litellm_metadata={"session_id": "value"}` in request args. - `user_api_key_hash` is already SHA-256, and is used as-is (no double hashing). - Affinity is scoped by a stable model identifier (the model-map key, e.g. `model_map_information.model_map_key`) so model aliases map to the same stickiness bucket. - The mapping TTL is controlled by `deployment_affinity_ttl_seconds` (configured on Router init / proxy startup). diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 36a8dfdb5a..c91e4b6de1 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -1,6 +1,5 @@ from typing import Optional - # Pre-define optional kwargs keys as frozenset for O(1) lookups # These are extracted from kwargs only if present, avoiding unnecessary .get() calls _OPTIONAL_KWARGS_KEYS = frozenset({ @@ -95,6 +94,13 @@ def get_litellm_params( litellm_request_debug: Optional[bool] = None, **kwargs, ) -> dict: + # Derive litellm_session_id / litellm_trace_id from metadata when not provided (call chaining) + _meta = metadata or {} + if litellm_session_id is None: + litellm_session_id = _meta.get("session_id") or _meta.get("trace_id") + if litellm_trace_id is None: + litellm_trace_id = _meta.get("trace_id") or _meta.get("session_id") + # Build base dict with explicit parameters (always included) litellm_params = { "acompletion": acompletion, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 5e5a6cea1b..0be89ac67f 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -133,8 +133,8 @@ from ..integrations.azure_sentinel.azure_sentinel import AzureSentinelLogger from ..integrations.azure_storage.azure_storage import AzureBlobStorageLogger from ..integrations.custom_prompt_management import CustomPromptManagement from ..integrations.datadog.datadog import DataDogLogger -from ..integrations.datadog.datadog_metrics import DatadogMetricsLogger from ..integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger +from ..integrations.datadog.datadog_metrics import DatadogMetricsLogger from ..integrations.dotprompt import DotpromptManager from ..integrations.dynamodb import DyanmoDBLogger from ..integrations.galileo import GalileoObserve @@ -5045,8 +5045,15 @@ class StandardLoggingPayloadSetup: return str(dynamic_litellm_session_id) elif dynamic_litellm_trace_id: return str(dynamic_litellm_trace_id) - else: - return logging_obj.litellm_trace_id + # Fallback: use metadata.session_id or metadata.trace_id for call chaining + metadata = litellm_params.get("metadata") or {} + metadata_session_id = metadata.get("session_id") + metadata_trace_id = metadata.get("trace_id") + if metadata_session_id: + return str(metadata_session_id) + if metadata_trace_id: + return str(metadata_trace_id) + return logging_obj.litellm_trace_id @staticmethod def _get_user_agent_tags(proxy_server_request: dict) -> Optional[List[str]]: diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 7f0f28ca37..dbc4ce7668 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -10,12 +10,16 @@ import litellm from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._service_logger import ServiceLogging from litellm.litellm_core_utils.safe_json_loads import safe_json_loads -from litellm.proxy._types import (AddTeamCallback, CommonProxyErrors, - LitellmDataForBackendLLMCall, - LitellmUserRoles, SpecialHeaders, - TeamCallbackMetadata, UserAPIKeyAuth) -from litellm.proxy.common_utils.http_parsing_utils import \ - _safe_get_request_headers +from litellm.proxy._types import ( + AddTeamCallback, + CommonProxyErrors, + LitellmDataForBackendLLMCall, + LitellmUserRoles, + SpecialHeaders, + TeamCallbackMetadata, + UserAPIKeyAuth, +) +from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers # Cache special headers as a frozenset for O(1) lookup performance _SPECIAL_HEADERS_CACHE = frozenset( @@ -24,9 +28,12 @@ _SPECIAL_HEADERS_CACHE = frozenset( from litellm.router import Router from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS from litellm.types.services import ServiceTypes -from litellm.types.utils import (LlmProviders, ProviderSpecificHeader, - StandardLoggingUserAPIKeyMetadata, - SupportedCacheControls) +from litellm.types.utils import ( + LlmProviders, + ProviderSpecificHeader, + StandardLoggingUserAPIKeyMetadata, + SupportedCacheControls, +) service_logger_obj = ServiceLogging() # used for tracking latency on OTEL @@ -571,9 +578,10 @@ class LiteLLMProxyRequestSetup: ######################################################################################### agent_id_from_header = headers.get("x-litellm-agent-id") - trace_id_from_header = headers.get("x-litellm-trace-id") - - session_id_from_header = headers.get("x-litellm-session-id") + # x-litellm-trace-id and x-litellm-session-id are interchangeable for call chaining + chain_id = headers.get("x-litellm-trace-id") or headers.get( + "x-litellm-session-id" + ) if agent_id_from_header: metadata_from_headers["agent_id"] = agent_id_from_header @@ -581,16 +589,13 @@ class LiteLLMProxyRequestSetup: f"Extracted agent_id from header: {agent_id_from_header}" ) - if trace_id_from_header: - metadata_from_headers["trace_id"] = trace_id_from_header + if chain_id: + metadata_from_headers["trace_id"] = chain_id + metadata_from_headers["session_id"] = chain_id + data["litellm_session_id"] = chain_id + data["litellm_trace_id"] = chain_id verbose_proxy_logger.debug( - f"Extracted trace_id from header: {trace_id_from_header}" - ) - - if session_id_from_header: - metadata_from_headers["session_id"] = session_id_from_header - verbose_proxy_logger.debug( - f"Extracted session_id from header: {session_id_from_header}" + f"Extracted chain_id from header (trace-id/session-id): {chain_id}" ) if isinstance(data[_metadata_variable_name], dict): @@ -664,7 +669,8 @@ class LiteLLMProxyRequestSetup: return data from litellm.proxy._types import ( LiteLLM_ManagementEndpoint_MetadataFields, - LiteLLM_ManagementEndpoint_MetadataFields_Premium) + LiteLLM_ManagementEndpoint_MetadataFields_Premium, + ) # ignore any special fields added_metadata = {} @@ -833,8 +839,7 @@ async def add_litellm_data_to_request( # noqa: PLR0915 """ from litellm.proxy.proxy_server import llm_router, premium_user - from litellm.types.proxy.litellm_pre_call_utils import (RedactedDict, - SecretFields) + from litellm.types.proxy.litellm_pre_call_utils import RedactedDict, SecretFields _raw_headers: Dict[str, str] = RedactedDict(_safe_get_request_headers(request)) @@ -1509,8 +1514,7 @@ async def move_guardrails_to_metadata( # Only check policy engine if no local config (avoid import + registry lookup) if not (has_key_config or has_team_config or has_request_config): - from litellm.proxy.policy_engine.policy_registry import \ - get_policy_registry + from litellm.proxy.policy_engine.policy_registry import get_policy_registry if not get_policy_registry().is_initialized(): # Nothing configured anywhere - clean up request body fields and return @@ -1574,16 +1578,14 @@ async def move_guardrails_to_metadata( def _is_policy_version_id(s: str) -> bool: """Return True if string is a policy version ID (starts with policy_ prefix).""" - from litellm.proxy.policy_engine.policy_registry import \ - POLICY_VERSION_ID_PREFIX + from litellm.proxy.policy_engine.policy_registry import POLICY_VERSION_ID_PREFIX return isinstance(s, str) and s.startswith(POLICY_VERSION_ID_PREFIX) def _extract_policy_id(s: str) -> Optional[str]: """Extract raw UUID from policy_ string, or None if not a valid version ID.""" - from litellm.proxy.policy_engine.policy_registry import \ - POLICY_VERSION_ID_PREFIX + from litellm.proxy.policy_engine.policy_registry import POLICY_VERSION_ID_PREFIX if not _is_policy_version_id(s): return None @@ -1604,9 +1606,10 @@ def _match_and_track_policies( """ from litellm._logging import verbose_proxy_logger from litellm.proxy.common_utils.callback_utils import ( - add_policy_sources_to_metadata, add_policy_to_applied_policies_header) - from litellm.proxy.policy_engine.attachment_registry import \ - get_attachment_registry + add_policy_sources_to_metadata, + add_policy_to_applied_policies_header, + ) + from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher # Get matching policies via attachments (with match reasons for attribution) @@ -1751,8 +1754,7 @@ async def add_guardrails_from_policy_engine( user_api_key_dict: The user's API key authentication info """ from litellm._logging import verbose_proxy_logger - from litellm.proxy.common_utils.http_parsing_utils import \ - get_tags_from_request_body + from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body from litellm.proxy.policy_engine.policy_registry import get_policy_registry from litellm.types.proxy.policy_engine import PolicyMatchContext diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 8abc6bfe07..bc13cea939 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -11,11 +11,16 @@ from fastapi import Request import litellm from litellm.proxy._types import TeamCallbackMetadata, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import ( - KeyAndTeamLoggingSettings, LiteLLMProxyRequestSetup, - _get_dynamic_logging_metadata, _get_enforced_params, - _get_metadata_variable_name, _update_model_if_key_alias_exists, - add_guardrails_from_policy_engine, add_litellm_data_to_request, - check_if_token_is_service_account) + KeyAndTeamLoggingSettings, + LiteLLMProxyRequestSetup, + _get_dynamic_logging_metadata, + _get_enforced_params, + _get_metadata_variable_name, + _update_model_if_key_alias_exists, + add_guardrails_from_policy_engine, + add_litellm_data_to_request, + check_if_token_is_service_account, +) sys.path.insert( 0, os.path.abspath("../../..") @@ -154,8 +159,7 @@ def test_get_enforced_params( @pytest.mark.asyncio async def test_add_litellm_data_to_request_parses_string_metadata(): - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup request_mock = MagicMock(spec=Request) @@ -201,8 +205,7 @@ async def test_add_litellm_data_to_request_parses_string_metadata(): @pytest.mark.asyncio async def test_add_litellm_data_to_request_user_spend_and_budget(): - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request request_mock = MagicMock(spec=Request) request_mock.url.path = "/v1/completions" @@ -240,8 +243,7 @@ async def test_add_litellm_data_to_request_user_spend_and_budget(): @pytest.mark.asyncio async def test_add_litellm_data_to_request_audio_transcription_multipart(): - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup request mock for /v1/audio/transcriptions request_mock = MagicMock(spec=Request) @@ -306,8 +308,7 @@ async def test_add_litellm_data_to_request_disabled_callbacks(): """ Test that litellm_disabled_callbacks from key metadata is properly added to the request data. """ - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request request_mock = MagicMock(spec=Request) @@ -360,8 +361,7 @@ async def test_add_litellm_data_to_request_disabled_callbacks_empty(): """ Test that litellm_disabled_callbacks is not added when it's empty. """ - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request request_mock = MagicMock(spec=Request) @@ -413,8 +413,7 @@ async def test_add_litellm_data_to_request_disabled_callbacks_not_present(): """ Test that litellm_disabled_callbacks is not added when it's not present in metadata. """ - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request request_mock = MagicMock(spec=Request) @@ -466,8 +465,7 @@ async def test_add_litellm_data_to_request_disabled_callbacks_invalid_type(): """ Test that litellm_disabled_callbacks is not added when it's not a list. """ - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request request_mock = MagicMock(spec=Request) @@ -519,8 +517,7 @@ async def test_add_litellm_data_to_request_disabled_callbacks_with_logging_setti """ Test that litellm_disabled_callbacks works correctly alongside logging settings. """ - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request request_mock = MagicMock(spec=Request) @@ -1030,8 +1027,7 @@ from unittest.mock import AsyncMock from fastapi.responses import Response from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy.common_request_processing import \ - ProxyBaseLLMRequestProcessing +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.utils import ProxyLogging from litellm.types.utils import StandardLoggingPayload @@ -1149,6 +1145,47 @@ async def test_add_litellm_metadata_from_request_headers(): litellm.callbacks = original_callbacks +def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain_id(): + """x-litellm-trace-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining.""" + headers = {"x-litellm-trace-id": "foo"} + data = {"metadata": {}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=headers, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["trace_id"] == "foo" + assert data["metadata"]["session_id"] == "foo" + assert data["litellm_session_id"] == "foo" + assert data["litellm_trace_id"] == "foo" + + +def test_add_litellm_metadata_from_request_headers_x_litellm_session_id_sets_chain_id(): + """x-litellm-session-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining.""" + headers = {"x-litellm-session-id": "bar"} + data = {"metadata": {}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=headers, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["trace_id"] == "bar" + assert data["metadata"]["session_id"] == "bar" + assert data["litellm_session_id"] == "bar" + assert data["litellm_trace_id"] == "bar" + + +def test_add_litellm_metadata_from_request_headers_both_headers_trace_id_precedence(): + """When both x-litellm-trace-id and x-litellm-session-id are present, trace-id takes precedence for chain_id.""" + headers = { + "x-litellm-trace-id": "trace-value", + "x-litellm-session-id": "session-value", + } + data = {"metadata": {}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=headers, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["trace_id"] == "trace-value" + assert data["metadata"]["session_id"] == "trace-value" + assert data["litellm_session_id"] == "trace-value" + assert data["litellm_trace_id"] == "trace-value" + def test_get_internal_user_header_from_mapping_returns_expected_header(): mappings = [ @@ -1407,8 +1444,7 @@ async def test_embedding_header_forwarding_with_model_group(): importlib.reload(pre_call_utils_module) # Re-import the function after reload to get the fresh version - from litellm.proxy.litellm_pre_call_utils import \ - add_litellm_data_to_request + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Setup mock request for embeddings request_mock = MagicMock(spec=Request) @@ -1542,11 +1578,13 @@ async def test_add_guardrails_from_policy_engine(): Test that add_guardrails_from_policy_engine adds guardrails from matching policies and tracks applied policies in metadata. """ - from litellm.proxy.policy_engine.attachment_registry import \ - get_attachment_registry + from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry from litellm.proxy.policy_engine.policy_registry import get_policy_registry - from litellm.types.proxy.policy_engine import (Policy, PolicyAttachment, - PolicyGuardrails) + from litellm.types.proxy.policy_engine import ( + Policy, + PolicyAttachment, + PolicyGuardrails, + ) # Setup test data data = { @@ -1659,8 +1697,7 @@ async def test_add_guardrails_from_policy_engine_policy_version_by_id(): Test that add_guardrails_from_policy_engine executes a specific policy version when policy_ is passed in the request body. """ - from litellm.proxy.policy_engine.attachment_registry import \ - get_attachment_registry + from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry from litellm.proxy.policy_engine.policy_registry import get_policy_registry from litellm.types.proxy.policy_engine import Policy, PolicyGuardrails @@ -1729,6 +1766,7 @@ async def test_bearer_token_not_in_debug_logs(): """ import logging from io import StringIO + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.proxy.proxy_server import ProxyConfig