Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/hooks/sensitive_data_routing.py: 31%

71 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Sensitive Data Routing Hook for LiteLLM Proxy. 

3 

4When a guardrail detects sensitive data and is configured with on_sensitive_data='route', 

5this hook manages: 

61. Storing the routing decision (session_id -> model) in cache 

72. Checking incoming requests for existing routing overrides 

83. Applying sticky routing so all subsequent requests in a session go to the same model 

9 

10Works across multiple proxy instances via DualCache (in-memory + Redis). 

11""" 

12 

13import logging 

14import os 

15from typing import TYPE_CHECKING, Any, Final 

16 

17from litellm._logging import verbose_proxy_logger 

18from litellm.caching.caching import DualCache 

19from litellm.caching.redis_cache import log_redis_failure 

20from litellm.integrations.custom_guardrail import get_session_id_from_request_data 

21from litellm.integrations.custom_logger import CustomLogger 

22from litellm.proxy._types import UserAPIKeyAuth 

23 

24if TYPE_CHECKING: 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true

25 from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache 

26 

27 InternalUsageCache = _InternalUsageCache 

28else: 

29 InternalUsageCache = Any 

30 

31 

32SENSITIVE_ROUTING_CACHE_PREFIX: Final = "sensitive_route" 

33DEFAULT_SENSITIVE_ROUTING_TTL: Final = 3600 

34 

35 

36class _PROXY_SensitiveDataRoutingHandler(CustomLogger): 

37 """ 

38 Pre-call hook that checks for existing sensitive data routing overrides 

39 and applies them to incoming requests. 

40 

41 This hook runs early in the pre-call chain and modifies the request's 

42 model field if a routing override exists for the session. 

43 """ 

44 

45 def __init__(self, internal_usage_cache: InternalUsageCache): 

46 self.internal_usage_cache = internal_usage_cache 

47 self.ttl = int( 

48 os.getenv( 

49 "LITELLM_SENSITIVE_ROUTING_TTL", 

50 str(DEFAULT_SENSITIVE_ROUTING_TTL), 

51 ) 

52 ) 

53 

54 def _make_cache_key(self, session_id: str, tenant: str) -> str: 

55 return f"{{{SENSITIVE_ROUTING_CACHE_PREFIX}:{tenant}:{session_id}}}:model" 

56 

57 @staticmethod 

58 def _resolve_tenant(user_api_key_dict: UserAPIKeyAuth | None) -> str: 

59 """ 

60 Identify the authenticated principal the routing override belongs to. 

61 

62 API-key auth is scoped by the hashed key. JWT (and other keyless) auth 

63 has no api_key, so fall back to a stable identity claim. Without this, 

64 every keyless caller would share the ``default`` namespace and could read 

65 or overwrite another principal's session routing. 

