feat(api,web): tier-based session limits and daily turn usage display
Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service
Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state
Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ from typing import Optional
|
||||
from app.services.redis_service import RedisService, RedisServiceError
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
@@ -75,25 +76,13 @@ class RateLimiterService:
|
||||
This service uses Redis to track daily AI usage per user and enforces
|
||||
limits based on subscription tier. Counters reset daily at midnight UTC.
|
||||
|
||||
Tier Limits:
|
||||
- Free: 20 turns/day
|
||||
- Basic: 50 turns/day
|
||||
- Premium: 100 turns/day
|
||||
- Elite: 200 turns/day
|
||||
Tier limits are loaded from config (rate_limiting.tiers.{tier}.ai_calls_per_day).
|
||||
A value of -1 means unlimited.
|
||||
|
||||
Attributes:
|
||||
redis: RedisService instance for counter storage
|
||||
tier_limits: Mapping of tier to daily turn limit
|
||||
"""
|
||||
|
||||
# Daily turn limits per tier
|
||||
TIER_LIMITS = {
|
||||
UserTier.FREE: 20,
|
||||
UserTier.BASIC: 50,
|
||||
UserTier.PREMIUM: 100,
|
||||
UserTier.ELITE: 200,
|
||||
}
|
||||
|
||||
# Daily DM question limits per tier
|
||||
DM_QUESTION_LIMITS = {
|
||||
UserTier.FREE: 10,
|
||||
@@ -167,15 +156,27 @@ class RateLimiterService:
|
||||
|
||||
def get_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||
"""
|
||||
Get the daily turn limit for a specific tier.
|
||||
Get the daily turn limit for a specific tier from config.
|
||||
|
||||
Args:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Daily turn limit for the tier
|
||||
Daily turn limit for the tier (-1 means unlimited)
|
||||
"""
|
||||
return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE])
|
||||
config = get_config()
|
||||
tier_name = user_tier.value.lower()
|
||||
tier_config = config.rate_limiting.tiers.get(tier_name)
|
||||
|
||||
if tier_config:
|
||||
return tier_config.ai_calls_per_day
|
||||
|
||||
# Fallback to default if tier not found in config
|
||||
logger.warning(
|
||||
"Tier not found in config, using default limit",
|
||||
tier=tier_name
|
||||
)
|
||||
return 50 # Default fallback
|
||||
|
||||
def get_current_usage(self, user_id: str) -> int:
|
||||
"""
|
||||
@@ -227,9 +228,19 @@ class RateLimiterService:
|
||||
RateLimitExceeded: If the user has reached their daily limit
|
||||
RedisServiceError: If Redis operation fails
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
logger.debug(
|
||||
"Rate limit check passed (unlimited)",
|
||||
user_id=user_id,
|
||||
user_tier=user_tier.value
|
||||
)
|
||||
return
|
||||
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
|
||||
if current_usage >= limit:
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
@@ -308,11 +319,15 @@ class RateLimiterService:
|
||||
user_tier: The user's subscription tier
|
||||
|
||||
Returns:
|
||||
Number of turns remaining (0 if limit reached)
|
||||
Number of turns remaining (-1 if unlimited, 0 if limit reached)
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
|
||||
# -1 means unlimited
|
||||
if limit == -1:
|
||||
return -1
|
||||
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
remaining = max(0, limit - current_usage)
|
||||
|
||||
logger.debug(
|
||||
@@ -339,16 +354,25 @@ class RateLimiterService:
|
||||
- user_id: User identifier
|
||||
- user_tier: Subscription tier
|
||||
- current_usage: Current daily usage
|
||||
- daily_limit: Daily limit for tier
|
||||
- remaining: Remaining turns
|
||||
- daily_limit: Daily limit for tier (-1 means unlimited)
|
||||
- remaining: Remaining turns (-1 if unlimited)
|
||||
- reset_time: ISO format UTC reset time
|
||||
- is_limited: Whether limit has been reached
|
||||
- is_limited: Whether limit has been reached (always False if unlimited)
|
||||
- is_unlimited: Whether user has unlimited turns
|
||||
"""
|
||||
current_usage = self.get_current_usage(user_id)
|
||||
limit = self.get_limit_for_tier(user_tier)
|
||||
remaining = max(0, limit - current_usage)
|
||||
reset_time = self._get_reset_time()
|
||||
|
||||
# Handle unlimited tier (-1)
|
||||
is_unlimited = (limit == -1)
|
||||
if is_unlimited:
|
||||
remaining = -1
|
||||
is_limited = False
|
||||
else:
|
||||
remaining = max(0, limit - current_usage)
|
||||
is_limited = current_usage >= limit
|
||||
|
||||
info = {
|
||||
"user_id": user_id,
|
||||
"user_tier": user_tier.value,
|
||||
@@ -356,7 +380,8 @@ class RateLimiterService:
|
||||
"daily_limit": limit,
|
||||
"remaining": remaining,
|
||||
"reset_time": reset_time.isoformat(),
|
||||
"is_limited": current_usage >= limit
|
||||
"is_limited": is_limited,
|
||||
"is_unlimited": is_unlimited
|
||||
}
|
||||
|
||||
logger.debug("Retrieved usage info", **info)
|
||||
|
||||
Reference in New Issue
Block a user