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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/ai/middleware.py
ADDED
|
@@ -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)
|