diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 15d1876e5a2..9ddc7ce2caf 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -45,6 +45,7 @@ _EXPLICIT_SESSION_HEADERS = frozenset({"x-litellm-trace-id", "x-litellm-session- # Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores # (covers UUIDs and most common session-id formats). _SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]{8,}$") +_ANTHROPIC_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") def _sanitize_for_log(value: Any) -> str: @@ -426,6 +427,30 @@ def get_chain_id_from_headers(headers: Optional[Dict[str, str]]) -> Optional[str ) +def _get_anthropic_session_id_from_metadata(metadata: object) -> str | None: + if not isinstance(metadata, dict): + return None + + user_id = metadata.get("user_id") + if isinstance(user_id, dict): + session_id = user_id.get("session_id") + if isinstance(session_id, str) and _ANTHROPIC_SESSION_ID_VALUE_RE.fullmatch(session_id): + return session_id + return None + if not isinstance(user_id, str): + return None + + session_marker = "_session_" + session_marker_index = user_id.rfind(session_marker) + if session_marker_index == -1: + return None + + session_id = user_id[session_marker_index + len(session_marker) :] + if not session_id or not _ANTHROPIC_SESSION_ID_VALUE_RE.fullmatch(session_id): + return None + return session_id + + def is_claude_code_user_agent(user_agent: str) -> bool: """Claude Code identifies itself as ``claude-cli/ ...``; the IDE extensions and the Agent SDK run through the same CLI and share that prefix.""" @@ -935,6 +960,15 @@ class LiteLLMProxyRequestSetup: data["litellm_session_id"] = chain_id data["litellm_trace_id"] = chain_id verbose_proxy_logger.debug(f"Extracted chain_id from header (trace-id/session-id): {chain_id}") + else: + body_metadata = data.get("metadata") + session_id = _get_anthropic_session_id_from_metadata(body_metadata) + if session_id: + metadata_from_headers["session_id"] = session_id + data["litellm_session_id"] = session_id + if isinstance(body_metadata, dict) and isinstance(body_metadata.get("user_id"), dict): + body_metadata["user_id"] = session_id + verbose_proxy_logger.debug("Extracted session_id from Anthropic metadata.user_id") if isinstance(data[_metadata_variable_name], dict): data[_metadata_variable_name].update(metadata_from_headers) 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 d2b8b7ec23d..47879ee96ad 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -2560,6 +2560,108 @@ def test_add_litellm_metadata_from_request_headers_generic_session_id_header(): assert data["litellm_trace_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" +def test_add_litellm_metadata_from_anthropic_user_id_sets_session_id(): + data = { + "metadata": { + "user_id": "user_abc123_account__session_e96634a3-fa28-4083-b354-55542e2dca01" + } + } + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={}, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["session_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" + assert data["litellm_session_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" + assert "litellm_trace_id" not in data + + +def test_add_litellm_metadata_from_anthropic_user_id_dict_sets_session_id(): + data = { + "metadata": { + "user_id": { + "device_id": "device", + "account_uuid": "account", + "session_id": "sess_4f8c1d2a-1234", + } + } + } + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={}, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["user_id"] == "sess_4f8c1d2a-1234" + assert data["metadata"]["session_id"] == "sess_4f8c1d2a-1234" + assert data["litellm_session_id"] == "sess_4f8c1d2a-1234" + assert "litellm_trace_id" not in data + + +def test_add_litellm_metadata_from_headers_session_id_beats_anthropic_user_id(): + data = { + "metadata": { + "user_id": "user_abc123_account__session_body-session-id", + } + } + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={"x-litellm-session-id": "header-session-id"}, + data=data, + _metadata_variable_name="metadata", + ) + assert data["metadata"]["session_id"] == "header-session-id" + assert data["litellm_session_id"] == "header-session-id" + assert data["litellm_trace_id"] == "header-session-id" + + +def test_add_litellm_metadata_from_headers_session_id_beats_anthropic_user_id_dict(): + data = { + "metadata": { + "user_id": { + "session_id": "body-session-id", + } + } + } + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={"x-litellm-session-id": "header-session-id"}, + data=data, + _metadata_variable_name="metadata", + ) + assert data["metadata"]["session_id"] == "header-session-id" + assert data["litellm_session_id"] == "header-session-id" + assert data["litellm_trace_id"] == "header-session-id" + + +@pytest.mark.parametrize( + "user_id", + [ + "user_abc123_account__session_", + "user_abc123_account_", + "user_abc123_account__session_invalid!", + ], +) +def test_add_litellm_metadata_from_anthropic_user_id_ignores_invalid_session_id(user_id: str): + data = {"metadata": {"user_id": user_id}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={}, data=data, _metadata_variable_name="metadata" + ) + assert data == {"metadata": {"user_id": user_id}} + + +@pytest.mark.parametrize( + "user_id", + [ + {}, + {"session_id": 123}, + {"session_id": "invalid session id"}, + {"session_id": ""}, + ], +) +def test_add_litellm_metadata_from_anthropic_user_id_dict_ignores_invalid_session_id( + user_id: object, +): + data = {"metadata": {"user_id": user_id}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers={}, data=data, _metadata_variable_name="metadata" + ) + assert data == {"metadata": {"user_id": user_id}} + + def test_add_litellm_metadata_from_request_headers_explicit_header_beats_generic(): """Explicit x-litellm-trace-id wins over a generic x-*-session-id header.""" headers = {