Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/compliance_checks.py: 72%

98 statements  

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

1""" 

2Compliance checker for EU AI Act and GDPR regulations. 

3 

4Provides guardrail-agnostic compliance validation based on guardrail modes 

5and execution results rather than specific guardrail names. 

6""" 

7 

8from typing import Final 

9 

10from litellm.types.proxy.compliance_endpoints import ( 

11 ComplianceCheckRequest, 

12 ComplianceCheckResult, 

13) 

14 

15 

16class ComplianceChecker: 

17 """ 

18 Validates compliance with EU AI Act and GDPR regulations. 

19 

20 Uses guardrail-agnostic checks based on: 

21 - Whether any guardrails ran 

22 - Guardrail execution mode (pre-call, post-call, etc.) 

23 - Whether guardrails intervened/blocked content 

24 - Completeness of audit records 

25 """ 

26 

27 def __init__(self, data: ComplianceCheckRequest): 

28 self.data = data 

29 self.guardrails = tuple(g for g in data.guardrail_information or () if g.get("guardrail_status") != "not_run") 

30 

31 def _get_guardrails_by_mode(self, mode: str) -> list[dict]: 

32 """ 

33 Get all guardrails that ran in a specific mode. 

34 

35 If a guardrail doesn't have a mode specified, it's treated as pre-call 

36 (the most common case). 

37 """ 

38 return [g for g in self.guardrails if self._mode_matches(g.get("guardrail_mode"), mode)] 

39 

40 @staticmethod 

41 def _mode_matches(g_mode: object, mode: str) -> bool: 

42 """ 

43 Return True only when a guardrail with logged ``guardrail_mode`` of 

44 ``g_mode`` is guaranteed to have run in ``mode`` for the audited request. 

45 

46 ``guardrail_mode`` in a spend log can take several shapes because 

47 ``LitellmParams.mode`` is typed ``Union[str, List[str], Mode]``, and 

48 when the event type cannot be inferred at write time the raw config is 

49 logged verbatim. The spend log records the configured mode(s), not the 

50 concrete hook that fired for a given request; a match reports a mode 

51 satisfied only when every configured branch runs in that mode, so True 

52 never claims a hook the guardrail may not have actually executed. 

53 

54 Fails safe: if the guarantee cannot be established (missing default, 

55 divergent per-tag override, or a list that runs in more than one mode), 

56 the guardrail counts for no mode. The precise fix is to log the 

57 resolved event mode and match on it; this is the safe interim. 

58 """ 

59 if g_mode is None: 59 ↛ 61line 59 didn't jump to line 61 because the condition on line 59 was always true

60 return mode == "pre_call" 

61 if isinstance(g_mode, str): 

62 return g_mode == mode 

63 if isinstance(g_mode, (list, tuple)): 

64 return bool(g_mode) and all(m == mode for m in g_mode) 

65 if isinstance(g_mode, dict): 

66 default: Final = g_mode.get("default") 

67 if default is None: 

68 return False 

69 tags: Final = g_mode.get("tags") 

70 tag_branches: Final = list(tags.values()) if isinstance(tags, dict) else [] 

71 

72 def _branch_runs_in_mode(branch: object) -> bool: 

73 if isinstance(branch, str): 

74 return branch == mode 

75 if isinstance(branch, (list, tuple)): 

76 return bool(branch) and all(m == mode for m in branch) 

77 return False 

78 

79 return all(_branch_runs_in_mode(branch) for branch in [default, *tag_branches]) 

80 return False 

81 

82 def _has_guardrail_intervention(self, guardrails: list[dict]) -> bool: 

83 """Check if any guardrail intervened (blocked/masked content).""" 

84 for g in guardrails: 

85 status = g.get("guardrail_status", "") 

86 if status in ["guardrail_intervened", "failed", "blocked"]: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 return True 

88 return False 

89 

90 def _all_guardrails_passed(self, guardrails: list[dict]) -> bool: 

91 """Check if all guardrails passed (no issues detected).""" 

92 if not guardrails: 

93 return False 

94 return all(g.get("guardrail_status") == "success" for g in guardrails) 

95 

96 # ── EU AI Act Helper Methods ──────────────────────────────────────────── 

97 

98 def _check_art_9_guardrails_applied(self) -> ComplianceCheckResult: 

99 """Art. 9: Check if any guardrails were applied.""" 

100 has_guardrails: Final = len(self.guardrails) > 0 

101 return ComplianceCheckResult( 

102 check_name="Guardrails applied", 

103 article="Art. 9", 

104 passed=has_guardrails, 

105 detail=(f"{len(self.guardrails)} guardrail(s) applied" if has_guardrails else "No guardrails applied"), 

106 ) 

107 

108 def _check_art_5_content_screened(self) -> ComplianceCheckResult: 

109 """Art. 5: Check if content was screened before LLM (pre-call).""" 

110 pre_call_guardrails: Final = self._get_guardrails_by_mode("pre_call") 

111 has_pre_call: Final = len(pre_call_guardrails) > 0 

