proxilion 0.0.1__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.
- proxilion/__init__.py +136 -0
- proxilion/audit/__init__.py +133 -0
- proxilion/audit/base_exporters.py +527 -0
- proxilion/audit/compliance/__init__.py +130 -0
- proxilion/audit/compliance/base.py +457 -0
- proxilion/audit/compliance/eu_ai_act.py +603 -0
- proxilion/audit/compliance/iso27001.py +544 -0
- proxilion/audit/compliance/soc2.py +491 -0
- proxilion/audit/events.py +493 -0
- proxilion/audit/explainability.py +1173 -0
- proxilion/audit/exporters/__init__.py +58 -0
- proxilion/audit/exporters/aws_s3.py +636 -0
- proxilion/audit/exporters/azure_storage.py +608 -0
- proxilion/audit/exporters/cloud_base.py +468 -0
- proxilion/audit/exporters/gcp_storage.py +570 -0
- proxilion/audit/exporters/multi_exporter.py +498 -0
- proxilion/audit/hash_chain.py +652 -0
- proxilion/audit/logger.py +543 -0
- proxilion/caching/__init__.py +49 -0
- proxilion/caching/tool_cache.py +633 -0
- proxilion/context/__init__.py +73 -0
- proxilion/context/context_window.py +556 -0
- proxilion/context/message_history.py +505 -0
- proxilion/context/session.py +735 -0
- proxilion/contrib/__init__.py +51 -0
- proxilion/contrib/anthropic.py +609 -0
- proxilion/contrib/google.py +1012 -0
- proxilion/contrib/langchain.py +641 -0
- proxilion/contrib/mcp.py +893 -0
- proxilion/contrib/openai.py +646 -0
- proxilion/core.py +3058 -0
- proxilion/decorators.py +966 -0
- proxilion/engines/__init__.py +287 -0
- proxilion/engines/base.py +266 -0
- proxilion/engines/casbin_engine.py +412 -0
- proxilion/engines/opa_engine.py +493 -0
- proxilion/engines/simple.py +437 -0
- proxilion/exceptions.py +887 -0
- proxilion/guards/__init__.py +54 -0
- proxilion/guards/input_guard.py +522 -0
- proxilion/guards/output_guard.py +634 -0
- proxilion/observability/__init__.py +198 -0
- proxilion/observability/cost_tracker.py +866 -0
- proxilion/observability/hooks.py +683 -0
- proxilion/observability/metrics.py +798 -0
- proxilion/observability/session_cost_tracker.py +1063 -0
- proxilion/policies/__init__.py +67 -0
- proxilion/policies/base.py +304 -0
- proxilion/policies/builtin.py +486 -0
- proxilion/policies/registry.py +376 -0
- proxilion/providers/__init__.py +201 -0
- proxilion/providers/adapter.py +468 -0
- proxilion/providers/anthropic_adapter.py +330 -0
- proxilion/providers/gemini_adapter.py +391 -0
- proxilion/providers/openai_adapter.py +294 -0
- proxilion/py.typed +0 -0
- proxilion/resilience/__init__.py +81 -0
- proxilion/resilience/degradation.py +615 -0
- proxilion/resilience/fallback.py +555 -0
- proxilion/resilience/retry.py +554 -0
- proxilion/scheduling/__init__.py +57 -0
- proxilion/scheduling/priority_queue.py +419 -0
- proxilion/scheduling/scheduler.py +459 -0
- proxilion/security/__init__.py +244 -0
- proxilion/security/agent_trust.py +968 -0
- proxilion/security/behavioral_drift.py +794 -0
- proxilion/security/cascade_protection.py +869 -0
- proxilion/security/circuit_breaker.py +428 -0
- proxilion/security/cost_limiter.py +690 -0
- proxilion/security/idor_protection.py +460 -0
- proxilion/security/intent_capsule.py +849 -0
- proxilion/security/intent_validator.py +495 -0
- proxilion/security/memory_integrity.py +767 -0
- proxilion/security/rate_limiter.py +509 -0
- proxilion/security/scope_enforcer.py +680 -0
- proxilion/security/sequence_validator.py +636 -0
- proxilion/security/trust_boundaries.py +784 -0
- proxilion/streaming/__init__.py +70 -0
- proxilion/streaming/detector.py +761 -0
- proxilion/streaming/transformer.py +674 -0
- proxilion/timeouts/__init__.py +55 -0
- proxilion/timeouts/decorators.py +477 -0
- proxilion/timeouts/manager.py +545 -0
- proxilion/tools/__init__.py +69 -0
- proxilion/tools/decorators.py +493 -0
- proxilion/tools/registry.py +732 -0
- proxilion/types.py +339 -0
- proxilion/validation/__init__.py +93 -0
- proxilion/validation/pydantic_schema.py +351 -0
- proxilion/validation/schema.py +651 -0
- proxilion-0.0.1.dist-info/METADATA +872 -0
- proxilion-0.0.1.dist-info/RECORD +94 -0
- proxilion-0.0.1.dist-info/WHEEL +4 -0
- proxilion-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost-based rate limiting for Proxilion.
|
|
3
|
+
|
|
4
|
+
Rate limit not just by request count, but by dollar spend. Prevents
|
|
5
|
+
runaway costs from expensive model calls or high-volume usage.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from proxilion.security.cost_limiter import (
|
|
9
|
+
... CostLimiter, CostLimit, HybridRateLimiter
|
|
10
|
+
... )
|
|
11
|
+
>>> from proxilion.observability import CostTracker
|
|
12
|
+
>>> from datetime import timedelta
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Create multi-tier cost limits
|
|
15
|
+
>>> limits = [
|
|
16
|
+
... CostLimit(max_cost=1.00, period=timedelta(minutes=1), scope="user"),
|
|
17
|
+
... CostLimit(max_cost=10.00, period=timedelta(hours=1), scope="user"),
|
|
18
|
+
... CostLimit(max_cost=50.00, period=timedelta(days=1), scope="user"),
|
|
19
|
+
... ]
|
|
20
|
+
>>>
|
|
21
|
+
>>> tracker = CostTracker()
|
|
22
|
+
>>> limiter = CostLimiter(limits=limits, cost_tracker=tracker)
|
|
23
|
+
>>>
|
|
24
|
+
>>> # Check limit before request
|
|
25
|
+
>>> result = limiter.check_limit("user_123", estimated_cost=0.50)
|
|
26
|
+
>>> if not result.allowed:
|
|
27
|
+
... print(f"Limit exceeded: {result.limit_name}")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import threading
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from enum import Enum
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LimitScope(Enum):
|
|
43
|
+
"""Scope of a cost limit."""
|
|
44
|
+
|
|
45
|
+
USER = "user"
|
|
46
|
+
"""Per-user limit."""
|
|
47
|
+
|
|
48
|
+
ORG = "org"
|
|
49
|
+
"""Organization-wide limit."""
|
|
50
|
+
|
|
51
|
+
GLOBAL = "global"
|
|
52
|
+
"""Global limit across all users and orgs."""
|
|
53
|
+
|
|
54
|
+
TOOL = "tool"
|
|
55
|
+
"""Per-tool limit."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CostLimit:
|
|
60
|
+
"""
|
|
61
|
+
Definition of a cost limit.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
max_cost: Maximum allowed cost in USD.
|
|
65
|
+
period: Time period for the limit.
|
|
66
|
+
scope: Scope of the limit (user, org, global, tool).
|
|
67
|
+
name: Optional name for the limit.
|
|
68
|
+
description: Optional description.
|
|
69
|
+
warn_at: Percentage (0.0-1.0) at which to warn.
|
|
70
|
+
hard_limit: If True, strictly enforce; if False, just warn.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
max_cost: float
|
|
74
|
+
period: timedelta
|
|
75
|
+
scope: LimitScope | str = LimitScope.USER
|
|
76
|
+
name: str = ""
|
|
77
|
+
description: str = ""
|
|
78
|
+
warn_at: float = 0.8
|
|
79
|
+
hard_limit: bool = True
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if isinstance(self.scope, str):
|
|
83
|
+
self.scope = LimitScope(self.scope.lower())
|
|
84
|
+
if not self.name:
|
|
85
|
+
self.name = f"{self.scope.value}_{self._period_name}"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def _period_name(self) -> str:
|
|
89
|
+
"""Get a human-readable period name."""
|
|
90
|
+
total_seconds = self.period.total_seconds()
|
|
91
|
+
if total_seconds < 60:
|
|
92
|
+
return f"{int(total_seconds)}s"
|
|
93
|
+
elif total_seconds < 3600:
|
|
94
|
+
return f"{int(total_seconds / 60)}m"
|
|
95
|
+
elif total_seconds < 86400:
|
|
96
|
+
return f"{int(total_seconds / 3600)}h"
|
|
97
|
+
else:
|
|
98
|
+
return f"{int(total_seconds / 86400)}d"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class CostLimitResult:
|
|
103
|
+
"""
|
|
104
|
+
Result of a cost limit check.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
allowed: Whether the request is allowed.
|
|
108
|
+
limit_name: Name of the limit (if exceeded).
|
|
109
|
+
current_spend: Current spend in the period.
|
|
110
|
+
limit: The limit amount.
|
|
111
|
+
remaining: Remaining budget.
|
|
112
|
+
reset_at: When the limit resets.
|
|
113
|
+
warning: True if approaching limit.
|
|
114
|
+
warning_message: Warning message if applicable.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
allowed: bool
|
|
118
|
+
limit_name: str = ""
|
|
119
|
+
current_spend: float = 0.0
|
|
120
|
+
limit: float = 0.0
|
|
121
|
+
remaining: float = 0.0
|
|
122
|
+
reset_at: datetime | None = None
|
|
123
|
+
warning: bool = False
|
|
124
|
+
warning_message: str = ""
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
"""Convert to dictionary."""
|
|
128
|
+
return {
|
|
129
|
+
"allowed": self.allowed,
|
|
130
|
+
"limit_name": self.limit_name,
|
|
131
|
+
"current_spend": self.current_spend,
|
|
132
|
+
"limit": self.limit,
|
|
133
|
+
"remaining": self.remaining,
|
|
134
|
+
"reset_at": self.reset_at.isoformat() if self.reset_at else None,
|
|
135
|
+
"warning": self.warning,
|
|
136
|
+
"warning_message": self.warning_message,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CostLimiter:
|
|
141
|
+
"""
|
|
142
|
+
Enforces cost-based rate limits.
|
|
143
|
+
|
|
144
|
+
Tracks spending against configurable limits with multiple tiers
|
|
145
|
+
and scopes (per-user, per-org, global).
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> limiter = CostLimiter(
|
|
149
|
+
... limits=[
|
|
150
|
+
... CostLimit(max_cost=1.00, period=timedelta(minutes=1), scope="user"),
|
|
151
|
+
... CostLimit(max_cost=50.00, period=timedelta(days=1), scope="user"),
|
|
152
|
+
... ],
|
|
153
|
+
... cost_tracker=tracker,
|
|
154
|
+
... )
|
|
155
|
+
>>>
|
|
156
|
+
>>> result = limiter.check_limit("user_123", estimated_cost=0.10)
|
|
157
|
+
>>> if result.allowed:
|
|
158
|
+
... # Proceed with request
|
|
159
|
+
... pass
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
limits: list[CostLimit],
|
|
165
|
+
cost_tracker: Any | None = None, # CostTracker
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Initialize the cost limiter.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
limits: List of cost limits to enforce.
|
|
172
|
+
cost_tracker: CostTracker instance for spend data.
|
|
173
|
+
"""
|
|
174
|
+
self._lock = threading.RLock()
|
|
175
|
+
self._limits = limits
|
|
176
|
+
self._cost_tracker = cost_tracker
|
|
177
|
+
|
|
178
|
+
# Internal tracking for when no cost_tracker provided
|
|
179
|
+
self._spend_records: dict[str, list[tuple[datetime, float]]] = {}
|
|
180
|
+
|
|
181
|
+
def set_cost_tracker(self, tracker: Any) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Set the cost tracker.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
tracker: CostTracker instance.
|
|
187
|
+
"""
|
|
188
|
+
self._cost_tracker = tracker
|
|
189
|
+
|
|
190
|
+
def add_limit(self, limit: CostLimit) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Add a cost limit.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
limit: The limit to add.
|
|
196
|
+
"""
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._limits.append(limit)
|
|
199
|
+
|
|
200
|
+
def remove_limit(self, name: str) -> bool:
|
|
201
|
+
"""
|
|
202
|
+
Remove a limit by name.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
name: Name of the limit to remove.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if removed, False if not found.
|
|
209
|
+
"""
|
|
210
|
+
with self._lock:
|
|
211
|
+
for i, limit in enumerate(self._limits):
|
|
212
|
+
if limit.name == name:
|
|
213
|
+
self._limits.pop(i)
|
|
214
|
+
return True
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def get_limits(self) -> list[CostLimit]:
|
|
218
|
+
"""Get all configured limits."""
|
|
219
|
+
with self._lock:
|
|
220
|
+
return list(self._limits)
|
|
221
|
+
|
|
222
|
+
def check_limit(
|
|
223
|
+
self,
|
|
224
|
+
user_id: str,
|
|
225
|
+
estimated_cost: float,
|
|
226
|
+
org_id: str | None = None,
|
|
227
|
+
tool_name: str | None = None,
|
|
228
|
+
) -> CostLimitResult:
|
|
229
|
+
"""
|
|
230
|
+
Check if a request would exceed cost limits.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
user_id: User making the request.
|
|
234
|
+
estimated_cost: Estimated cost of the request.
|
|
235
|
+
org_id: Organization ID (for org-scoped limits).
|
|
236
|
+
tool_name: Tool name (for tool-scoped limits).
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
CostLimitResult indicating if request is allowed.
|
|
240
|
+
"""
|
|
241
|
+
with self._lock:
|
|
242
|
+
now = datetime.now(timezone.utc)
|
|
243
|
+
warnings = []
|
|
244
|
+
|
|
245
|
+
for limit in self._limits:
|
|
246
|
+
# Get the appropriate spend based on scope
|
|
247
|
+
current_spend = self._get_spend_for_scope(
|
|
248
|
+
limit, user_id, org_id, tool_name
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Calculate remaining and reset time
|
|
252
|
+
remaining = max(0, limit.max_cost - current_spend)
|
|
253
|
+
reset_at = self._calculate_reset_time(limit, now)
|
|
254
|
+
|
|
255
|
+
# Check if would exceed limit
|
|
256
|
+
if current_spend + estimated_cost > limit.max_cost:
|
|
257
|
+
if limit.hard_limit:
|
|
258
|
+
return CostLimitResult(
|
|
259
|
+
allowed=False,
|
|
260
|
+
limit_name=limit.name,
|
|
261
|
+
current_spend=current_spend,
|
|
262
|
+
limit=limit.max_cost,
|
|
263
|
+
remaining=remaining,
|
|
264
|
+
reset_at=reset_at,
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
# Soft limit - just warn
|
|
268
|
+
warnings.append(
|
|
269
|
+
f"Soft limit '{limit.name}' exceeded: "
|
|
270
|
+
f"${current_spend + estimated_cost:.4f} > ${limit.max_cost:.4f}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Check warning threshold
|
|
274
|
+
if limit.warn_at > 0:
|
|
275
|
+
usage_pct = (current_spend + estimated_cost) / limit.max_cost
|
|
276
|
+
if usage_pct >= limit.warn_at:
|
|
277
|
+
warnings.append(
|
|
278
|
+
f"Approaching limit '{limit.name}': "
|
|
279
|
+
f"{usage_pct * 100:.1f}% of ${limit.max_cost:.2f}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# All limits passed
|
|
283
|
+
result = CostLimitResult(
|
|
284
|
+
allowed=True,
|
|
285
|
+
current_spend=self._get_total_spend(user_id, timedelta(days=1)),
|
|
286
|
+
remaining=self._get_min_remaining(user_id, org_id, tool_name),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if warnings:
|
|
290
|
+
result.warning = True
|
|
291
|
+
result.warning_message = "; ".join(warnings)
|
|
292
|
+
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
def _get_spend_for_scope(
|
|
296
|
+
self,
|
|
297
|
+
limit: CostLimit,
|
|
298
|
+
user_id: str,
|
|
299
|
+
org_id: str | None,
|
|
300
|
+
tool_name: str | None,
|
|
301
|
+
) -> float:
|
|
302
|
+
"""Get spend for a specific limit scope."""
|
|
303
|
+
if self._cost_tracker:
|
|
304
|
+
if limit.scope == LimitScope.USER:
|
|
305
|
+
return self._cost_tracker.get_user_spend(user_id, limit.period)
|
|
306
|
+
elif limit.scope == LimitScope.ORG or limit.scope == LimitScope.GLOBAL:
|
|
307
|
+
return self._cost_tracker.get_org_spend(limit.period)
|
|
308
|
+
elif limit.scope == LimitScope.TOOL and tool_name:
|
|
309
|
+
# Get tool-specific spend from summary
|
|
310
|
+
summary = self._cost_tracker.get_summary(
|
|
311
|
+
start=datetime.now(timezone.utc) - limit.period
|
|
312
|
+
)
|
|
313
|
+
return summary.by_tool.get(tool_name, 0.0)
|
|
314
|
+
else:
|
|
315
|
+
# Fallback to internal tracking
|
|
316
|
+
return self._get_internal_spend(user_id, limit.period)
|
|
317
|
+
|
|
318
|
+
return 0.0
|
|
319
|
+
|
|
320
|
+
def _get_internal_spend(self, key: str, period: timedelta) -> float:
|
|
321
|
+
"""Get spend from internal tracking."""
|
|
322
|
+
now = datetime.now(timezone.utc)
|
|
323
|
+
cutoff = now - period
|
|
324
|
+
|
|
325
|
+
if key not in self._spend_records:
|
|
326
|
+
return 0.0
|
|
327
|
+
|
|
328
|
+
total = 0.0
|
|
329
|
+
for timestamp, cost in self._spend_records[key]:
|
|
330
|
+
if timestamp >= cutoff:
|
|
331
|
+
total += cost
|
|
332
|
+
|
|
333
|
+
return total
|
|
334
|
+
|
|
335
|
+
def _get_total_spend(self, user_id: str, period: timedelta) -> float:
|
|
336
|
+
"""Get total spend for a user."""
|
|
337
|
+
if self._cost_tracker:
|
|
338
|
+
return self._cost_tracker.get_user_spend(user_id, period)
|
|
339
|
+
return self._get_internal_spend(user_id, period)
|
|
340
|
+
|
|
341
|
+
def _get_min_remaining(
|
|
342
|
+
self,
|
|
343
|
+
user_id: str,
|
|
344
|
+
org_id: str | None,
|
|
345
|
+
tool_name: str | None,
|
|
346
|
+
) -> float:
|
|
347
|
+
"""Get minimum remaining budget across all limits."""
|
|
348
|
+
min_remaining = float("inf")
|
|
349
|
+
|
|
350
|
+
for limit in self._limits:
|
|
351
|
+
current = self._get_spend_for_scope(limit, user_id, org_id, tool_name)
|
|
352
|
+
remaining = max(0, limit.max_cost - current)
|
|
353
|
+
min_remaining = min(min_remaining, remaining)
|
|
354
|
+
|
|
355
|
+
return min_remaining if min_remaining != float("inf") else 0.0
|
|
356
|
+
|
|
357
|
+
def _calculate_reset_time(self, limit: CostLimit, now: datetime) -> datetime:
|
|
358
|
+
"""Calculate when a limit period resets."""
|
|
359
|
+
# Align to period boundaries for consistency
|
|
360
|
+
total_seconds = limit.period.total_seconds()
|
|
361
|
+
|
|
362
|
+
if total_seconds <= 60: # Minute or less
|
|
363
|
+
return now.replace(second=0, microsecond=0) + limit.period
|
|
364
|
+
elif total_seconds <= 3600: # Hour or less
|
|
365
|
+
return now.replace(minute=0, second=0, microsecond=0) + limit.period
|
|
366
|
+
elif total_seconds <= 86400: # Day or less
|
|
367
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0) + limit.period
|
|
368
|
+
else: # Longer periods
|
|
369
|
+
return now + limit.period
|
|
370
|
+
|
|
371
|
+
def record_spend(
|
|
372
|
+
self,
|
|
373
|
+
user_id: str,
|
|
374
|
+
actual_cost: float,
|
|
375
|
+
tool_name: str | None = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Record actual spend (for internal tracking when no CostTracker).
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
user_id: User who incurred the cost.
|
|
382
|
+
actual_cost: Actual cost in USD.
|
|
383
|
+
tool_name: Tool that incurred the cost.
|
|
384
|
+
"""
|
|
385
|
+
with self._lock:
|
|
386
|
+
now = datetime.now(timezone.utc)
|
|
387
|
+
|
|
388
|
+
if user_id not in self._spend_records:
|
|
389
|
+
self._spend_records[user_id] = []
|
|
390
|
+
|
|
391
|
+
self._spend_records[user_id].append((now, actual_cost))
|
|
392
|
+
|
|
393
|
+
# Clean up old records
|
|
394
|
+
self._cleanup_old_records(user_id)
|
|
395
|
+
|
|
396
|
+
def _cleanup_old_records(self, key: str) -> None:
|
|
397
|
+
"""Remove records older than max period."""
|
|
398
|
+
if key not in self._spend_records:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
# Find max period
|
|
402
|
+
if self._limits:
|
|
403
|
+
max_period = max(limit.period for limit in self._limits)
|
|
404
|
+
else:
|
|
405
|
+
max_period = timedelta(days=30)
|
|
406
|
+
cutoff = datetime.now(timezone.utc) - max_period
|
|
407
|
+
|
|
408
|
+
self._spend_records[key] = [
|
|
409
|
+
(t, c) for t, c in self._spend_records[key]
|
|
410
|
+
if t >= cutoff
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
def get_remaining_budget(
|
|
414
|
+
self,
|
|
415
|
+
user_id: str,
|
|
416
|
+
period: timedelta | None = None,
|
|
417
|
+
) -> float:
|
|
418
|
+
"""
|
|
419
|
+
Get remaining budget for a user.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
user_id: User to check.
|
|
423
|
+
period: Specific period (or minimum across all limits).
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Remaining budget in USD.
|
|
427
|
+
"""
|
|
428
|
+
if period:
|
|
429
|
+
# Find matching limit
|
|
430
|
+
for limit in self._limits:
|
|
431
|
+
if limit.period == period and limit.scope == LimitScope.USER:
|
|
432
|
+
current = self._get_total_spend(user_id, period)
|
|
433
|
+
return max(0, limit.max_cost - current)
|
|
434
|
+
return 0.0
|
|
435
|
+
else:
|
|
436
|
+
return self._get_min_remaining(user_id, None, None)
|
|
437
|
+
|
|
438
|
+
def get_spend_by_period(
|
|
439
|
+
self,
|
|
440
|
+
user_id: str,
|
|
441
|
+
period: timedelta,
|
|
442
|
+
) -> float:
|
|
443
|
+
"""
|
|
444
|
+
Get spend for a specific period.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
user_id: User to check.
|
|
448
|
+
period: Time period.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Spend in USD.
|
|
452
|
+
"""
|
|
453
|
+
return self._get_total_spend(user_id, period)
|
|
454
|
+
|
|
455
|
+
def reset_period(
|
|
456
|
+
self,
|
|
457
|
+
user_id: str,
|
|
458
|
+
limit_name: str | None = None,
|
|
459
|
+
) -> None:
|
|
460
|
+
"""
|
|
461
|
+
Manually reset spend tracking for a user.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
user_id: User to reset.
|
|
465
|
+
limit_name: Specific limit to reset (or all).
|
|
466
|
+
"""
|
|
467
|
+
with self._lock:
|
|
468
|
+
if user_id in self._spend_records:
|
|
469
|
+
del self._spend_records[user_id]
|
|
470
|
+
|
|
471
|
+
logger.info(f"Reset spend tracking for user {user_id}")
|
|
472
|
+
|
|
473
|
+
def get_status(
|
|
474
|
+
self,
|
|
475
|
+
user_id: str,
|
|
476
|
+
org_id: str | None = None,
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
"""
|
|
479
|
+
Get comprehensive status for a user.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
user_id: User to check.
|
|
483
|
+
org_id: Organization ID.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Dictionary with status for all limits.
|
|
487
|
+
"""
|
|
488
|
+
status = {
|
|
489
|
+
"user_id": user_id,
|
|
490
|
+
"limits": [],
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
now = datetime.now(timezone.utc)
|
|
494
|
+
|
|
495
|
+
for limit in self._limits:
|
|
496
|
+
current = self._get_spend_for_scope(limit, user_id, org_id, None)
|
|
497
|
+
remaining = max(0, limit.max_cost - current)
|
|
498
|
+
reset_at = self._calculate_reset_time(limit, now)
|
|
499
|
+
|
|
500
|
+
status["limits"].append({
|
|
501
|
+
"name": limit.name,
|
|
502
|
+
"scope": limit.scope.value,
|
|
503
|
+
"period_seconds": limit.period.total_seconds(),
|
|
504
|
+
"max_cost": limit.max_cost,
|
|
505
|
+
"current_spend": current,
|
|
506
|
+
"remaining": remaining,
|
|
507
|
+
"percentage_used": current / limit.max_cost if limit.max_cost > 0 else 0,
|
|
508
|
+
"reset_at": reset_at.isoformat(),
|
|
509
|
+
"hard_limit": limit.hard_limit,
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return status
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class HybridRateLimiter:
|
|
516
|
+
"""
|
|
517
|
+
Combines request-based and cost-based rate limiting.
|
|
518
|
+
|
|
519
|
+
Checks both request rate limits (fast) and cost limits for
|
|
520
|
+
comprehensive protection against abuse.
|
|
521
|
+
|
|
522
|
+
Example:
|
|
523
|
+
>>> from proxilion.security.rate_limiter import TokenBucketRateLimiter
|
|
524
|
+
>>>
|
|
525
|
+
>>> hybrid = HybridRateLimiter(
|
|
526
|
+
... request_limiter=TokenBucketRateLimiter(rate=10, capacity=100),
|
|
527
|
+
... cost_limiter=cost_limiter,
|
|
528
|
+
... )
|
|
529
|
+
>>>
|
|
530
|
+
>>> allowed, reason = hybrid.allow_request("user_123", estimated_cost=0.10)
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __init__(
|
|
534
|
+
self,
|
|
535
|
+
request_limiter: Any | None = None, # TokenBucketRateLimiter
|
|
536
|
+
cost_limiter: CostLimiter | None = None,
|
|
537
|
+
) -> None:
|
|
538
|
+
"""
|
|
539
|
+
Initialize hybrid rate limiter.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
request_limiter: Request-based rate limiter.
|
|
543
|
+
cost_limiter: Cost-based rate limiter.
|
|
544
|
+
"""
|
|
545
|
+
self._request_limiter = request_limiter
|
|
546
|
+
self._cost_limiter = cost_limiter
|
|
547
|
+
|
|
548
|
+
def set_request_limiter(self, limiter: Any) -> None:
|
|
549
|
+
"""Set the request limiter."""
|
|
550
|
+
self._request_limiter = limiter
|
|
551
|
+
|
|
552
|
+
def set_cost_limiter(self, limiter: CostLimiter) -> None:
|
|
553
|
+
"""Set the cost limiter."""
|
|
554
|
+
self._cost_limiter = limiter
|
|
555
|
+
|
|
556
|
+
def allow_request(
|
|
557
|
+
self,
|
|
558
|
+
user_id: str,
|
|
559
|
+
estimated_cost: float = 0.0,
|
|
560
|
+
org_id: str | None = None,
|
|
561
|
+
tool_name: str | None = None,
|
|
562
|
+
) -> tuple[bool, str | None]:
|
|
563
|
+
"""
|
|
564
|
+
Check if a request is allowed.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
user_id: User making the request.
|
|
568
|
+
estimated_cost: Estimated cost in USD.
|
|
569
|
+
org_id: Organization ID.
|
|
570
|
+
tool_name: Tool being called.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Tuple of (allowed, reason). If not allowed, reason explains why.
|
|
574
|
+
"""
|
|
575
|
+
# Check request rate first (fast check)
|
|
576
|
+
if self._request_limiter:
|
|
577
|
+
# Try different common interfaces
|
|
578
|
+
if hasattr(self._request_limiter, "allow_request"):
|
|
579
|
+
if not self._request_limiter.allow_request(user_id):
|
|
580
|
+
return False, "Request rate limit exceeded"
|
|
581
|
+
elif hasattr(self._request_limiter, "check"):
|
|
582
|
+
result = self._request_limiter.check(user_id)
|
|
583
|
+
if hasattr(result, "allowed") and not result.allowed:
|
|
584
|
+
return False, "Request rate limit exceeded"
|
|
585
|
+
|
|
586
|
+
# Check cost limit
|
|
587
|
+
if self._cost_limiter and estimated_cost > 0:
|
|
588
|
+
cost_result = self._cost_limiter.check_limit(
|
|
589
|
+
user_id, estimated_cost, org_id, tool_name
|
|
590
|
+
)
|
|
591
|
+
if not cost_result.allowed:
|
|
592
|
+
return False, (
|
|
593
|
+
f"Cost limit exceeded ({cost_result.limit_name}): "
|
|
594
|
+
f"${cost_result.current_spend:.2f}/${cost_result.limit:.2f}"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Log warnings
|
|
598
|
+
if cost_result.warning:
|
|
599
|
+
logger.warning(
|
|
600
|
+
f"Cost warning for user {user_id}: {cost_result.warning_message}"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return True, None
|
|
604
|
+
|
|
605
|
+
def record_usage(
|
|
606
|
+
self,
|
|
607
|
+
user_id: str,
|
|
608
|
+
actual_cost: float,
|
|
609
|
+
tool_name: str | None = None,
|
|
610
|
+
) -> None:
|
|
611
|
+
"""
|
|
612
|
+
Record actual usage after request completion.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
user_id: User who made the request.
|
|
616
|
+
actual_cost: Actual cost incurred.
|
|
617
|
+
tool_name: Tool that was called.
|
|
618
|
+
"""
|
|
619
|
+
if self._cost_limiter:
|
|
620
|
+
self._cost_limiter.record_spend(user_id, actual_cost, tool_name)
|
|
621
|
+
|
|
622
|
+
def get_status(self, user_id: str) -> dict[str, Any]:
|
|
623
|
+
"""
|
|
624
|
+
Get combined status from both limiters.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
user_id: User to check.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
Dictionary with status from both limiters.
|
|
631
|
+
"""
|
|
632
|
+
status: dict[str, Any] = {"user_id": user_id}
|
|
633
|
+
|
|
634
|
+
if self._request_limiter and hasattr(self._request_limiter, "get_status"):
|
|
635
|
+
status["request_limiter"] = self._request_limiter.get_status(user_id)
|
|
636
|
+
|
|
637
|
+
if self._cost_limiter:
|
|
638
|
+
status["cost_limiter"] = self._cost_limiter.get_status(user_id)
|
|
639
|
+
|
|
640
|
+
return status
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def create_cost_limiter(
|
|
644
|
+
limits: list[CostLimit] | None = None,
|
|
645
|
+
cost_tracker: Any | None = None,
|
|
646
|
+
include_defaults: bool = True,
|
|
647
|
+
) -> CostLimiter:
|
|
648
|
+
"""
|
|
649
|
+
Factory function to create a CostLimiter.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
limits: Custom limits to use.
|
|
653
|
+
cost_tracker: CostTracker for spend data.
|
|
654
|
+
include_defaults: Whether to include sensible default limits.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Configured CostLimiter instance.
|
|
658
|
+
"""
|
|
659
|
+
all_limits = []
|
|
660
|
+
|
|
661
|
+
if include_defaults:
|
|
662
|
+
# Sensible default limits
|
|
663
|
+
all_limits.extend([
|
|
664
|
+
CostLimit(
|
|
665
|
+
max_cost=1.00,
|
|
666
|
+
period=timedelta(minutes=1),
|
|
667
|
+
scope=LimitScope.USER,
|
|
668
|
+
name="user_burst",
|
|
669
|
+
description="Burst protection",
|
|
670
|
+
),
|
|
671
|
+
CostLimit(
|
|
672
|
+
max_cost=10.00,
|
|
673
|
+
period=timedelta(hours=1),
|
|
674
|
+
scope=LimitScope.USER,
|
|
675
|
+
name="user_hourly",
|
|
676
|
+
description="Hourly cap",
|
|
677
|
+
),
|
|
678
|
+
CostLimit(
|
|
679
|
+
max_cost=50.00,
|
|
680
|
+
period=timedelta(days=1),
|
|
681
|
+
scope=LimitScope.USER,
|
|
682
|
+
name="user_daily",
|
|
683
|
+
description="Daily cap",
|
|
684
|
+
),
|
|
685
|
+
])
|
|
686
|
+
|
|
687
|
+
if limits:
|
|
688
|
+
all_limits.extend(limits)
|
|
689
|
+
|
|
690
|
+
return CostLimiter(limits=all_limits, cost_tracker=cost_tracker)
|