swiftpy-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,918 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import time
7
+ import uuid
8
+ from collections import defaultdict
9
+ from collections.abc import Awaitable, Callable, Sequence
10
+ from datetime import UTC, datetime
11
+ from typing import Any, Protocol
12
+
13
+ import redis.asyncio as aioredis
14
+ from redis.exceptions import RedisError
15
+
16
+ from swiftpy.ai.exceptions import (
17
+ AIBudgetExceededError,
18
+ AIConnectionError,
19
+ AIContentModerationError,
20
+ AIRateLimitError,
21
+ )
22
+ from swiftpy.ai.pricing import calculate_token_cost
23
+ from swiftpy.ai.providers.base import LLMRequest, LLMResponse
24
+ from swiftpy.core.config import Config
25
+ from swiftpy.core.context import Context
26
+ from swiftpy.support.redis_config import resolve_redis_params
27
+
28
+ logger = logging.getLogger("swiftpy.ai.middleware")
29
+
30
+ NextHandler = Callable[[LLMRequest], Awaitable[LLMResponse]]
31
+
32
+ _BUDGET_KEY_TTL_SECONDS: int = 172_800
33
+ _DEFAULT_MAX_TRACKED_KEYS: int = 10_000
34
+
35
+
36
+ def _extract_user_value(val: Any) -> str:
37
+ if hasattr(val, "id"):
38
+ return str(val.id)
39
+ if isinstance(val, dict) and "id" in val:
40
+ return str(val["id"])
41
+ return str(val)
42
+
43
+
44
+ async def _build_ai_redis_client(
45
+ config: Config | None,
46
+ storage_mode: str,
47
+ ) -> aioredis.Redis | None:
48
+ """Resolve an async Redis client for AI middleware unless in memory or auto-testing mode."""
49
+ if storage_mode == "memory":
50
+ return None
51
+ cfg = config or Config.get_instance()
52
+ app_env = str(cfg.get("app.env", "local")).lower().strip()
53
+ if app_env in ("testing", "test") and storage_mode == "auto":
54
+ return None
55
+
56
+ params = resolve_redis_params(cfg, section="ai")
57
+ return aioredis.from_url(
58
+ params["url"],
59
+ decode_responses=True,
60
+ socket_timeout=3.0,
61
+ socket_connect_timeout=3.0,
62
+ )
63
+
64
+
65
+ async def _scan_and_delete(client: aioredis.Redis, pattern: str) -> None:
66
+ """Non-blocking key cleanup using Redis SCAN cursor iteration."""
67
+ batch: list[str] = []
68
+ async for key in client.scan_iter(match=pattern, count=200):
69
+ batch.append(str(key))
70
+ if len(batch) >= 200:
71
+ await client.delete(*batch)
72
+ batch.clear()
73
+ if batch:
74
+ await client.delete(*batch)
75
+
76
+
77
+ class AIMiddleware(Protocol):
78
+ """Protocol for AI execution pipeline middleware."""
79
+
80
+ async def __call__(
81
+ self,
82
+ request: LLMRequest,
83
+ next_call: NextHandler,
84
+ ) -> LLMResponse:
85
+ """Process an LLM request or intercept/modify response."""
86
+ ...
87
+
88
+
89
+ def resolve_user_id(request: LLMRequest) -> str:
90
+ """
91
+ Extract caller/user identifier from LLMRequest metadata, extra, or task Context.
92
+ Defaults to 'anonymous' if no user context is present.
93
+ """
94
+ for mapping in (request.metadata, request.extra):
95
+ if "user_id" in mapping:
96
+ return str(mapping["user_id"])
97
+ if "user" in mapping:
98
+ return _extract_user_value(mapping["user"])
99
+
100
+ for ctx_key in ("__swiftpy_current_user__", "auth.user", "user"):
101
+ ctx_val = Context.get(ctx_key)
102
+ if ctx_val is not None:
103
+ return _extract_user_value(ctx_val)
104
+
105
+ return "anonymous"
106
+
107
+
108
+ def resolve_tenant_id(request: LLMRequest) -> str:
109
+ """Extract tenant identifier from LLMRequest metadata, extra, or task Context."""
110
+ for mapping in (request.metadata, request.extra):
111
+ if "tenant_id" in mapping:
112
+ return str(mapping["tenant_id"])
113
+ if "tenant" in mapping:
114
+ return str(mapping["tenant"])
115
+
116
+ for ctx_key in ("tenant_id", "tenant", "auth.tenant_id"):
117
+ ctx_val = Context.get(ctx_key)
118
+ if ctx_val is not None:
119
+ return str(ctx_val)
120
+
121
+ return "default"
122
+
123
+
124
+ class AIMiddlewarePipeline:
125
+ """
126
+ Asynchronous middleware execution pipeline for LLM requests.
127
+ Wraps provider invocations with registered pre/post processing layers.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ middlewares: Sequence[AIMiddleware | Callable[..., Any]] | None = None,
133
+ ) -> None:
134
+ self._middlewares: list[AIMiddleware | Callable[..., Any]] = list(
135
+ middlewares or []
136
+ )
137
+
138
+ def use(self, middleware: AIMiddleware | Callable[..., Any]) -> AIMiddlewarePipeline:
139
+ """Append a middleware to this pipeline."""
140
+ self._middlewares.append(middleware)
141
+ return self
142
+
143
+ @property
144
+ def middlewares(self) -> list[AIMiddleware | Callable[..., Any]]:
145
+ """Return registered middlewares."""
146
+ return list(self._middlewares)
147
+
148
+ async def execute(
149
+ self,
150
+ request: LLMRequest,
151
+ terminal_handler: NextHandler,
152
+ ) -> LLMResponse:
153
+ """
154
+ Execute request through all registered middlewares in sequence.
155
+ """
156
+ if not self._middlewares:
157
+ return await terminal_handler(request)
158
+
159
+ handler = terminal_handler
160
+ for mw in reversed(self._middlewares):
161
+ handler = self._wrap(mw, handler)
162
+
163
+ return await handler(request)
164
+
165
+ def _wrap(
166
+ self,
167
+ mw: AIMiddleware | Callable[..., Any],
168
+ next_fn: NextHandler,
169
+ ) -> NextHandler:
170
+ async def wrapped(req: LLMRequest) -> LLMResponse:
171
+ return await mw(req, next_fn)
172
+
173
+ return wrapped
174
+
175
+
176
+ _LUA_SLIDING_WINDOW_RATE_LIMIT = """
177
+ local key = KEYS[1]
178
+ local now = tonumber(ARGV[1])
179
+ local window = tonumber(ARGV[2])
180
+ local max_limit = tonumber(ARGV[3])
181
+ local member = ARGV[4]
182
+ local clear_before = now - window
183
+
184
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', clear_before)
185
+ local current_count = redis.call('ZCARD', key)
186
+
187
+ if current_count < max_limit then
188
+ redis.call('ZADD', key, now, member)
189
+ redis.call('EXPIRE', key, math.ceil(window) + 10)
190
+ return {1, max_limit - current_count - 1, 0}
191
+ else
192
+ local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
193
+ local retry_after = window
194
+ if oldest and #oldest >= 2 then
195
+ local oldest_ts = tonumber(oldest[2])
196
+ if oldest_ts then
197
+ retry_after = (oldest_ts + window) - now
198
+ end
199
+ end
200
+ if retry_after < 0.1 then retry_after = 0.1 end
201
+ return {0, 0, retry_after}
202
+ end
203
+ """
204
+
205
+
206
+ class AIRateLimitMiddleware:
207
+ """
208
+ Per-user / per-tenant request rate limiting middleware.
209
+ Supports in-memory sliding window and distributed Redis backend with
210
+ atomic Lua script evaluation, preventing multi-worker race conditions.
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ requests_per_minute: int = 60,
216
+ window_seconds: float = 60.0,
217
+ redis_client: aioredis.Redis | None = None,
218
+ storage: str = "auto",
219
+ fail_closed: bool = False,
220
+ key_prefix: str = "ai:ratelimit",
221
+ config: Config | None = None,
222
+ max_tracked_keys: int = _DEFAULT_MAX_TRACKED_KEYS,
223
+ ) -> None:
224
+ if requests_per_minute <= 0:
225
+ raise ValueError("requests_per_minute must be greater than 0.")
226
+ if window_seconds <= 0:
227
+ raise ValueError("window_seconds must be greater than 0.")
228
+ if max_tracked_keys <= 0:
229
+ raise ValueError("max_tracked_keys must be greater than 0.")
230
+
231
+ self.requests_per_minute = requests_per_minute
232
+ self.window_seconds = window_seconds
233
+ self.storage_mode = storage.lower().strip()
234
+ self.fail_closed = fail_closed
235
+ self.key_prefix = key_prefix
236
+ self._redis_client = redis_client
237
+ self._config = config
238
+ self._max_tracked_keys = max_tracked_keys
239
+
240
+ self._user_timestamps: dict[tuple[str, str], list[float]] = defaultdict(list)
241
+ self._lock = asyncio.Lock()
242
+
243
+ async def get_redis_client(self) -> aioredis.Redis | None:
244
+ """Resolve Redis client instance if configured."""
245
+ if self._redis_client is not None:
246
+ return self._redis_client
247
+ try:
248
+ self._redis_client = await _build_ai_redis_client(self._config, self.storage_mode)
249
+ return self._redis_client
250
+ except Exception as exc:
251
+ if self.storage_mode == "redis":
252
+ logger.error("Failed to connect to configured Redis: %s", exc)
253
+ raise
254
+ return None
255
+
256
+ def reset(self) -> None:
257
+ """Clear local in-memory tracked rate limit buckets."""
258
+ self._user_timestamps.clear()
259
+
260
+ async def reset_backend(self) -> None:
261
+ """Clear rate limit records from both memory and Redis backend."""
262
+ self.reset()
263
+ client = await self.get_redis_client()
264
+ if client is not None:
265
+ try:
266
+ await _scan_and_delete(client, f"{self.key_prefix}:*")
267
+ except RedisError as exc:
268
+ logger.warning("Failed to reset Redis rate limit keys: %s", exc)
269
+
270
+ async def _check_redis(
271
+ self,
272
+ client: aioredis.Redis,
273
+ tenant_id: str,
274
+ user_id: str,
275
+ ) -> None:
276
+ key = f"{self.key_prefix}:{tenant_id}:{user_id}"
277
+ now = time.time()
278
+ nonce = f"{now}:{uuid.uuid4().hex[:8]}"
279
+
280
+ res = await client.eval(
281
+ _LUA_SLIDING_WINDOW_RATE_LIMIT,
282
+ 1,
283
+ key,
284
+ now,
285
+ self.window_seconds,
286
+ self.requests_per_minute,
287
+ nonce,
288
+ )
289
+
290
+ if isinstance(res, (list, tuple)) and len(res) >= 3:
291
+ allowed = bool(res[0])
292
+ retry_after = float(res[2])
293
+ if not allowed:
294
+ raise AIRateLimitError(
295
+ f"AI rate limit exceeded for user '{user_id}'. "
296
+ f"Max {self.requests_per_minute} requests per {int(self.window_seconds)}s.",
297
+ retry_after=max(0.1, round(retry_after, 2)),
298
+ )
299
+
300
+ async def _check_memory(self, tenant_id: str, user_id: str) -> None:
301
+ now = time.monotonic()
302
+ cutoff = now - self.window_seconds
303
+ scope_key = (tenant_id, user_id)
304
+
305
+ async with self._lock:
306
+ existing = self._user_timestamps.get(scope_key, [])
307
+ timestamps = [t for t in existing if t > cutoff]
308
+ if len(timestamps) >= self.requests_per_minute:
309
+ oldest = timestamps[0]
310
+ self._user_timestamps[scope_key] = timestamps
311
+ retry_after = round(self.window_seconds - (now - oldest), 2)
312
+ raise AIRateLimitError(
313
+ f"AI rate limit exceeded for user '{user_id}'. "
314
+ f"Max {self.requests_per_minute} requests per {int(self.window_seconds)}s.",
315
+ retry_after=max(0.1, retry_after),
316
+ )
317
+
318
+ if scope_key not in self._user_timestamps and len(self._user_timestamps) >= self._max_tracked_keys:
319
+ # Prune empty/expired buckets first before evicting oldest key
320
+ expired_keys = [
321
+ k for k, vals in self._user_timestamps.items()
322
+ if not vals or vals[-1] <= cutoff
323
+ ]
324
+ for ek in expired_keys:
325
+ del self._user_timestamps[ek]
326
+ if len(self._user_timestamps) >= self._max_tracked_keys:
327
+ oldest_key = next(iter(self._user_timestamps))
328
+ del self._user_timestamps[oldest_key]
329
+
330
+ timestamps.append(now)
331
+ self._user_timestamps[scope_key] = timestamps
332
+
333
+ async def __call__(
334
+ self,
335
+ request: LLMRequest,
336
+ next_call: NextHandler,
337
+ ) -> LLMResponse:
338
+ user_id = resolve_user_id(request)
339
+ tenant_id = resolve_tenant_id(request)
340
+
341
+ if self.storage_mode != "memory":
342
+ redis_client = await self.get_redis_client()
343
+ if redis_client is not None:
344
+ try:
345
+ await self._check_redis(redis_client, tenant_id, user_id)
346
+ return await next_call(request)
347
+ except AIRateLimitError:
348
+ raise
349
+ except Exception as exc:
350
+ if self.storage_mode == "redis":
351
+ if self.fail_closed:
352
+ raise AIRateLimitError(
353
+ f"Rate limit storage error (fail-closed): {exc}"
354
+ ) from exc
355
+ logger.warning("Redis rate limit unavailable, failing open: %s", exc)
356
+ return await next_call(request)
357
+ logger.debug("Redis unavailable, falling back to memory rate limiting: %s", exc)
358
+
359
+ await self._check_memory(tenant_id, user_id)
360
+ return await next_call(request)
361
+
362
+
363
+ _LUA_BUDGET_CHECK_AND_RESERVE = """
364
+ local limit1_t = tonumber(ARGV[1])
365
+ local limit1_c = tonumber(ARGV[2])
366
+ local limit2_t = tonumber(ARGV[3])
367
+ local limit2_c = tonumber(ARGV[4])
368
+ local res_t = tonumber(ARGV[5])
369
+ local res_c = tonumber(ARGV[6])
370
+ local is_hard = tonumber(ARGV[7])
371
+ local ttl_sec = tonumber(ARGV[8])
372
+
373
+ local cur1_t = tonumber(redis.call('HGET', KEYS[1], 'tokens') or '0')
374
+ local cur1_c = tonumber(redis.call('HGET', KEYS[1], 'cost') or '0')
375
+ local chk1_t = cur1_t + (is_hard == 1 and res_t or 0)
376
+ local chk1_c = cur1_c + (is_hard == 1 and res_c or 0.0)
377
+
378
+ if limit1_t >= 0 and chk1_t > limit1_t then
379
+ return {0, 1, 'tokens', tostring(cur1_t)}
380
+ end
381
+ if limit1_c >= 0 and chk1_c > (limit1_c + 1e-9) then
382
+ return {0, 1, 'cost', tostring(cur1_c)}
383
+ end
384
+
385
+ if #KEYS >= 2 then
386
+ local cur2_t = tonumber(redis.call('HGET', KEYS[2], 'tokens') or '0')
387
+ local cur2_c = tonumber(redis.call('HGET', KEYS[2], 'cost') or '0')
388
+ local chk2_t = cur2_t + (is_hard == 1 and res_t or 0)
389
+ local chk2_c = cur2_c + (is_hard == 1 and res_c or 0.0)
390
+ if limit2_t >= 0 and chk2_t > limit2_t then
391
+ return {0, 2, 'tokens', tostring(cur2_t)}
392
+ end
393
+ if limit2_c >= 0 and chk2_c > (limit2_c + 1e-9) then
394
+ return {0, 2, 'cost', tostring(cur2_c)}
395
+ end
396
+ end
397
+
398
+ if is_hard == 1 then
399
+ for i = 1, #KEYS do
400
+ local nt = tonumber(redis.call('HGET', KEYS[i], 'tokens') or '0') + res_t
401
+ local nc = tonumber(redis.call('HGET', KEYS[i], 'cost') or '0') + res_c
402
+ redis.call('HSET', KEYS[i], 'tokens', nt, 'cost', string.format('%.6f', nc))
403
+ redis.call('EXPIRE', KEYS[i], ttl_sec)
404
+ end
405
+ end
406
+
407
+ return {1, 0, 'ok', '0'}
408
+ """
409
+
410
+ _LUA_BUDGET_ADJUST = """
411
+ local delta_t = tonumber(ARGV[1])
412
+ local delta_c = tonumber(ARGV[2])
413
+ local ttl_sec = tonumber(ARGV[3])
414
+
415
+ for i = 1, #KEYS do
416
+ local nt = tonumber(redis.call('HGET', KEYS[i], 'tokens') or '0') + delta_t
417
+ if nt < 0 then nt = 0 end
418
+ local nc = tonumber(redis.call('HGET', KEYS[i], 'cost') or '0') + delta_c
419
+ if nc < 0 then nc = 0.0 end
420
+ redis.call('HSET', KEYS[i], 'tokens', nt, 'cost', string.format('%.6f', nc))
421
+ redis.call('EXPIRE', KEYS[i], ttl_sec)
422
+ end
423
+ return 1
424
+ """
425
+
426
+
427
+ class TokenBudgetMiddleware:
428
+ """
429
+ Token and spend budget enforcement middleware.
430
+ Supports atomic preflight reservation (hard limit) and post-reconciliation,
431
+ with distributed Redis support and simultaneous global/per-user/per-tenant budgets.
432
+ """
433
+
434
+ def __init__(
435
+ self,
436
+ daily_token_limit: int | None = None,
437
+ daily_cost_limit: float | None = None,
438
+ global_daily_token_limit: int | None = None,
439
+ global_daily_cost_limit: float | None = None,
440
+ per_user: bool = True,
441
+ mode: str = "soft",
442
+ default_reservation_tokens: int = 250,
443
+ redis_client: aioredis.Redis | None = None,
444
+ storage: str = "auto",
445
+ key_prefix: str = "ai:budget",
446
+ config: Config | None = None,
447
+ max_tracked_keys: int = _DEFAULT_MAX_TRACKED_KEYS,
448
+ ) -> None:
449
+ if daily_token_limit is not None and daily_token_limit <= 0:
450
+ raise ValueError("daily_token_limit must be a positive integer.")
451
+ if daily_cost_limit is not None and daily_cost_limit <= 0:
452
+ raise ValueError("daily_cost_limit must be greater than 0.")
453
+ if global_daily_token_limit is not None and global_daily_token_limit <= 0:
454
+ raise ValueError("global_daily_token_limit must be a positive integer.")
455
+ if global_daily_cost_limit is not None and global_daily_cost_limit <= 0:
456
+ raise ValueError("global_daily_cost_limit must be greater than 0.")
457
+ if max_tracked_keys <= 0:
458
+ raise ValueError("max_tracked_keys must be greater than 0.")
459
+
460
+ self.daily_token_limit = daily_token_limit
461
+ self.daily_cost_limit = daily_cost_limit
462
+ self.global_daily_token_limit = global_daily_token_limit
463
+ self.global_daily_cost_limit = global_daily_cost_limit
464
+ self.per_user = per_user
465
+ self.mode = mode.lower().strip()
466
+ self.default_reservation_tokens = default_reservation_tokens
467
+ self.storage_mode = storage.lower().strip()
468
+ self.key_prefix = key_prefix
469
+ self._redis_client = redis_client
470
+ self._config = config
471
+ self._max_tracked_keys = max_tracked_keys
472
+
473
+ # Keys: (date_str, tenant_id, user_id) or ("__global__", "__global__", date_str)
474
+ self._daily_usage: dict[tuple[str, str, str], dict[str, Any]] = defaultdict(
475
+ lambda: {"tokens": 0, "cost": 0.0}
476
+ )
477
+ self._lock = asyncio.Lock()
478
+
479
+ async def get_redis_client(self) -> aioredis.Redis | None:
480
+ """Resolve Redis client instance if configured."""
481
+ if self._redis_client is not None:
482
+ return self._redis_client
483
+ try:
484
+ self._redis_client = await _build_ai_redis_client(self._config, self.storage_mode)
485
+ return self._redis_client
486
+ except Exception as exc:
487
+ if self.storage_mode == "redis":
488
+ logger.error("Failed to connect to configured Redis for budget: %s", exc)
489
+ raise
490
+ return None
491
+
492
+ def reset(self) -> None:
493
+ """Clear local in-memory daily budget tracking counters."""
494
+ self._daily_usage.clear()
495
+
496
+ async def reset_backend(self) -> None:
497
+ """Clear budget counters in memory and Redis."""
498
+ self.reset()
499
+ client = await self.get_redis_client()
500
+ if client is not None:
501
+ try:
502
+ await _scan_and_delete(client, f"{self.key_prefix}:*")
503
+ except RedisError as exc:
504
+ logger.warning("Failed to reset Redis budget keys: %s", exc)
505
+
506
+ def get_current_usage(self, user_id: str = "anonymous", tenant_id: str | None = None) -> dict[str, Any]:
507
+ """Return today's accumulated tokens and cost in memory."""
508
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
509
+ if self.per_user:
510
+ eff_tenant = tenant_id if tenant_id is not None else resolve_tenant_id(LLMRequest(messages=[]))
511
+ return dict(self._daily_usage[(today, eff_tenant, user_id)])
512
+ return dict(self._daily_usage[("__global__", "__global__", today)])
513
+
514
+ def _estimate_request_usage(self, request: LLMRequest) -> tuple[int, float]:
515
+ prompt_chars = sum(len(m.content or "") for m in request.messages)
516
+ prompt_tokens = max(1, prompt_chars // 4)
517
+ reserved_completion = (
518
+ min(request.max_tokens, 2048)
519
+ if request.max_tokens is not None
520
+ else self.default_reservation_tokens
521
+ )
522
+ total_reserved = prompt_tokens + reserved_completion
523
+ model = request.model or "unknown"
524
+ estimated_cost = calculate_token_cost(model, prompt_tokens, reserved_completion)
525
+ return total_reserved, estimated_cost
526
+
527
+ def _compute_actual_usage(self, request: LLMRequest, response: LLMResponse) -> tuple[int, float]:
528
+ if response.usage is not None:
529
+ p_tokens = response.usage.prompt_tokens
530
+ c_tokens = response.usage.completion_tokens
531
+ act_tokens = response.usage.total_tokens
532
+ else:
533
+ p_chars = sum(len(m.content or "") for m in request.messages)
534
+ p_tokens = max(1, p_chars // 4)
535
+ c_chars = len(response.content or "")
536
+ c_tokens = max(1, c_chars // 4)
537
+ act_tokens = p_tokens + c_tokens
538
+
539
+ act_cost = calculate_token_cost(
540
+ response.model or request.model or "unknown",
541
+ p_tokens,
542
+ c_tokens,
543
+ )
544
+ return act_tokens, act_cost
545
+
546
+ def _prune_memory_buckets(self, today: str) -> None:
547
+ stale_keys = [
548
+ k for k in self._daily_usage
549
+ if (k[0] == "__global__" and k[2] != today) or (k[0] != "__global__" and k[0] != today)
550
+ ]
551
+ for k in stale_keys:
552
+ del self._daily_usage[k]
553
+ while len(self._daily_usage) >= self._max_tracked_keys:
554
+ oldest_key = next(iter(self._daily_usage))
555
+ del self._daily_usage[oldest_key]
556
+
557
+ def _memory_keys_to_update(
558
+ self,
559
+ today: str,
560
+ tenant_id: str,
561
+ user_id: str,
562
+ ) -> list[tuple[str, str, str]]:
563
+ global_key = ("__global__", "__global__", today)
564
+ has_global = (
565
+ self.global_daily_token_limit is not None
566
+ or self.global_daily_cost_limit is not None
567
+ )
568
+ if not self.per_user:
569
+ return [global_key]
570
+ keys: list[tuple[str, str, str]] = [(today, tenant_id, user_id)]
571
+ if has_global:
572
+ keys.append(global_key)
573
+ return keys
574
+
575
+ def _redis_keys_to_update(
576
+ self,
577
+ today: str,
578
+ tenant_id: str,
579
+ user_id: str,
580
+ ) -> list[str]:
581
+ global_key = f"{self.key_prefix}:{today}:global"
582
+ has_global = (
583
+ self.global_daily_token_limit is not None
584
+ or self.global_daily_cost_limit is not None
585
+ )
586
+ if not self.per_user:
587
+ return [global_key]
588
+ keys = [f"{self.key_prefix}:{today}:u:{tenant_id}:{user_id}"]
589
+ if has_global:
590
+ keys.append(global_key)
591
+ return keys
592
+
593
+ def _apply_memory_delta(
594
+ self,
595
+ keys: Sequence[tuple[str, str, str]],
596
+ delta_tokens: int,
597
+ delta_cost: float,
598
+ ) -> None:
599
+ for key in keys:
600
+ bucket = self._daily_usage[key]
601
+ bucket["tokens"] = max(0, int(bucket["tokens"]) + delta_tokens)
602
+ bucket["cost"] = max(0.0, round(float(bucket["cost"]) + delta_cost, 6))
603
+
604
+ async def _run_memory_budget(
605
+ self,
606
+ request: LLMRequest,
607
+ next_call: NextHandler,
608
+ tenant_id: str,
609
+ user_id: str,
610
+ today: str,
611
+ est_tokens: int,
612
+ est_cost: float,
613
+ ) -> LLMResponse:
614
+ is_hard = self.mode == "hard"
615
+ user_key = (today, tenant_id, user_id)
616
+ global_key = ("__global__", "__global__", today)
617
+ update_keys = self._memory_keys_to_update(today, tenant_id, user_id)
618
+
619
+ async with self._lock:
620
+ self._prune_memory_buckets(today)
621
+ if self.per_user:
622
+ u_usage = self._daily_usage[user_key]
623
+ check_tokens = u_usage["tokens"] + (est_tokens if is_hard else 0)
624
+ check_cost = u_usage["cost"] + (est_cost if is_hard else 0.0)
625
+ if self.daily_token_limit is not None and check_tokens > self.daily_token_limit:
626
+ raise AIBudgetExceededError(
627
+ f"Daily token budget of {self.daily_token_limit} exceeded for '{user_id}'. "
628
+ f"Current: {u_usage['tokens']}, requested/reserved: {est_tokens} tokens."
629
+ )
630
+ if self.daily_cost_limit is not None and check_cost > self.daily_cost_limit:
631
+ raise AIBudgetExceededError(
632
+ f"Daily cost budget of ${self.daily_cost_limit:.4f} exceeded for '{user_id}'. "
633
+ f"Current: ${u_usage['cost']:.4f}, estimated: ${est_cost:.4f}."
634
+ )
635
+ else:
636
+ g_usage = self._daily_usage[global_key]
637
+ check_tokens = g_usage["tokens"] + (est_tokens if is_hard else 0)
638
+ check_cost = g_usage["cost"] + (est_cost if is_hard else 0.0)
639
+ if self.daily_token_limit is not None and check_tokens > self.daily_token_limit:
640
+ raise AIBudgetExceededError(
641
+ f"Daily token budget of {self.daily_token_limit} exceeded. "
642
+ f"Current: {g_usage['tokens']}, requested/reserved: {est_tokens} tokens."
643
+ )
644
+ if self.daily_cost_limit is not None and check_cost > self.daily_cost_limit:
645
+ raise AIBudgetExceededError(
646
+ f"Daily cost budget of ${self.daily_cost_limit:.4f} exceeded. "
647
+ f"Current: ${g_usage['cost']:.4f}, estimated: ${est_cost:.4f}."
648
+ )
649
+
650
+ if self.global_daily_token_limit is not None or self.global_daily_cost_limit is not None:
651
+ g_usage = self._daily_usage[global_key]
652
+ g_check_tokens = g_usage["tokens"] + (est_tokens if is_hard else 0)
653
+ g_check_cost = g_usage["cost"] + (est_cost if is_hard else 0.0)
654
+ if (
655
+ self.global_daily_token_limit is not None
656
+ and g_check_tokens > self.global_daily_token_limit
657
+ ):
658
+ raise AIBudgetExceededError(
659
+ f"Global daily token budget of {self.global_daily_token_limit} exceeded. "
660
+ f"Current: {g_usage['tokens']}, requested/reserved: {est_tokens} tokens."
661
+ )
662
+ if (
663
+ self.global_daily_cost_limit is not None
664
+ and g_check_cost > self.global_daily_cost_limit
665
+ ):
666
+ raise AIBudgetExceededError(
667
+ f"Global daily cost budget of ${self.global_daily_cost_limit:.4f} exceeded. "
668
+ f"Current: ${g_usage['cost']:.4f}, estimated: ${est_cost:.4f}."
669
+ )
670
+
671
+ if is_hard:
672
+ self._apply_memory_delta(update_keys, est_tokens, est_cost)
673
+
674
+ try:
675
+ response = await next_call(request)
676
+ except Exception:
677
+ if is_hard:
678
+ async with self._lock:
679
+ self._apply_memory_delta(update_keys, -est_tokens, -est_cost)
680
+ raise
681
+
682
+ act_tokens, act_cost = self._compute_actual_usage(request, response)
683
+ async with self._lock:
684
+ if is_hard:
685
+ self._apply_memory_delta(
686
+ update_keys,
687
+ act_tokens - est_tokens,
688
+ round(act_cost - est_cost, 6),
689
+ )
690
+ else:
691
+ self._apply_memory_delta(update_keys, act_tokens, act_cost)
692
+ return response
693
+
694
+ async def _run_redis_budget(
695
+ self,
696
+ client: aioredis.Redis,
697
+ request: LLMRequest,
698
+ next_call: NextHandler,
699
+ tenant_id: str,
700
+ user_id: str,
701
+ today: str,
702
+ est_tokens: int,
703
+ est_cost: float,
704
+ ) -> LLMResponse:
705
+ is_hard = self.mode == "hard"
706
+ keys = self._redis_keys_to_update(today, tenant_id, user_id)
707
+
708
+ if self.per_user:
709
+ l1_t = self.daily_token_limit if self.daily_token_limit is not None else -1
710
+ l1_c = self.daily_cost_limit if self.daily_cost_limit is not None else -1.0
711
+ l2_t = self.global_daily_token_limit if self.global_daily_token_limit is not None else -1
712
+ l2_c = self.global_daily_cost_limit if self.global_daily_cost_limit is not None else -1.0
713
+ else:
714
+ tok_bounds = [v for v in (self.daily_token_limit, self.global_daily_token_limit) if v is not None]
715
+ cost_bounds = [v for v in (self.daily_cost_limit, self.global_daily_cost_limit) if v is not None]
716
+ l1_t = min(tok_bounds) if tok_bounds else -1
717
+ l1_c = min(cost_bounds) if cost_bounds else -1.0
718
+ l2_t = -1
719
+ l2_c = -1.0
720
+
721
+ res = await client.eval(
722
+ _LUA_BUDGET_CHECK_AND_RESERVE,
723
+ len(keys),
724
+ *keys,
725
+ l1_t,
726
+ l1_c,
727
+ l2_t,
728
+ l2_c,
729
+ est_tokens,
730
+ est_cost,
731
+ 1 if is_hard else 0,
732
+ _BUDGET_KEY_TTL_SECONDS,
733
+ )
734
+ if isinstance(res, (list, tuple)) and len(res) >= 4 and int(res[0]) == 0:
735
+ scope_idx = int(res[1])
736
+ metric = str(res[2])
737
+ cur_val = str(res[3])
738
+ is_global_scope = (scope_idx == 2) or (not self.per_user and self.global_daily_token_limit is not None and metric == "tokens" and (self.daily_token_limit is None or self.global_daily_token_limit <= self.daily_token_limit))
739
+ if metric == "tokens":
740
+ if is_global_scope:
741
+ raise AIBudgetExceededError(
742
+ f"Global daily token budget of {self.global_daily_token_limit} exceeded. "
743
+ f"Current: {cur_val}, requested/reserved: {est_tokens} tokens."
744
+ )
745
+ target_limit = self.daily_token_limit
746
+ suffix = f" for '{user_id}'" if self.per_user else ""
747
+ raise AIBudgetExceededError(
748
+ f"Daily token budget of {target_limit} exceeded{suffix}. "
749
+ f"Current: {cur_val}, requested/reserved: {est_tokens} tokens."
750
+ )
751
+ if is_global_scope:
752
+ raise AIBudgetExceededError(
753
+ f"Global daily cost budget of ${self.global_daily_cost_limit or 0:.4f} exceeded. "
754
+ f"Current: ${float(cur_val):.4f}, estimated: ${est_cost:.4f}."
755
+ )
756
+ suffix = f" for '{user_id}'" if self.per_user else ""
757
+ raise AIBudgetExceededError(
758
+ f"Daily cost budget of ${self.daily_cost_limit or 0:.4f} exceeded{suffix}. "
759
+ f"Current: ${float(cur_val):.4f}, estimated: ${est_cost:.4f}."
760
+ )
761
+
762
+ try:
763
+ response = await next_call(request)
764
+ except Exception:
765
+ if is_hard:
766
+ try:
767
+ await client.eval(
768
+ _LUA_BUDGET_ADJUST,
769
+ len(keys),
770
+ *keys,
771
+ -est_tokens,
772
+ -est_cost,
773
+ _BUDGET_KEY_TTL_SECONDS,
774
+ )
775
+ except RedisError as rb_exc:
776
+ logger.warning("Failed Redis budget rollback: %s", rb_exc)
777
+ raise
778
+
779
+ act_tokens, act_cost = self._compute_actual_usage(request, response)
780
+ delta_t = (act_tokens - est_tokens) if is_hard else act_tokens
781
+ delta_c = round(act_cost - est_cost, 6) if is_hard else act_cost
782
+ try:
783
+ await client.eval(
784
+ _LUA_BUDGET_ADJUST,
785
+ len(keys),
786
+ *keys,
787
+ delta_t,
788
+ delta_c,
789
+ _BUDGET_KEY_TTL_SECONDS,
790
+ )
791
+ except RedisError as adj_exc:
792
+ if self.storage_mode == "redis":
793
+ raise AIConnectionError(
794
+ f"Failed to record budget usage in Redis: {adj_exc}",
795
+ provider="redis",
796
+ ) from adj_exc
797
+ logger.warning("Failed Redis budget reconciliation: %s", adj_exc)
798
+ return response
799
+
800
+ async def __call__(
801
+ self,
802
+ request: LLMRequest,
803
+ next_call: NextHandler,
804
+ ) -> LLMResponse:
805
+ user_id = resolve_user_id(request)
806
+ tenant_id = resolve_tenant_id(request)
807
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
808
+ est_tokens, est_cost = self._estimate_request_usage(request)
809
+
810
+ if self.storage_mode != "memory":
811
+ redis_client = await self.get_redis_client()
812
+ if redis_client is not None:
813
+ try:
814
+ return await self._run_redis_budget(
815
+ redis_client,
816
+ request,
817
+ next_call,
818
+ tenant_id,
819
+ user_id,
820
+ today,
821
+ est_tokens,
822
+ est_cost,
823
+ )
824
+ except (AIBudgetExceededError, AIConnectionError):
825
+ raise
826
+ except Exception as exc:
827
+ if self.storage_mode == "redis":
828
+ raise AIConnectionError(
829
+ f"Budget Redis error: {exc}",
830
+ provider="redis",
831
+ ) from exc
832
+ logger.debug("Redis unavailable, falling back to memory budget: %s", exc)
833
+
834
+ return await self._run_memory_budget(
835
+ request,
836
+ next_call,
837
+ tenant_id,
838
+ user_id,
839
+ today,
840
+ est_tokens,
841
+ est_cost,
842
+ )
843
+
844
+
845
+ ModerationChecker = Callable[[str], Awaitable[bool | tuple[bool, str]]]
846
+
847
+
848
+ class ContentModerationMiddleware:
849
+ """
850
+ Content moderation middleware executing policy checks BEFORE the LLM call.
851
+ Inspects messages for banned patterns or delegates to a moderation checker.
852
+ Raises AIContentModerationError without contacting the LLM if flagged.
853
+ """
854
+
855
+ def __init__(
856
+ self,
857
+ checker: ModerationChecker | None = None,
858
+ banned_words: Sequence[str] | None = None,
859
+ patterns: Sequence[re.Pattern[str]] | None = None,
860
+ fail_closed: bool = True,
861
+ check_all_roles: bool = True,
862
+ ) -> None:
863
+ self.checker = checker
864
+ self.banned_words = [w.lower().strip() for w in (banned_words or []) if w.strip()]
865
+ self.patterns = list(patterns or [])
866
+ self.fail_closed = fail_closed
867
+ self.check_all_roles = check_all_roles
868
+
869
+ async def check_content(self, text: str) -> tuple[bool, str | None]:
870
+ """
871
+ Evaluate text against configured banned words, patterns, and custom checker.
872
+ Returns (is_violating, reason).
873
+ """
874
+ lower = text.lower()
875
+ for word in self.banned_words:
876
+ if word in lower:
877
+ return True, f"Contains banned keyword: '{word}'"
878
+
879
+ for pat in self.patterns:
880
+ if pat.search(text):
881
+ return True, f"Matched prohibited pattern: '{pat.pattern}'"
882
+
883
+ if self.checker is not None:
884
+ try:
885
+ res = await self.checker(text)
886
+ if isinstance(res, tuple):
887
+ flagged, reason = res
888
+ if flagged:
889
+ return True, reason
890
+ elif bool(res):
891
+ return True, "Flagged by content moderation policy"
892
+ except Exception as exc:
893
+ if self.fail_closed:
894
+ raise AIContentModerationError(
895
+ f"Content moderation service unavailable: {exc}"
896
+ ) from exc
897
+ logger.warning(
898
+ "Content moderation checker failed, failing open: %s", exc
899
+ )
900
+
901
+ return False, None
902
+
903
+ async def __call__(
904
+ self,
905
+ request: LLMRequest,
906
+ next_call: NextHandler,
907
+ ) -> LLMResponse:
908
+ for msg in request.messages:
909
+ if not self.check_all_roles and msg.role != "user":
910
+ continue
911
+ content = msg.content or ""
912
+ is_violating, reason = await self.check_content(content)
913
+ if is_violating:
914
+ raise AIContentModerationError(
915
+ f"Content moderation violation on message role '{msg.role}': {reason}"
916
+ )
917
+
918
+ return await next_call(request)