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.
Files changed (94) hide show
  1. proxilion/__init__.py +136 -0
  2. proxilion/audit/__init__.py +133 -0
  3. proxilion/audit/base_exporters.py +527 -0
  4. proxilion/audit/compliance/__init__.py +130 -0
  5. proxilion/audit/compliance/base.py +457 -0
  6. proxilion/audit/compliance/eu_ai_act.py +603 -0
  7. proxilion/audit/compliance/iso27001.py +544 -0
  8. proxilion/audit/compliance/soc2.py +491 -0
  9. proxilion/audit/events.py +493 -0
  10. proxilion/audit/explainability.py +1173 -0
  11. proxilion/audit/exporters/__init__.py +58 -0
  12. proxilion/audit/exporters/aws_s3.py +636 -0
  13. proxilion/audit/exporters/azure_storage.py +608 -0
  14. proxilion/audit/exporters/cloud_base.py +468 -0
  15. proxilion/audit/exporters/gcp_storage.py +570 -0
  16. proxilion/audit/exporters/multi_exporter.py +498 -0
  17. proxilion/audit/hash_chain.py +652 -0
  18. proxilion/audit/logger.py +543 -0
  19. proxilion/caching/__init__.py +49 -0
  20. proxilion/caching/tool_cache.py +633 -0
  21. proxilion/context/__init__.py +73 -0
  22. proxilion/context/context_window.py +556 -0
  23. proxilion/context/message_history.py +505 -0
  24. proxilion/context/session.py +735 -0
  25. proxilion/contrib/__init__.py +51 -0
  26. proxilion/contrib/anthropic.py +609 -0
  27. proxilion/contrib/google.py +1012 -0
  28. proxilion/contrib/langchain.py +641 -0
  29. proxilion/contrib/mcp.py +893 -0
  30. proxilion/contrib/openai.py +646 -0
  31. proxilion/core.py +3058 -0
  32. proxilion/decorators.py +966 -0
  33. proxilion/engines/__init__.py +287 -0
  34. proxilion/engines/base.py +266 -0
  35. proxilion/engines/casbin_engine.py +412 -0
  36. proxilion/engines/opa_engine.py +493 -0
  37. proxilion/engines/simple.py +437 -0
  38. proxilion/exceptions.py +887 -0
  39. proxilion/guards/__init__.py +54 -0
  40. proxilion/guards/input_guard.py +522 -0
  41. proxilion/guards/output_guard.py +634 -0
  42. proxilion/observability/__init__.py +198 -0
  43. proxilion/observability/cost_tracker.py +866 -0
  44. proxilion/observability/hooks.py +683 -0
  45. proxilion/observability/metrics.py +798 -0
  46. proxilion/observability/session_cost_tracker.py +1063 -0
  47. proxilion/policies/__init__.py +67 -0
  48. proxilion/policies/base.py +304 -0
  49. proxilion/policies/builtin.py +486 -0
  50. proxilion/policies/registry.py +376 -0
  51. proxilion/providers/__init__.py +201 -0
  52. proxilion/providers/adapter.py +468 -0
  53. proxilion/providers/anthropic_adapter.py +330 -0
  54. proxilion/providers/gemini_adapter.py +391 -0
  55. proxilion/providers/openai_adapter.py +294 -0
  56. proxilion/py.typed +0 -0
  57. proxilion/resilience/__init__.py +81 -0
  58. proxilion/resilience/degradation.py +615 -0
  59. proxilion/resilience/fallback.py +555 -0
  60. proxilion/resilience/retry.py +554 -0
  61. proxilion/scheduling/__init__.py +57 -0
  62. proxilion/scheduling/priority_queue.py +419 -0
  63. proxilion/scheduling/scheduler.py +459 -0
  64. proxilion/security/__init__.py +244 -0
  65. proxilion/security/agent_trust.py +968 -0
  66. proxilion/security/behavioral_drift.py +794 -0
  67. proxilion/security/cascade_protection.py +869 -0
  68. proxilion/security/circuit_breaker.py +428 -0
  69. proxilion/security/cost_limiter.py +690 -0
  70. proxilion/security/idor_protection.py +460 -0
  71. proxilion/security/intent_capsule.py +849 -0
  72. proxilion/security/intent_validator.py +495 -0
  73. proxilion/security/memory_integrity.py +767 -0
  74. proxilion/security/rate_limiter.py +509 -0
  75. proxilion/security/scope_enforcer.py +680 -0
  76. proxilion/security/sequence_validator.py +636 -0
  77. proxilion/security/trust_boundaries.py +784 -0
  78. proxilion/streaming/__init__.py +70 -0
  79. proxilion/streaming/detector.py +761 -0
  80. proxilion/streaming/transformer.py +674 -0
  81. proxilion/timeouts/__init__.py +55 -0
  82. proxilion/timeouts/decorators.py +477 -0
  83. proxilion/timeouts/manager.py +545 -0
  84. proxilion/tools/__init__.py +69 -0
  85. proxilion/tools/decorators.py +493 -0
  86. proxilion/tools/registry.py +732 -0
  87. proxilion/types.py +339 -0
  88. proxilion/validation/__init__.py +93 -0
  89. proxilion/validation/pydantic_schema.py +351 -0
  90. proxilion/validation/schema.py +651 -0
  91. proxilion-0.0.1.dist-info/METADATA +872 -0
  92. proxilion-0.0.1.dist-info/RECORD +94 -0
  93. proxilion-0.0.1.dist-info/WHEEL +4 -0
  94. 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)