112 return ComplianceCheckResult( 

113 check_name="Content screened before LLM", 

114 article="Art. 5", 

115 passed=has_pre_call, 

116 detail=( 

117 f"{len(pre_call_guardrails)} pre-call guardrail(s) screened content" 

118 if has_pre_call 

119 else "No pre-call screening applied" 

120 ), 

121 ) 

122 

123 def _check_art_12_audit_complete(self) -> ComplianceCheckResult: 

124 """Art. 12: Check if audit record is complete.""" 

125 has_user: Final = bool(self.data.user_id) 

126 has_model: Final = bool(self.data.model) 

127 has_timestamp: Final = bool(self.data.timestamp) 

128 has_guardrails: Final = len(self.guardrails) > 0 

129 audit_complete: Final = has_user and has_model and has_timestamp and has_guardrails 

130 

131 missing: Final = [] 

132 if not has_user: 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was always true

133 missing.append("user_id") 

134 if not has_model: 

135 missing.append("model") 

136 if not has_timestamp: 

137 missing.append("timestamp") 

138 if not has_guardrails: 

139 missing.append("guardrail_results") 

140 

141 return ComplianceCheckResult( 

142 check_name="Audit record complete", 

143 article="Art. 12", 

144 passed=audit_complete, 

145 detail=("All required audit fields present" if audit_complete else f"Missing: {', '.join(missing)}"), 

146 ) 

147 

148 # ── GDPR Helper Methods ────────────────────────────────────────────────── 

149 

150 def _check_art_32_data_protection(self) -> ComplianceCheckResult: 

151 """Art. 32: Check if data protection was applied (pre-call).""" 

152 pre_call_guardrails: Final = self._get_guardrails_by_mode("pre_call") 

153 has_pre_call: Final = len(pre_call_guardrails) > 0 

154 return ComplianceCheckResult( 

155 check_name="Data protection applied", 

156 article="Art. 32", 

157 passed=has_pre_call, 

158 detail=( 

159 f"{len(pre_call_guardrails)} pre-call guardrail(s) protect data" 

160 if has_pre_call 

161 else "No pre-call data protection applied" 

162 ), 

163 ) 

164 

165 def _check_art_5_1c_sensitive_data_protected(self) -> ComplianceCheckResult: 

166 """Art. 5(1)(c): Check if sensitive data was protected.""" 

167 pre_call_guardrails: Final = self._get_guardrails_by_mode("pre_call") 

168 has_intervention: Final = self._has_guardrail_intervention(pre_call_guardrails) 

169 all_passed: Final = self._all_guardrails_passed(pre_call_guardrails) 

170 data_protected: Final = has_intervention or all_passed 

171 

172 if has_intervention: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 detail = "Guardrail intervened to protect sensitive data" 

174 elif all_passed: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 detail = "No sensitive data detected" 

176 else: 

177 detail = "No pre-call guardrails to protect sensitive data" 

178 

179 return ComplianceCheckResult( 

180 check_name="Sensitive data protected", 

181 article="Art. 5(1)(c)", 

182 passed=data_protected, 

183 detail=detail, 

184 ) 

185 

186 def _check_art_30_audit_complete(self) -> ComplianceCheckResult: 

187 """Art. 30: Check if audit record is complete.""" 

188 has_user: Final = bool(self.data.user_id) 

189 has_model: Final = bool(self.data.model) 

190 has_timestamp: Final = bool(self.data.timestamp) 

191 has_guardrails: Final = len(self.guardrails) > 0 

192 audit_complete: Final = has_user and has_model and has_timestamp and has_guardrails 

193 

194 missing: Final = [] 

195 if not has_user: 

196 missing.append("user_id") 

197 if not has_model: 

198 missing.append("model") 

199 if not has_timestamp: 

200 missing.append("timestamp") 

201 if not has_guardrails: 

202 missing.append("guardrail_results") 

203 

204 return ComplianceCheckResult( 

205 check_name="Audit record complete", 

206 article="Art. 30", 

207 passed=audit_complete, 

208 detail=("All required audit fields present" if audit_complete else f"Missing: {', '.join(missing)}"), 

209 ) 

210 

211 # ── Main Compliance Check Methods ──────────────────────────────────────── 

212 

213 def check_eu_ai_act(self) -> list[ComplianceCheckResult]: 

214 """ 

215 Check EU AI Act compliance. 

216 

217 Returns: 

218 List of compliance check results for: 

219 - Art. 9: Guardrails applied 

220 - Art. 5: Content screened before LLM (pre-call screening) 

221 - Art. 12: Audit record complete 

222 """ 

223 return [ 

224 self._check_art_9_guardrails_applied(), 

225 self._check_art_5_content_screened(), 

226 self._check_art_12_audit_complete(), 

227 ] 

228 

229 def check_gdpr(self) -> list[ComplianceCheckResult]: 

230 """ 

231 Check GDPR compliance. 

232 

233 Returns: 

234 List of compliance check results for: 

235 - Art. 32: Data protection applied (pre-call screening) 

236 - Art. 5(1)(c): Sensitive data protected 

237 - Art. 30: Audit record complete 

238 """ 

239 return [ 

240 self._check_art_32_data_protection(), 

241 self._check_art_5_1c_sensitive_data_protected(), 

242 self._check_art_30_audit_complete(), 

243 ]