66 """ 

67 if user_api_key_dict is None: 

68 return "default" 

69 if user_api_key_dict.api_key: 

70 return user_api_key_dict.api_key 

71 principal: Final = [ 

72 f"{label}:{value}" 

73 for label, value in ( 

74 ("user", user_api_key_dict.user_id), 

75 ("team", user_api_key_dict.team_id), 

76 ("org", user_api_key_dict.org_id), 

77 ) 

78 if value 

79 ] 

80 return "|".join(principal) if principal else "default" 

81 

82 async def _get_routed_model(self, session_id: str, user_api_key_dict: UserAPIKeyAuth | None) -> str | None: 

83 """Get the model this session should be routed to, if any.""" 

84 cache_key: Final = self._make_cache_key(session_id, self._resolve_tenant(user_api_key_dict)) 

85 

86 if self.internal_usage_cache.dual_cache.redis_cache is not None: 

87 try: 

88 result = await self.internal_usage_cache.dual_cache.redis_cache.async_get_cache(key=cache_key) 

89 if result is not None: 

90 routed_model: Final = str(result) 

91 remaining_ttl = await self.internal_usage_cache.dual_cache.redis_cache.async_get_ttl(key=cache_key) 

92 await self.internal_usage_cache.async_set_cache( 

93 key=cache_key, 

94 value=routed_model, 

95 ttl=remaining_ttl if remaining_ttl is not None else self.ttl, 

96 litellm_parent_otel_span=None, 

97 local_only=True, 

98 ) 

99 return routed_model 

100 except Exception as e: 

101 log_redis_failure( 

102 verbose_proxy_logger, 

103 logging.WARNING, 

104 "SensitiveDataRoutingHandler: Redis GET failed, falling back to in-memory", 

105 e, 

106 ) 

107 

108 result = await self.internal_usage_cache.async_get_cache( 

109 key=cache_key, 

110 litellm_parent_otel_span=None, 

111 local_only=True, 

112 ) 

113 if result is not None: 

114 return str(result) 

115 return None 

116 

117 async def set_session_routing( 

118 self, 

119 session_id: str, 

120 model: str, 

121 user_api_key_dict: UserAPIKeyAuth | None = None, 

122 guardrail_name: str | None = None, 

123 ) -> None: 

124 """ 

125 Store a routing override for a session. 

126 

127 Called by guardrails when they detect sensitive data and want to 

128 route the session to a specific model. The override is scoped to the 

129 requesting principal so sessions from different tenants cannot collide. 

130 """ 

131 cache_key: Final = self._make_cache_key(session_id, self._resolve_tenant(user_api_key_dict)) 

132 

133 verbose_proxy_logger.info( 

134 "SensitiveDataRoutingHandler: Setting session routing session_id=%s model=%s guardrail=%s ttl=%s", 

135 session_id, 

136 model, 

137 guardrail_name, 

138 self.ttl, 

139 ) 

140 

141 if self.internal_usage_cache.dual_cache.redis_cache is not None: 

142 try: 

143 await self.internal_usage_cache.dual_cache.redis_cache.async_set_cache( 

144 key=cache_key, 

145 value=model, 

146 ttl=self.ttl, 

147 ) 

148 except Exception as e: 

149 log_redis_failure( 

150 verbose_proxy_logger, 

151 logging.WARNING, 

152 "SensitiveDataRoutingHandler: Redis SET failed, falling back to in-memory", 

153 e, 

154 ) 

155 

156 await self.internal_usage_cache.async_set_cache( 

157 key=cache_key, 

158 value=model, 

159 ttl=self.ttl, 

160 litellm_parent_otel_span=None, 

161 local_only=True, 

162 ) 

163 

164 async def async_pre_call_hook( 

165 self, 

166 user_api_key_dict: UserAPIKeyAuth, 

167 cache: DualCache, 

168 data: dict, 

169 call_type: str, 

170 ) -> Exception | str | dict | None: 

171 """ 

172 Before each LLM call, check if this session has a routing override. 

173 If so, modify the request's model field. 

174 """ 

175 session_id: Final = get_session_id_from_request_data(data) 

176 if session_id is None: 176 ↛ 179line 176 didn't jump to line 179 because the condition on line 176 was always true

177 return None 

178 

179 routed_model: Final = await self._get_routed_model(session_id, user_api_key_dict) 

180 if routed_model is None: 

181 return None 

182 

183 original_model: Final = data.get("model") 

184 if original_model == routed_model: 

185 return None 

186 

187 verbose_proxy_logger.info( 

188 "SensitiveDataRoutingHandler: Applying session routing override " 

189 "session_id=%s original_model=%s routed_model=%s", 

190 session_id, 

191 original_model, 

192 routed_model, 

193 ) 

194 

195 data["model"] = routed_model 

196 

197 metadata: Final = data.get("metadata") or {} 

198 metadata["sensitive_data_routing_applied"] = True 

199 metadata["sensitive_data_routing_original_model"] = original_model 

200 data["metadata"] = metadata 

201 

202 return data