reseller_models.py
1 """ 2 Pydantic request/response models for reseller and admin API endpoints. 3 4 Defines the API contract for reseller management, user provisioning, 5 usage reporting, API key management, and admin operations. 6 """ 7 import re 8 from datetime import datetime 9 from typing import Any, Optional 10 11 from pydantic import BaseModel, Field, field_validator 12 13 14 # ============================================================================= 15 # Shared / Common 16 # ============================================================================= 17 18 class PaginationInfo(BaseModel): 19 """Pagination metadata for list responses.""" 20 page: int = Field(description="Current page number") 21 per_page: int = Field(description="Items per page") 22 total: int = Field(description="Total number of items") 23 total_pages: int = Field(description="Total number of pages") 24 25 26 class ResellerErrorDetail(BaseModel): 27 """Structured error detail with machine-readable code.""" 28 code: str = Field(description="Machine-readable error code") 29 message: str = Field(description="Human-readable error message") 30 details: Optional[dict[str, Any]] = Field( 31 default=None, description="Additional error context" 32 ) 33 34 35 class ResellerErrorResponse(BaseModel): 36 """Standard error response for reseller/admin endpoints.""" 37 error: ResellerErrorDetail 38 39 40 # ============================================================================= 41 # Reseller User Management — Requests 42 # ============================================================================= 43 44 _EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$') 45 46 47 class CreateResellerUserRequest(BaseModel): 48 """Request body for POST /reseller/users.""" 49 username: str = Field( 50 min_length=3, max_length=32, 51 description="Username (3-32 chars, lowercase alphanumeric + underscore)" 52 ) 53 email: str = Field(description="Email address") 54 password: str = Field(min_length=8, description="Password (min 8 chars)") 55 quota_overrides: Optional[dict[str, Any]] = Field( 56 default=None, description="Quota overrides within reseller limits" 57 ) 58 feature_overrides: Optional[dict[str, Any]] = Field( 59 default=None, description="Feature overrides (cannot exceed reseller features)" 60 ) 61 metadata: Optional[dict[str, Any]] = Field( 62 default=None, description="Opaque metadata (e.g., whmcs_service_id)" 63 ) 64 65 @field_validator("username") 66 @classmethod 67 def validate_username(cls, v: str) -> str: 68 if not re.match(r'^[a-z][a-z0-9_]{2,31}$', v): 69 raise ValueError( 70 "Username must start with a letter, contain only " 71 "lowercase letters, digits, and underscores" 72 ) 73 return v 74 75 @field_validator("email") 76 @classmethod 77 def validate_email(cls, v: str) -> str: 78 if not _EMAIL_RE.match(v): 79 raise ValueError("Invalid email format") 80 return v.lower() 81 82 83 class UpdateResellerUserRequest(BaseModel): 84 """Request body for PUT /reseller/users/{user_id}.""" 85 email: Optional[str] = Field(default=None, description="New email") 86 quota_overrides: Optional[dict[str, Any]] = Field(default=None) 87 feature_overrides: Optional[dict[str, Any]] = Field(default=None) 88 metadata: Optional[dict[str, Any]] = Field(default=None) 89 90 @field_validator("email") 91 @classmethod 92 def validate_email(cls, v: Optional[str]) -> Optional[str]: 93 if v is not None and not _EMAIL_RE.match(v): 94 raise ValueError("Invalid email format") 95 return v.lower() if v else v 96 97 98 class SuspendRequest(BaseModel): 99 """Request for suspend operations.""" 100 reason: Optional[str] = Field(default=None, description="Suspension reason") 101 102 103 class ChangePasswordRequest(BaseModel): 104 """Request for password change.""" 105 new_password: str = Field(min_length=8, description="New password") 106 107 108 # ============================================================================= 109 # Reseller User Management — Responses 110 # ============================================================================= 111 112 class ResellerUserQuota(BaseModel): 113 """User quota summary.""" 114 max_concurrent_tasks: int = 2 115 max_daily_tasks: int = 50 116 tasks_today: int = 0 117 118 119 class ResellerUserUsageSummary(BaseModel): 120 """User usage summary for the current period.""" 121 sessions_total: int = 0 122 sessions_this_month: int = 0 123 cost_this_month_usd: float = 0.0 124 tokens_this_month: int = 0 125 126 127 class ResellerUserResponse(BaseModel): 128 """Response representing a user managed by a reseller.""" 129 id: str 130 username: str 131 email: str 132 is_active: bool 133 created_at: datetime 134 updated_at: Optional[datetime] = None 135 last_session_at: Optional[datetime] = None 136 sessions_total: int = 0 137 quota: Optional[ResellerUserQuota] = None 138 features: Optional[dict[str, Any]] = None 139 usage_summary: Optional[ResellerUserUsageSummary] = None 140 metadata: Optional[dict[str, Any]] = None 141 142 143 class ResellerUserListResponse(BaseModel): 144 """Paginated list of users.""" 145 users: list[ResellerUserResponse] = Field(default_factory=list) 146 pagination: PaginationInfo 147 148 149 class SuspendUserResponse(BaseModel): 150 """Response from suspending a user.""" 151 id: str 152 username: str 153 is_active: bool 154 suspended_at: Optional[datetime] = None 155 active_sessions_cancelled: int = 0 156 157 158 class DeleteUserResponse(BaseModel): 159 """Response from deleting a user.""" 160 status: str = "deleted" 161 id: str 162 username: str 163 sessions_deleted: int = 0 164 files_cleaned: bool = True 165 166 167 class PasswordChangedResponse(BaseModel): 168 """Response from changing a password.""" 169 status: str = "password_changed" 170 tokens_revoked: bool = True 171 172 173 # ============================================================================= 174 # API Key Management 175 # ============================================================================= 176 177 VALID_SCOPES = { 178 "users:create", "users:read", "users:update", "users:suspend", 179 "users:delete", "users:password", "sessions:read", "usage:read", 180 "keys:manage", "config:read", "config:update", "skills:manage", 181 "security:manage", 182 } 183 184 185 class CreateAPIKeyRequest(BaseModel): 186 """Request to create an API key.""" 187 name: str = Field(min_length=1, max_length=100, description="Key name") 188 scopes: list[str] = Field(description="Permitted scopes") 189 ip_allowlist: Optional[list[str]] = Field( 190 default=None, description="Allowed IPs/CIDRs (null = any)" 191 ) 192 rate_limit_per_minute: int = Field(default=60, ge=1, le=1000) 193 expires_at: Optional[datetime] = Field(default=None) 194 195 @field_validator("scopes") 196 @classmethod 197 def validate_scopes(cls, v: list[str]) -> list[str]: 198 invalid = set(v) - VALID_SCOPES 199 if invalid: 200 raise ValueError(f"Invalid scopes: {invalid}") 201 return v 202 203 204 class APIKeyResponse(BaseModel): 205 """Response for API key (without full key).""" 206 id: str 207 key_prefix: str 208 name: str 209 scopes: list[str] 210 ip_allowlist: Optional[list[str]] = None 211 rate_limit_per_minute: int = 60 212 is_active: bool = True 213 last_used_at: Optional[datetime] = None 214 last_used_ip: Optional[str] = None 215 expires_at: Optional[datetime] = None 216 created_at: datetime 217 218 219 class APIKeyCreatedResponse(APIKeyResponse): 220 """Response from creating an API key (includes full key, shown once).""" 221 key: str = Field(description="Full API key — shown only once") 222 223 224 class APIKeyRotatedResponse(APIKeyCreatedResponse): 225 """Response from rotating an API key.""" 226 old_key_valid_until: datetime 227 228 229 class APIKeyListResponse(BaseModel): 230 """List of API keys.""" 231 api_keys: list[APIKeyResponse] = Field(default_factory=list) 232 233 234 # ============================================================================= 235 # Usage & Reporting 236 # ============================================================================= 237 238 class WhmcsMetricDefinition(BaseModel): 239 """Single metric definition in WHMCS MetricProvider format.""" 240 type: str = "snapcount" 241 display: str 242 243 244 class WhmcsMetricsResponse(BaseModel): 245 """Response in WHMCS MetricProvider format for billing integration.""" 246 metrics: dict[str, WhmcsMetricDefinition] 247 usage: dict[str, dict[str, Any]] 248 249 250 class UsagePeriod(BaseModel): 251 """Time period for usage queries.""" 252 start: datetime 253 end: datetime 254 255 256 class UsageTotals(BaseModel): 257 """Aggregate usage totals.""" 258 sessions: int = 0 259 input_tokens: int = 0 260 output_tokens: int = 0 261 cost_usd: float = 0.0 262 active_users: int = 0 263 ssh_commands: int = 0 264 265 266 class UserUsageBreakdown(BaseModel): 267 """Per-user usage breakdown.""" 268 user_id: str 269 username: str 270 sessions: int = 0 271 input_tokens: int = 0 272 output_tokens: int = 0 273 cost_usd: float = 0.0 274 ssh_commands: int = 0 275 276 277 class DayUsageBreakdown(BaseModel): 278 """Per-day usage breakdown.""" 279 date: str 280 sessions: int = 0 281 cost_usd: float = 0.0 282 active_users: int = 0 283 284 285 class UsageResponse(BaseModel): 286 """Aggregate usage report response.""" 287 period: UsagePeriod 288 totals: UsageTotals 289 by_user: Optional[list[UserUsageBreakdown]] = None 290 by_day: Optional[list[DayUsageBreakdown]] = None 291 292 293 class UserUsageResponse(BaseModel): 294 """Per-user usage detail response.""" 295 user_id: str 296 username: str 297 period: UsagePeriod 298 totals: UsageTotals 299 sessions: list[dict[str, Any]] = Field(default_factory=list) 300 301 302 # ============================================================================= 303 # Spending 304 # ============================================================================= 305 306 class SpendingLimits(BaseModel): 307 """Spending limit configuration.""" 308 monthly_usd: Optional[float] = None 309 daily_usd: Optional[float] = None 310 per_session_usd: Optional[float] = None 311 312 313 class SpendingCurrent(BaseModel): 314 """Current spending amounts.""" 315 monthly_usd: float = 0.0 316 daily_usd: float = 0.0 317 318 319 class SpendingStatusResponse(BaseModel): 320 """Spending status for a user or reseller.""" 321 limits: SpendingLimits 322 current: SpendingCurrent 323 alert_threshold_pct: int = 80 324 status: str = "ok" # "ok", "warning", "exceeded" 325 326 327 class SetSpendingLimitsRequest(BaseModel): 328 """Request to set spending limits.""" 329 max_monthly_usd: Optional[float] = Field(default=None, ge=0) 330 max_daily_usd: Optional[float] = Field(default=None, ge=0) 331 max_per_session_usd: Optional[float] = Field(default=None, ge=0) 332 333 334 # ============================================================================= 335 # User Configuration 336 # ============================================================================= 337 338 class UserConfigResponse(BaseModel): 339 """Full user configuration response.""" 340 user_id: str 341 settings_mode: str = "readonly" 342 allowed_overrides: list[str] = Field(default_factory=list) 343 features: dict[str, Any] = Field(default_factory=dict) 344 security: dict[str, Any] = Field(default_factory=dict) 345 spending: SpendingStatusResponse 346 skills: dict[str, Any] = Field(default_factory=dict) 347 ssh_filters: dict[str, Any] = Field(default_factory=dict) 348 349 350 class UpdateUserConfigRequest(BaseModel): 351 """Request to update user configuration.""" 352 settings_mode: Optional[str] = None 353 allowed_overrides: Optional[list[str]] = None 354 feature_overrides: Optional[dict[str, Any]] = None 355 356 357 class UpdateSecurityConfigRequest(BaseModel): 358 """Request to update user security configuration.""" 359 allowed_tools: Optional[list[str]] = None 360 disabled_tools: Optional[list[str]] = None 361 command_block_patterns: Optional[list[str]] = None 362 network_allowed_domains: Optional[list[str]] = None 363 network_blocked_domains: Optional[list[str]] = None 364 path_blocklist_additions: Optional[list[str]] = None 365 366 367 class UpdateSSHFiltersRequest(BaseModel): 368 """Request to update user SSH filters.""" 369 blocked_hosts: Optional[list[str]] = None 370 allowed_hosts: Optional[list[str]] = None 371 command_block_patterns: Optional[list[str]] = None 372 max_connections: Optional[int] = Field(default=None, ge=1, le=20) 373 session_timeout_seconds: Optional[int] = Field(default=None, ge=60, le=86400) 374 375 376 class SetSettingsModeRequest(BaseModel): 377 """Request to set user settings mode.""" 378 mode: str = Field(description="'readonly' or 'configurable'") 379 allowed_overrides: list[str] = Field(default_factory=list) 380 381 @field_validator("mode") 382 @classmethod 383 def validate_mode(cls, v: str) -> str: 384 if v not in ("readonly", "configurable"): 385 raise ValueError("mode must be 'readonly' or 'configurable'") 386 return v 387 388 389 class SetEnvVarsRequest(BaseModel): 390 """Request to set environment variables.""" 391 env_vars: dict[str, str] = Field(description="Environment variable name-value pairs") 392 393 394 # ============================================================================= 395 # Skills Management 396 # ============================================================================= 397 398 class SkillResponse(BaseModel): 399 """Skill information.""" 400 name: str 401 source: str = "library" 402 is_enabled: bool = True 403 content_hash: str = "" 404 created_at: Optional[datetime] = None 405 406 407 class UserSkillsResponse(BaseModel): 408 """List of user skills with limits.""" 409 skills: list[SkillResponse] = Field(default_factory=list) 410 limits: dict[str, Any] = Field(default_factory=dict) 411 412 413 class AssignSkillRequest(BaseModel): 414 """Request to assign a skill to a user.""" 415 name: str = Field(min_length=1, max_length=100) 416 source: str = Field(default="library") 417 418 419 class UploadSkillRequest(BaseModel): 420 """Request to upload a skill to the reseller library.""" 421 name: str = Field(min_length=1, max_length=100) 422 description: Optional[str] = Field(default=None, max_length=1000) 423 content: str = Field(min_length=1, max_length=51200) # 50KB max 424 425 426 # ============================================================================= 427 # Webhooks 428 # ============================================================================= 429 430 # Single source of truth for event types lives in webhook_service 431 from ..services.webhook_service import VALID_EVENT_TYPES as VALID_WEBHOOK_EVENTS 432 433 434 class CreateWebhookRequest(BaseModel): 435 """Request to register a webhook endpoint.""" 436 url: str = Field(min_length=1, max_length=2048, description="Webhook URL (HTTPS)") 437 events: list[str] = Field(description="Event types to subscribe to") 438 description: Optional[str] = Field(default=None, max_length=255) 439 440 @field_validator("events") 441 @classmethod 442 def validate_events(cls, v: list[str]) -> list[str]: 443 if not v: 444 raise ValueError("At least one event type required") 445 invalid = set(v) - VALID_WEBHOOK_EVENTS - {"*"} 446 if invalid: 447 raise ValueError(f"Invalid event types: {invalid}") 448 return v 449 450 451 class UpdateWebhookRequest(BaseModel): 452 """Request to update a webhook endpoint.""" 453 url: Optional[str] = Field(default=None, max_length=2048) 454 events: Optional[list[str]] = None 455 is_active: Optional[bool] = None 456 description: Optional[str] = Field(default=None, max_length=255) 457 458 @field_validator("events") 459 @classmethod 460 def validate_events(cls, v: Optional[list[str]]) -> Optional[list[str]]: 461 if v is not None: 462 invalid = set(v) - VALID_WEBHOOK_EVENTS - {"*"} 463 if invalid: 464 raise ValueError(f"Invalid event types: {invalid}") 465 return v 466 467 468 class WebhookEndpointResponse(BaseModel): 469 """Response for a webhook endpoint (secret is masked).""" 470 id: str 471 url: str 472 events: list[str] 473 is_active: bool 474 description: Optional[str] = None 475 created_at: datetime 476 updated_at: Optional[datetime] = None 477 478 479 class WebhookCreatedResponse(WebhookEndpointResponse): 480 """Response when creating a webhook (includes secret, shown once).""" 481 secret: str = Field(description="HMAC secret — shown only once") 482 483 484 class WebhookDeliveryResponse(BaseModel): 485 """Single delivery log entry.""" 486 id: int 487 event_type: str 488 status: str 489 attempts: int 490 max_attempts: int 491 response_status: Optional[int] = None 492 error: Optional[str] = None 493 last_attempt_at: Optional[datetime] = None 494 next_retry_at: Optional[datetime] = None 495 created_at: datetime 496 497 498 class WebhookDeliveryListResponse(BaseModel): 499 """List of delivery log entries.""" 500 deliveries: list[WebhookDeliveryResponse] = Field(default_factory=list) 501 502 503 class WebhookListResponse(BaseModel): 504 """List of webhook endpoints.""" 505 webhooks: list[WebhookEndpointResponse] = Field(default_factory=list) 506 507 508 # ============================================================================= 509 # Reseller Self-Service 510 # ============================================================================= 511 512 class ResellerProfileResponse(BaseModel): 513 """Reseller's own profile.""" 514 id: str 515 name: str 516 company: Optional[str] = None 517 contact_email: str 518 is_active: bool 519 limits: dict[str, Any] 520 llm_provider: Optional[str] = None 521 features: dict[str, Any] = Field(default_factory=dict) 522 spending: Optional[SpendingStatusResponse] = None 523 created_at: datetime 524 525 526 class ConnectionTestResponse(BaseModel): 527 """Response for test-connection endpoint.""" 528 status: str = "ok" 529 authenticated_as: str 530 reseller_id: str 531 server_version: str 532 timestamp: datetime 533 534 535 # ============================================================================= 536 # Admin — Reseller Management 537 # ============================================================================= 538 539 class CreateResellerRequest(BaseModel): 540 """Request to create a new reseller.""" 541 name: str = Field(min_length=1, max_length=100) 542 company: Optional[str] = Field(default=None, max_length=255) 543 contact_email: str = Field(description="Primary contact email") 544 password: str = Field(min_length=8, description="Reseller login password") 545 max_users: int = Field(default=50, ge=1, le=10000) 546 max_concurrent_tasks: int = Field(default=10, ge=1, le=100) 547 max_daily_tasks: int = Field(default=500, ge=1, le=100000) 548 max_monthly_spending_usd: Optional[float] = Field(default=None, ge=0) 549 max_daily_spending_usd: Optional[float] = Field(default=None, ge=0) 550 spending_alert_threshold_pct: int = Field(default=80, ge=1, le=100) 551 llm_provider: Optional[str] = Field(default=None) 552 features: Optional[dict[str, Any]] = Field(default=None) 553 notes: Optional[str] = Field(default=None, max_length=5000) 554 555 @field_validator("name") 556 @classmethod 557 def validate_name(cls, v: str) -> str: 558 if not re.match(r'^[a-zA-Z][a-zA-Z0-9_ -]{0,99}$', v): 559 raise ValueError( 560 "Name must start with a letter and contain only " 561 "letters, digits, spaces, hyphens, underscores" 562 ) 563 return v 564 565 566 class UpdateResellerRequest(BaseModel): 567 """Request to update a reseller.""" 568 name: Optional[str] = Field(default=None, max_length=100) 569 company: Optional[str] = Field(default=None, max_length=255) 570 contact_email: Optional[str] = None 571 max_users: Optional[int] = Field(default=None, ge=1, le=10000) 572 max_concurrent_tasks: Optional[int] = Field(default=None, ge=1, le=100) 573 max_daily_tasks: Optional[int] = Field(default=None, ge=1, le=100000) 574 max_monthly_spending_usd: Optional[float] = Field(default=None, ge=0) 575 max_daily_spending_usd: Optional[float] = Field(default=None, ge=0) 576 spending_alert_threshold_pct: Optional[int] = Field(default=None, ge=1, le=100) 577 llm_provider: Optional[str] = None 578 features: Optional[dict[str, Any]] = None 579 notes: Optional[str] = Field(default=None, max_length=5000) 580 581 582 class ResellerLimits(BaseModel): 583 """Reseller quota limits.""" 584 max_users: int = 50 585 current_users: int = 0 586 max_concurrent_tasks: int = 10 587 max_daily_tasks: int = 500 588 589 590 class ResellerSpending(BaseModel): 591 """Reseller spending status.""" 592 limits: SpendingLimits 593 current: SpendingCurrent 594 alert_threshold_pct: int = 80 595 596 597 class ResellerStats(BaseModel): 598 """Reseller statistics.""" 599 user_count: int = 0 600 active_users_30d: int = 0 601 total_sessions: int = 0 602 total_cost_usd: float = 0.0 603 api_keys_active: int = 0 604 sessions_this_month: int = 0 605 cost_this_month_usd: float = 0.0 606 607 608 class ResellerResponse(BaseModel): 609 """Response for a reseller.""" 610 id: str 611 name: str 612 company: Optional[str] = None 613 contact_email: str 614 owner_user_id: str 615 owner_username: Optional[str] = None 616 is_active: bool 617 suspended_at: Optional[datetime] = None 618 limits: ResellerLimits 619 llm_provider: Optional[str] = None 620 features: dict[str, Any] = Field(default_factory=dict) 621 spending: Optional[ResellerSpending] = None 622 stats: Optional[ResellerStats] = None 623 notes: Optional[str] = None 624 api_keys: Optional[list[APIKeyResponse]] = None 625 created_at: datetime 626 updated_at: Optional[datetime] = None 627 628 629 class ResellerListResponse(BaseModel): 630 """Paginated list of resellers.""" 631 resellers: list[ResellerResponse] = Field(default_factory=list) 632 pagination: PaginationInfo 633 634 635 class SuspendResellerResponse(BaseModel): 636 """Response from suspending a reseller.""" 637 id: str 638 name: str 639 is_active: bool 640 suspended_at: datetime 641 users_suspended: int = 0 642 sessions_cancelled: int = 0 643 api_keys_deactivated: int = 0 644 645 646 class UnsuspendResellerResponse(BaseModel): 647 """Response from unsuspending a reseller.""" 648 id: str 649 name: str 650 is_active: bool 651 users_restored: int = 0 652 api_keys_reactivated: int = 0 653 654 655 class DeleteResellerResponse(BaseModel): 656 """Response from deleting a reseller.""" 657 status: str = "deleted" 658 name: str 659 users_deleted: int = 0 660 sessions_deleted: int = 0 661 662 663 # ============================================================================= 664 # Admin — Platform Stats 665 # ============================================================================= 666 667 class PlatformStats(BaseModel): 668 """Platform-wide statistics.""" 669 platform: dict[str, Any] 670 resellers: dict[str, Any] 671 users: dict[str, Any] 672 sessions: dict[str, Any] 673 usage_this_month: dict[str, Any] 674 capacity: dict[str, Any] 675 676 677 class UpdatePlatformConfigRequest(BaseModel): 678 """Request to update platform-level defaults via PUT /admin/config.""" 679 features: Optional[dict[str, Any]] = Field( 680 default=None, description="Feature flag overrides (null values reset to hardcoded)" 681 ) 682 quotas: Optional[dict[str, Any]] = Field( 683 default=None, description="Quota overrides (null values reset to hardcoded)" 684 ) 685 spending: Optional[dict[str, Any]] = Field( 686 default=None, description="Spending limit overrides (null values reset to hardcoded)" 687 ) 688 689 690 class PlatformConfigResponse(BaseModel): 691 """Response for GET/PUT /admin/config.""" 692 default_features: dict[str, Any] 693 default_quotas: dict[str, Any] 694 default_spending_limits: dict[str, Any] 695 default_settings_mode: str = "readonly" 696 default_allowed_overrides: list[str] = Field(default_factory=list) 697 698 699 class AuditLogEntry(BaseModel): 700 """Single audit log entry.""" 701 id: int 702 timestamp: datetime 703 api_key_name: Optional[str] = None 704 reseller_name: Optional[str] = None 705 action: str 706 target_user: Optional[str] = None 707 ip_address: str 708 status_code: int 709 error: Optional[str] = None 710 711 712 class AuditLogResponse(BaseModel): 713 """Paginated audit log.""" 714 entries: list[AuditLogEntry] = Field(default_factory=list) 715 pagination: PaginationInfo 716 717 718 # ============================================================================= 719 # Data retention 720 # ============================================================================= 721 722 class RetentionConfigResponse(BaseModel): 723 """Current data retention configuration (days per table).""" 724 usage_records: int = Field(description="Retention in days for usage_records") 725 events: int = Field(description="Retention in days for events") 726 webhook_delivery_log: int = Field(description="Retention in days for webhook delivery log") 727 api_key_audit_log: int = Field(description="Retention in days for API key audit log") 728 729 730 class UpdateRetentionRequest(BaseModel): 731 """Update retention periods (values in days).""" 732 usage_records: Optional[int] = Field(default=None, ge=1) 733 events: Optional[int] = Field(default=None, ge=1) 734 webhook_delivery_log: Optional[int] = Field(default=None, ge=1) 735 api_key_audit_log: Optional[int] = Field(default=None, ge=1) 736 737 738 class RetentionRunResponse(BaseModel): 739 """Result of a manual retention purge run.""" 740 total_purged: int = Field(description="Total rows purged across all tables") 741 tables: dict[str, Any] = Field(description="Per-table purge results")