/ src / api / reseller_models.py
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")