From 700723a7f4357b5de1b6690ef1d170beefdc9fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20N=C3=A9meth?= Date: Sat, 7 Mar 2026 23:06:52 +0100 Subject: [PATCH] =?UTF-8?q?Per-feature=20enable/disable=20toggles=20for=20?= =?UTF-8?q?all=20four=20AI=20features=20(Card=20Suggestion,=20Ability=20Re?= =?UTF-8?q?-roll,=20Deck=20Analytics,=20Card=20Swap)=20=E2=99=A6=20Per-fea?= =?UTF-8?q?ture=20token=20caps=20configurable=20per=20AI=20feature=20to=20?= =?UTF-8?q?control=20cost=20and=20response=20length=20=E2=99=A6=20Per-user?= =?UTF-8?q?=20rate=20limiting=20with=20configurable=20hourly=20and=20daily?= =?UTF-8?q?=20request=20quotas=20=E2=99=A6=20System-wide=20daily=20and=20m?= =?UTF-8?q?onthly=20cost=20budget=20enforcement=20with=20live=20spend=20st?= =?UTF-8?q?ats=20in=20AI=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 + app/Exceptions/AiLimitExceededException.php | 16 ++ app/Filament/Pages/AiSettings.php | 161 +++++++++++++ .../CardResource/Pages/CreateCard.php | 6 +- .../Resources/CardResource/Pages/EditCard.php | 10 +- .../Resources/DeckResource/Pages/EditDeck.php | 10 +- .../Resources/DeckResource/Pages/ViewDeck.php | 6 +- .../Resources/PhysicalCardResource.php | 2 +- app/Services/Ai/AbilityRerollService.php | 9 +- app/Services/Ai/AiBudgetGuard.php | 64 ++++++ app/Services/Ai/AiFeatureGuard.php | 45 ++++ app/Services/Ai/AiProxyService.php | 18 +- app/Services/Ai/AiRateLimiter.php | 50 ++++ app/Services/Ai/CardSuggestionService.php | 9 +- app/Services/Ai/CardSwapService.php | 9 +- app/Services/Ai/DeckAnalyticsService.php | 9 +- config/app_version.php | 4 +- database/seeders/AiSettingSeeder.php | 18 ++ dps/plans/ai-features-limiting.md | 217 ++++++++++++++++++ 19 files changed, 650 insertions(+), 21 deletions(-) create mode 100644 app/Exceptions/AiLimitExceededException.php create mode 100644 app/Services/Ai/AiBudgetGuard.php create mode 100644 app/Services/Ai/AiFeatureGuard.php create mode 100644 app/Services/Ai/AiRateLimiter.php create mode 100644 dps/plans/ai-features-limiting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d97ff2..bb528f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [1.7.5] - 2026-03-08 + +### Added +- Per-feature enable/disable toggles for all four AI features (Card Suggestion, Ability Re-roll, Deck Analytics, Card Swap) +- Per-feature token caps configurable per AI feature to control cost and response length +- Per-user rate limiting with configurable hourly and daily request quotas +- System-wide daily and monthly cost budget enforcement with live spend stats in AI Settings + ## [1.7.0] - 2026-03-07 ### Added diff --git a/app/Exceptions/AiLimitExceededException.php b/app/Exceptions/AiLimitExceededException.php new file mode 100644 index 0000000..395dbdb --- /dev/null +++ b/app/Exceptions/AiLimitExceededException.php @@ -0,0 +1,16 @@ + AiSetting::get('ai_max_tokens', 2048), 'ai_temperature' => AiSetting::get('ai_temperature', 0.7), 'ai_system_prompt' => AiSetting::get('ai_system_prompt', config('ai_prompts.card_generation.system', '')), + // Features + 'ai_feature_card_suggest' => filter_var(AiSetting::get('ai_feature_card_suggest', 'true'), FILTER_VALIDATE_BOOLEAN), + 'ai_feature_ability_reroll' => filter_var(AiSetting::get('ai_feature_ability_reroll', 'true'), FILTER_VALIDATE_BOOLEAN), + 'ai_feature_deck_analytics' => filter_var(AiSetting::get('ai_feature_deck_analytics', 'true'), FILTER_VALIDATE_BOOLEAN), + 'ai_feature_card_swap' => filter_var(AiSetting::get('ai_feature_card_swap', 'true'), FILTER_VALIDATE_BOOLEAN), + // Token caps + 'ai_tokens_card_suggest' => AiSetting::get('ai_tokens_card_suggest', 1024), + 'ai_tokens_ability_reroll' => AiSetting::get('ai_tokens_ability_reroll', 512), + 'ai_tokens_deck_analytics' => AiSetting::get('ai_tokens_deck_analytics', 2048), + 'ai_tokens_card_swap' => AiSetting::get('ai_tokens_card_swap', 1024), + // Rate limiting + 'ai_rate_limit_enabled' => filter_var(AiSetting::get('ai_rate_limit_enabled', 'true'), FILTER_VALIDATE_BOOLEAN), + 'ai_rate_limit_hourly' => AiSetting::get('ai_rate_limit_hourly', 20), + 'ai_rate_limit_daily' => AiSetting::get('ai_rate_limit_daily', 100), + // Budget + 'ai_budget_enabled' => filter_var(AiSetting::get('ai_budget_enabled', 'true'), FILTER_VALIDATE_BOOLEAN), + 'ai_budget_daily_usd' => AiSetting::get('ai_budget_daily_usd', 5.00), + 'ai_budget_monthly_usd' => AiSetting::get('ai_budget_monthly_usd', 50.00), ]); } @@ -137,6 +158,124 @@ public function form(Form $form): Form ->helperText('This prompt is sent with every card generation request. It defines the AI\'s domain knowledge and required JSON output format.') ->columnSpanFull(), ]), + + Section::make('Feature Toggles') + ->description('Enable or disable individual AI features. Disabled features are hidden from the UI.') + ->icon('heroicon-o-adjustments-vertical') + ->schema([ + Toggle::make('ai_feature_card_suggest') + ->label('AI Card Suggestion') + ->helperText('Allow supervisors to generate card data using AI on the Card Create/Edit pages.'), + + Toggle::make('ai_feature_ability_reroll') + ->label('Ability Re-roll') + ->helperText('Allow supervisors to re-generate card abilities with a chosen tone.'), + + Toggle::make('ai_feature_deck_analytics') + ->label('Deck Analytics') + ->helperText('Allow supervisors to run AI synergy and curve analysis on decks.'), + + Toggle::make('ai_feature_card_swap') + ->label('AI Card Swap') + ->helperText('Allow supervisors to get AI-powered card swap suggestions for decks.'), + ]) + ->columns(2), + + Section::make('Per-Feature Token Caps') + ->description('Limit the maximum tokens each AI feature can use per request. Lower values reduce cost but may produce shorter responses.') + ->icon('heroicon-o-calculator') + ->schema([ + TextInput::make('ai_tokens_card_suggest') + ->label('Card Suggestion (tokens)') + ->numeric() + ->minValue(100) + ->maxValue(8192) + ->default(1024), + + TextInput::make('ai_tokens_ability_reroll') + ->label('Ability Re-roll (tokens)') + ->numeric() + ->minValue(100) + ->maxValue(8192) + ->default(512), + + TextInput::make('ai_tokens_deck_analytics') + ->label('Deck Analytics (tokens)') + ->numeric() + ->minValue(100) + ->maxValue(8192) + ->default(2048), + + TextInput::make('ai_tokens_card_swap') + ->label('Card Swap (tokens)') + ->numeric() + ->minValue(100) + ->maxValue(8192) + ->default(1024), + ]) + ->columns(2), + + Section::make('Rate Limiting') + ->description('Limit how many AI requests each user can make per rolling time window.') + ->icon('heroicon-o-clock') + ->schema([ + Toggle::make('ai_rate_limit_enabled') + ->label('Enable Rate Limiting') + ->columnSpanFull(), + + TextInput::make('ai_rate_limit_hourly') + ->label('Max Requests per Hour (per user)') + ->numeric() + ->minValue(1) + ->maxValue(1000) + ->default(20) + ->helperText('Number of AI requests a single user can make within any 60-minute window.'), + + TextInput::make('ai_rate_limit_daily') + ->label('Max Requests per Day (per user)') + ->numeric() + ->minValue(1) + ->maxValue(10000) + ->default(100) + ->helperText('Number of AI requests a single user can make per calendar day.'), + ]) + ->columns(2), + + Section::make('Cost Budget') + ->description('Block all AI calls when estimated spend exceeds the configured limit.') + ->icon('heroicon-o-currency-dollar') + ->schema([ + Toggle::make('ai_budget_enabled') + ->label('Enable Budget Limiting') + ->columnSpanFull(), + + TextInput::make('ai_budget_daily_usd') + ->label('Daily Budget (USD)') + ->numeric() + ->minValue(0.01) + ->step(0.01) + ->default(5.00) + ->prefix('$') + ->helperText('System-wide daily spend cap. Resets at midnight.'), + + TextInput::make('ai_budget_monthly_usd') + ->label('Monthly Budget (USD)') + ->numeric() + ->minValue(0.01) + ->step(0.01) + ->default(50.00) + ->prefix('$') + ->helperText('System-wide monthly spend cap. Resets on the 1st of each month.'), + + Placeholder::make('daily_spend_stat') + ->label('Today\'s Spend') + ->content(fn () => '$' . number_format(app(AiBudgetGuard::class)->getDailySpend(), 4)), + + Placeholder::make('monthly_spend_stat') + ->label('This Month\'s Spend') + ->content(fn () => '$' . number_format(app(AiBudgetGuard::class)->getMonthlySpend(), 4)), + ]) + ->columns(2), ]) ->statePath('data'); } @@ -159,6 +298,28 @@ public function save(): void $this->saveSetting('ai_temperature', $data['ai_temperature'], 'number', 'parameters', 'Temperature', 2); $this->saveSetting('ai_system_prompt', $data['ai_system_prompt'], 'textarea', 'prompts', 'System Prompt', 1); + // Feature toggles + $this->saveSetting('ai_feature_card_suggest', $data['ai_feature_card_suggest'] ? 'true' : 'false', 'text', 'features', 'AI Card Suggestion', 1); + $this->saveSetting('ai_feature_ability_reroll', $data['ai_feature_ability_reroll'] ? 'true' : 'false', 'text', 'features', 'Ability Re-roll', 2); + $this->saveSetting('ai_feature_deck_analytics', $data['ai_feature_deck_analytics'] ? 'true' : 'false', 'text', 'features', 'Deck Analytics', 3); + $this->saveSetting('ai_feature_card_swap', $data['ai_feature_card_swap'] ? 'true' : 'false', 'text', 'features', 'AI Card Swap', 4); + + // Token caps + $this->saveSetting('ai_tokens_card_suggest', $data['ai_tokens_card_suggest'], 'number', 'token_caps', 'Card Suggestion Tokens', 1); + $this->saveSetting('ai_tokens_ability_reroll', $data['ai_tokens_ability_reroll'], 'number', 'token_caps', 'Ability Re-roll Tokens', 2); + $this->saveSetting('ai_tokens_deck_analytics', $data['ai_tokens_deck_analytics'], 'number', 'token_caps', 'Deck Analytics Tokens', 3); + $this->saveSetting('ai_tokens_card_swap', $data['ai_tokens_card_swap'], 'number', 'token_caps', 'Card Swap Tokens', 4); + + // Rate limiting + $this->saveSetting('ai_rate_limit_enabled', $data['ai_rate_limit_enabled'] ? 'true' : 'false', 'text', 'rate_limit', 'Rate Limiting Enabled', 1); + $this->saveSetting('ai_rate_limit_hourly', $data['ai_rate_limit_hourly'], 'number', 'rate_limit', 'Hourly Limit', 2); + $this->saveSetting('ai_rate_limit_daily', $data['ai_rate_limit_daily'], 'number', 'rate_limit', 'Daily Limit', 3); + + // Budget + $this->saveSetting('ai_budget_enabled', $data['ai_budget_enabled'] ? 'true' : 'false', 'text', 'budget', 'Budget Enabled', 1); + $this->saveSetting('ai_budget_daily_usd', $data['ai_budget_daily_usd'], 'number', 'budget', 'Daily Budget (USD)', 2); + $this->saveSetting('ai_budget_monthly_usd', $data['ai_budget_monthly_usd'], 'number', 'budget', 'Monthly Budget (USD)', 3); + Notification::make() ->title('Settings saved successfully.') ->success() diff --git a/app/Filament/Resources/CardResource/Pages/CreateCard.php b/app/Filament/Resources/CardResource/Pages/CreateCard.php index f3f9dd7..7b25213 100644 --- a/app/Filament/Resources/CardResource/Pages/CreateCard.php +++ b/app/Filament/Resources/CardResource/Pages/CreateCard.php @@ -5,10 +5,12 @@ namespace App\Filament\Resources\CardResource\Pages; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; use App\Filament\Resources\CardResource; use App\Models\CardType; use App\Models\Game; +use App\Services\Ai\AiFeatureGuard; use App\Services\Ai\CardSuggestionService; use Filament\Actions; use Filament\Forms; @@ -32,7 +34,7 @@ protected function getHeaderActions(): array ->label('AI Suggest') ->icon('heroicon-o-sparkles') ->color('warning') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('card_suggest')) ->form([ Forms\Components\Select::make('game_id') ->label('Game') @@ -72,7 +74,7 @@ protected function getHeaderActions(): array ->title('AI Suggestion Applied') ->body('Review the generated fields and save when ready.') ->send(); - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') diff --git a/app/Filament/Resources/CardResource/Pages/EditCard.php b/app/Filament/Resources/CardResource/Pages/EditCard.php index 14d9788..f156a56 100644 --- a/app/Filament/Resources/CardResource/Pages/EditCard.php +++ b/app/Filament/Resources/CardResource/Pages/EditCard.php @@ -5,11 +5,13 @@ namespace App\Filament\Resources\CardResource\Pages; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; use App\Filament\Resources\CardResource; use App\Models\CardType; use App\Models\Game; use App\Services\Ai\AbilityRerollService; +use App\Services\Ai\AiFeatureGuard; use App\Services\Ai\CardSuggestionService; use Filament\Actions; use Filament\Forms; @@ -27,7 +29,7 @@ protected function getHeaderActions(): array ->label('AI Suggest') ->icon('heroicon-o-sparkles') ->color('warning') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('card_suggest')) ->form([ Forms\Components\Select::make('game_id') ->label('Game') @@ -67,7 +69,7 @@ protected function getHeaderActions(): array ->title('AI Suggestion Applied') ->body('Review the generated fields and save when ready.') ->send(); - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') @@ -80,7 +82,7 @@ protected function getHeaderActions(): array ->label('Re-roll Ability') ->icon('heroicon-o-arrow-path') ->color('info') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('ability_reroll')) ->form([ Forms\Components\Select::make('tone') ->label('Tone') @@ -120,7 +122,7 @@ protected function getHeaderActions(): array ->body('Card ability updated. Save to persist the changes.') ->send(); } - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') diff --git a/app/Filament/Resources/DeckResource/Pages/EditDeck.php b/app/Filament/Resources/DeckResource/Pages/EditDeck.php index de698c5..6e2b0a3 100644 --- a/app/Filament/Resources/DeckResource/Pages/EditDeck.php +++ b/app/Filament/Resources/DeckResource/Pages/EditDeck.php @@ -5,10 +5,12 @@ namespace App\Filament\Resources\DeckResource\Pages; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; use App\Filament\Resources\DeckResource; use App\Models\Card; use App\Models\Deck; +use App\Services\Ai\AiFeatureGuard; use App\Services\Ai\CardSwapService; use App\Services\Ai\DeckAnalyticsService; use Filament\Actions; @@ -26,7 +28,7 @@ protected function getHeaderActions(): array ->label('AI Analytics') ->icon('heroicon-o-chart-bar') ->color('success') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('deck_analytics')) ->slideOver() ->modalHeading('Deck Synergy Analysis') ->modalSubmitAction(false) @@ -35,7 +37,7 @@ protected function getHeaderActions(): array try { $report = app(DeckAnalyticsService::class)->analyze($this->record->id); return view('filament.modals.deck-analytics', ['report' => $report]); - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') @@ -49,7 +51,7 @@ protected function getHeaderActions(): array ->label('AI Card Swap') ->icon('heroicon-o-arrows-right-left') ->color('warning') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('card_swap')) ->slideOver() ->modalHeading('AI Card Swap Suggestions') ->modalSubmitAction(false) @@ -61,7 +63,7 @@ protected function getHeaderActions(): array 'suggestions' => $suggestions, 'deckId' => $this->record->id, ]); - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') diff --git a/app/Filament/Resources/DeckResource/Pages/ViewDeck.php b/app/Filament/Resources/DeckResource/Pages/ViewDeck.php index 9a09010..1949501 100644 --- a/app/Filament/Resources/DeckResource/Pages/ViewDeck.php +++ b/app/Filament/Resources/DeckResource/Pages/ViewDeck.php @@ -6,8 +6,10 @@ namespace App\Filament\Resources\DeckResource\Pages; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; use App\Filament\Resources\DeckResource; +use App\Services\Ai\AiFeatureGuard; use App\Services\Ai\DeckAnalyticsService; use Filament\Actions; use Filament\Notifications\Notification; @@ -28,7 +30,7 @@ protected function getHeaderActions(): array ->label('AI Analytics') ->icon('heroicon-o-chart-bar') ->color('success') - ->visible(fn () => auth()->user()?->supervisor === true) + ->visible(fn () => AiFeatureGuard::isEnabled('deck_analytics')) ->slideOver() ->modalHeading('Deck Synergy Analysis') ->modalSubmitAction(false) @@ -37,7 +39,7 @@ protected function getHeaderActions(): array try { $report = app(DeckAnalyticsService::class)->analyze($this->record->id); return view('filament.modals.deck-analytics', ['report' => $report]); - } catch (AiSuggestionException $e) { + } catch (AiLimitExceededException | AiSuggestionException $e) { Notification::make() ->danger() ->title('AI Error') diff --git a/app/Filament/Resources/PhysicalCardResource.php b/app/Filament/Resources/PhysicalCardResource.php index 52d27d7..a6394a9 100644 --- a/app/Filament/Resources/PhysicalCardResource.php +++ b/app/Filament/Resources/PhysicalCardResource.php @@ -37,7 +37,7 @@ public static function getNavigationBadge(): ?string public static function canViewAny(): bool { - return auth()->user()?->isSupervisor() ?? false; + return auth()->check(); } public static function getEloquentQuery(): Builder diff --git a/app/Services/Ai/AbilityRerollService.php b/app/Services/Ai/AbilityRerollService.php index 78e9f7e..9110be2 100644 --- a/app/Services/Ai/AbilityRerollService.php +++ b/app/Services/Ai/AbilityRerollService.php @@ -5,8 +5,11 @@ namespace App\Services\Ai; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; +use App\Models\AiSetting; use App\Models\Card; +use App\Services\Ai\AiFeatureGuard; class AbilityRerollService { @@ -16,9 +19,11 @@ public function __construct(private AiProxyService $ai) {} * Re-generate card_text and card_data for an existing card with a given tone. * * @throws AiSuggestionException + * @throws AiLimitExceededException */ public function reroll(int $cardId, string $tone): array { + AiFeatureGuard::assertEnabled('ability_reroll'); $card = Card::find($cardId); if (!$card) { @@ -40,10 +45,12 @@ public function reroll(int $cardId, string $tone): array // Inject max_length into system prompt $systemPrompt = str_replace('{max_length}', $maxLength, config('ai_prompts.ability_reroll.system')); + $maxTokens = (int) (AiSetting::get('ai_tokens_ability_reroll') ?? 512); + $response = $this->ai->chat( systemPrompt: $systemPrompt, userPrompt: $userPrompt, - options: ['json_mode' => true] + options: ['json_mode' => true, 'max_tokens' => $maxTokens] ); if (!$response->isSuccess) { diff --git a/app/Services/Ai/AiBudgetGuard.php b/app/Services/Ai/AiBudgetGuard.php new file mode 100644 index 0000000..c2ea443 --- /dev/null +++ b/app/Services/Ai/AiBudgetGuard.php @@ -0,0 +1,64 @@ +getDailySpend(); + + if ($dailySpend >= $dailyBudget) { + throw new AiLimitExceededException( + sprintf('Daily AI budget exceeded ($%.4f / $%.2f). Resets at midnight.', $dailySpend, $dailyBudget) + ); + } + + $monthlySpend = $this->getMonthlySpend(); + + if ($monthlySpend >= $monthlyBudget) { + throw new AiLimitExceededException( + sprintf('Monthly AI budget exceeded ($%.4f / $%.2f). Contact your supervisor.', $monthlySpend, $monthlyBudget) + ); + } + } + + /** + * Get total estimated cost for today. + */ + public function getDailySpend(): float + { + return (float) AiLog::whereDate('created_at', today())->sum('cost_estimate'); + } + + /** + * Get total estimated cost for the current month. + */ + public function getMonthlySpend(): float + { + return (float) AiLog::whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->sum('cost_estimate'); + } +} diff --git a/app/Services/Ai/AiFeatureGuard.php b/app/Services/Ai/AiFeatureGuard.php new file mode 100644 index 0000000..740dd3c --- /dev/null +++ b/app/Services/Ai/AiFeatureGuard.php @@ -0,0 +1,45 @@ + 'AI Card Suggestion', + 'ability_reroll' => 'Ability Re-roll', + 'deck_analytics' => 'Deck Analytics', + 'card_swap' => 'AI Card Swap', + ]; + + $label = $labels[$feature] ?? $feature; + + throw new AiLimitExceededException("{$label} is currently disabled by the administrator."); + } + } +} diff --git a/app/Services/Ai/AiProxyService.php b/app/Services/Ai/AiProxyService.php index c76bad2..fe0c8f3 100644 --- a/app/Services/Ai/AiProxyService.php +++ b/app/Services/Ai/AiProxyService.php @@ -5,6 +5,7 @@ namespace App\Services\Ai; +use App\Exceptions\AiLimitExceededException; use App\Models\AiLog; use App\Models\AiSetting; use App\Services\Ai\Providers\AnthropicProvider; @@ -18,19 +19,32 @@ class AiProxyService private int $retryAttempts; private int $retryDelayMs; - public function __construct() - { + public function __construct( + private ?AiRateLimiter $rateLimiter = null, + private ?AiBudgetGuard $budgetGuard = null, + ) { $this->retryAttempts = (int) config('ai.retry_attempts', 3); $this->retryDelayMs = (int) config('ai.retry_delay_ms', 1000); $this->provider = $this->resolveProvider(); + $this->rateLimiter ??= new AiRateLimiter(); + $this->budgetGuard ??= new AiBudgetGuard(); } /** * Send a chat request through the configured AI provider. * Handles retry logic, logging, and error normalization. */ + /** + * @throws AiLimitExceededException + */ public function chat(string $systemPrompt, string $userPrompt, array $options = []): AiResponse { + // Enforce per-user rate limit and system-wide budget before calling the API + if (Auth::id()) { + $this->rateLimiter->check(Auth::id()); + } + $this->budgetGuard->check(); + $attempt = 0; $lastResponse = null; diff --git a/app/Services/Ai/AiRateLimiter.php b/app/Services/Ai/AiRateLimiter.php new file mode 100644 index 0000000..326110d --- /dev/null +++ b/app/Services/Ai/AiRateLimiter.php @@ -0,0 +1,50 @@ +where('created_at', '>=', now()->subHour()) + ->count(); + + if ($hourlyCount >= $hourlyLimit) { + throw new AiLimitExceededException( + "Hourly AI request limit reached ({$hourlyLimit}/hour). Please wait before making another request." + ); + } + + $dailyCount = AiLog::where('user_id', $userId) + ->whereDate('created_at', today()) + ->count(); + + if ($dailyCount >= $dailyLimit) { + throw new AiLimitExceededException( + "Daily AI request limit reached ({$dailyLimit}/day). Your quota resets at midnight." + ); + } + } +} diff --git a/app/Services/Ai/CardSuggestionService.php b/app/Services/Ai/CardSuggestionService.php index 205a5f2..6fb85f8 100644 --- a/app/Services/Ai/CardSuggestionService.php +++ b/app/Services/Ai/CardSuggestionService.php @@ -5,10 +5,13 @@ namespace App\Services\Ai; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; +use App\Models\AiSetting; use App\Models\Card; use App\Models\CardType; use App\Models\Game; +use App\Services\Ai\AiFeatureGuard; class CardSuggestionService { @@ -18,9 +21,11 @@ public function __construct(private AiProxyService $ai) {} * Generate a card suggestion based on game and card type context. * * @throws AiSuggestionException + * @throws AiLimitExceededException */ public function suggest(int $gameId, int $cardTypeId, string $goal = ''): array { + AiFeatureGuard::assertEnabled('card_suggest'); $game = Game::find($gameId); $cardType = CardType::find($cardTypeId); @@ -49,10 +54,12 @@ public function suggest(int $gameId, int $cardTypeId, string $goal = ''): array config('ai_prompts.card_suggestion.user_template') ); + $maxTokens = (int) (AiSetting::get('ai_tokens_card_suggest') ?? 1024); + $response = $this->ai->chat( systemPrompt: config('ai_prompts.card_suggestion.system'), userPrompt: $userPrompt, - options: ['json_mode' => true] + options: ['json_mode' => true, 'max_tokens' => $maxTokens] ); if (!$response->isSuccess) { diff --git a/app/Services/Ai/CardSwapService.php b/app/Services/Ai/CardSwapService.php index 0b7d1ba..aaac970 100644 --- a/app/Services/Ai/CardSwapService.php +++ b/app/Services/Ai/CardSwapService.php @@ -5,9 +5,12 @@ namespace App\Services\Ai; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; +use App\Models\AiSetting; use App\Models\Card; use App\Models\Deck; +use App\Services\Ai\AiFeatureGuard; class CardSwapService { @@ -17,9 +20,11 @@ public function __construct(private AiProxyService $ai) {} * Suggest card swaps for a deck, returning validated suggestions with card IDs. * * @throws AiSuggestionException + * @throws AiLimitExceededException */ public function suggest(int $deckId): array { + AiFeatureGuard::assertEnabled('card_swap'); $deck = Deck::with('game')->find($deckId); if (!$deck) { @@ -63,10 +68,12 @@ public function suggest(int $deckId): array config('ai_prompts.card_swap.user_template') ); + $maxTokens = (int) (AiSetting::get('ai_tokens_card_swap') ?? 1024); + $response = $this->ai->chat( systemPrompt: config('ai_prompts.card_swap.system'), userPrompt: $userPrompt, - options: ['json_mode' => true] + options: ['json_mode' => true, 'max_tokens' => $maxTokens] ); if (!$response->isSuccess) { diff --git a/app/Services/Ai/DeckAnalyticsService.php b/app/Services/Ai/DeckAnalyticsService.php index 654e61a..c5ee21b 100644 --- a/app/Services/Ai/DeckAnalyticsService.php +++ b/app/Services/Ai/DeckAnalyticsService.php @@ -5,9 +5,12 @@ namespace App\Services\Ai; +use App\Exceptions\AiLimitExceededException; use App\Exceptions\AiSuggestionException; +use App\Models\AiSetting; use App\Models\Card; use App\Models\Deck; +use App\Services\Ai\AiFeatureGuard; class DeckAnalyticsService { @@ -17,9 +20,11 @@ public function __construct(private AiProxyService $ai) {} * Analyze a deck and return a structured synergy report. * * @throws AiSuggestionException + * @throws AiLimitExceededException */ public function analyze(int $deckId): array { + AiFeatureGuard::assertEnabled('deck_analytics'); $deck = Deck::with('game')->find($deckId); if (!$deck) { @@ -70,10 +75,12 @@ public function analyze(int $deckId): array config('ai_prompts.deck_analytics.user_template') ); + $maxTokens = (int) (AiSetting::get('ai_tokens_deck_analytics') ?? 2048); + $response = $this->ai->chat( systemPrompt: config('ai_prompts.deck_analytics.system'), userPrompt: $userPrompt, - options: ['json_mode' => true] + options: ['json_mode' => true, 'max_tokens' => $maxTokens] ); if (!$response->isSuccess) { diff --git a/config/app_version.php b/config/app_version.php index 19ea95f..4df6654 100644 --- a/config/app_version.php +++ b/config/app_version.php @@ -11,9 +11,9 @@ | */ - 'version' => env('APP_VERSION', 'v1.7.0'), + 'version' => env('APP_VERSION', 'v1.7.5'), - 'release_date' => '2026-03-07', + 'release_date' => '2026-03-08', 'status' => 'stable', // alpha, beta, stable diff --git a/database/seeders/AiSettingSeeder.php b/database/seeders/AiSettingSeeder.php index ba07377..f0df3ef 100644 --- a/database/seeders/AiSettingSeeder.php +++ b/database/seeders/AiSettingSeeder.php @@ -70,6 +70,24 @@ public function run(): void 'description' => 'The system prompt sent with every card generation AI request.', 'order' => 1, ], + // Feature toggles + ['key' => 'ai_feature_card_suggest', 'value' => 'true', 'type' => 'text', 'group' => 'features', 'label' => 'AI Card Suggestion', 'description' => 'Enable the AI Suggest button on Card pages.', 'order' => 1], + ['key' => 'ai_feature_ability_reroll', 'value' => 'true', 'type' => 'text', 'group' => 'features', 'label' => 'Ability Re-roll', 'description' => 'Enable the Re-roll Ability button on Card Edit.', 'order' => 2], + ['key' => 'ai_feature_deck_analytics', 'value' => 'true', 'type' => 'text', 'group' => 'features', 'label' => 'Deck Analytics', 'description' => 'Enable the AI Analytics button on Deck pages.', 'order' => 3], + ['key' => 'ai_feature_card_swap', 'value' => 'true', 'type' => 'text', 'group' => 'features', 'label' => 'AI Card Swap', 'description' => 'Enable the AI Card Swap button on Deck Edit.', 'order' => 4], + // Token caps + ['key' => 'ai_tokens_card_suggest', 'value' => '1024', 'type' => 'number', 'group' => 'token_caps', 'label' => 'Card Suggestion Tokens', 'description' => 'Max tokens for card suggestion requests.', 'order' => 1], + ['key' => 'ai_tokens_ability_reroll', 'value' => '512', 'type' => 'number', 'group' => 'token_caps', 'label' => 'Ability Re-roll Tokens', 'description' => 'Max tokens for ability re-roll requests.', 'order' => 2], + ['key' => 'ai_tokens_deck_analytics', 'value' => '2048', 'type' => 'number', 'group' => 'token_caps', 'label' => 'Deck Analytics Tokens', 'description' => 'Max tokens for deck analytics requests.', 'order' => 3], + ['key' => 'ai_tokens_card_swap', 'value' => '1024', 'type' => 'number', 'group' => 'token_caps', 'label' => 'Card Swap Tokens', 'description' => 'Max tokens for card swap suggestion requests.','order' => 4], + // Rate limiting + ['key' => 'ai_rate_limit_enabled', 'value' => 'true', 'type' => 'text', 'group' => 'rate_limit', 'label' => 'Rate Limiting Enabled', 'description' => 'Enable per-user rate limiting.', 'order' => 1], + ['key' => 'ai_rate_limit_hourly', 'value' => '20', 'type' => 'number', 'group' => 'rate_limit', 'label' => 'Hourly Limit', 'description' => 'Max AI requests per user per hour.', 'order' => 2], + ['key' => 'ai_rate_limit_daily', 'value' => '100', 'type' => 'number', 'group' => 'rate_limit', 'label' => 'Daily Limit', 'description' => 'Max AI requests per user per calendar day.', 'order' => 3], + // Budget + ['key' => 'ai_budget_enabled', 'value' => 'true', 'type' => 'text', 'group' => 'budget', 'label' => 'Budget Enabled', 'description' => 'Enable system-wide cost budget enforcement.', 'order' => 1], + ['key' => 'ai_budget_daily_usd', 'value' => '5.00', 'type' => 'number', 'group' => 'budget', 'label' => 'Daily Budget (USD)', 'description' => 'Maximum estimated spend per day in USD.', 'order' => 2], + ['key' => 'ai_budget_monthly_usd', 'value' => '50.00','type' => 'number', 'group' => 'budget', 'label' => 'Monthly Budget (USD)', 'description' => 'Maximum estimated spend per month in USD.', 'order' => 3], ]; foreach ($defaults as $setting) { diff --git a/dps/plans/ai-features-limiting.md b/dps/plans/ai-features-limiting.md new file mode 100644 index 0000000..00f6cf8 --- /dev/null +++ b/dps/plans/ai-features-limiting.md @@ -0,0 +1,217 @@ +# AI Features Limiting + +**Feature:** Rate limiting, cost budgets, token caps, and per-feature toggles for all AI functionality +**Version:** 1.7.5 +**Status:** Planned +**Deploy Date:** 2026-03-08 +**Access:** Supervisors only + +--- + +## Overview + +Add a comprehensive control layer over all AI features. Supervisors can set per-user request quotas, daily/monthly cost budgets, per-feature token limits, and enable/disable individual AI features — all from the existing AI Settings admin page. The `AiProxyService` enforces all limits before making any API call. + +--- + +## 1. Rate Limiting (Requests per User) + +### Goal +Prevent a single user from flooding the AI API. Limits are enforced per user, per rolling window (hourly and daily). + +### Database Changes + +Add to `ai_settings` table (via seeder, no migration needed — uses existing key-value store): + +| Key | Default | Description | +|-----|---------|-------------| +| `ai_rate_limit_hourly` | `20` | Max requests per user per hour | +| `ai_rate_limit_daily` | `100` | Max requests per user per day | +| `ai_rate_limit_enabled` | `true` | Toggle rate limiting on/off | + +### Implementation + +**`app/Services/Ai/AiRateLimiter.php`** — new class: +- `check(int $userId): void` — throws `AiLimitExceededException` if over quota +- Uses `AiLog` table to count requests: `AiLog::where('user_id', $userId)->where('created_at', '>=', now()->subHour())->count()` +- No extra table needed — queries existing `ai_logs` + +**`AiProxyService::chat()`** — call `AiRateLimiter::check(Auth::id())` before provider call. + +### UI (AI Settings page — new "Rate Limiting" section) + +- Toggle: Enable Rate Limiting +- Number input: Max requests per hour (per user) +- Number input: Max requests per day (per user) + +--- + +## 2. Cost Budget Limiting + +### Goal +Cap total AI spend. When the daily or monthly estimated cost crosses the configured threshold, all AI calls are blocked until the period resets. + +### Database Changes (ai_settings keys) + +| Key | Default | Description | +|-----|---------|-------------| +| `ai_budget_daily_usd` | `5.00` | Max USD spend per day (system-wide) | +| `ai_budget_monthly_usd` | `50.00` | Max USD spend per month (system-wide) | +| `ai_budget_enabled` | `true` | Toggle budget enforcement on/off | + +### Implementation + +**`app/Services/Ai/AiBudgetGuard.php`** — new class: +- `check(): void` — throws `AiLimitExceededException` if budget exceeded +- `getDailySpend(): float` — `AiLog::whereDate('created_at', today())->sum('cost_estimate')` +- `getMonthlySpend(): float` — `AiLog::whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->sum('cost_estimate')` + +**`AiProxyService::chat()`** — call `AiBudgetGuard::check()` after rate limit check. + +### UI (AI Settings page — "Budget" section) + +- Toggle: Enable Budget Limiting +- Number input: Daily budget (USD) +- Number input: Monthly budget (USD) +- Read-only stat: Today's spend / Daily budget +- Read-only stat: This month's spend / Monthly budget + +--- + +## 3. Per-Feature Token Caps + +### Goal +Different AI features have very different complexity. Deck analytics needs more tokens than a simple ability re-roll. Supervisors set caps per feature. + +### Database Changes (ai_settings keys) + +| Key | Default | Description | +|-----|---------|-------------| +| `ai_tokens_card_suggest` | `1024` | Max tokens for card suggestion | +| `ai_tokens_ability_reroll` | `512` | Max tokens for ability re-roll | +| `ai_tokens_deck_analytics` | `2048` | Max tokens for deck analytics | +| `ai_tokens_card_swap` | `1024` | Max tokens for card swap | + +### Implementation + +Each service already passes `options` to `AiProxyService::chat()`. Load the cap from `AiSetting` in each service: + +```php +// Example in CardSuggestionService +$maxTokens = (int) (AiSetting::get('ai_tokens_card_suggest') ?? 1024); +$response = $this->ai->chat($system, $user, ['max_tokens' => $maxTokens]); +``` + +No changes to `AiProxyService` needed — providers already respect `options['max_tokens']`. + +### UI (AI Settings page — "Token Caps" section) + +Number input (100–8192) per feature: +- Card Suggestion max tokens +- Ability Re-roll max tokens +- Deck Analytics max tokens +- Card Swap max tokens + +--- + +## 4. Per-Feature Enable/Disable Toggles + +### Goal +Supervisors can disable individual AI features without touching code. Disabled features hide their action buttons in the Filament UI. + +### Database Changes (ai_settings keys) + +| Key | Default | Description | +|-----|---------|-------------| +| `ai_feature_card_suggest` | `true` | Enable/disable AI Suggest button | +| `ai_feature_ability_reroll` | `true` | Enable/disable Re-roll Ability button | +| `ai_feature_deck_analytics` | `true` | Enable/disable AI Analytics button | +| `ai_feature_card_swap` | `true` | Enable/disable AI Card Swap button | + +### Implementation + +**`app/Services/Ai/AiFeatureGuard.php`** — new class: +- `isEnabled(string $feature): bool` — reads `ai_feature_{$feature}` from `AiSetting` +- `assertEnabled(string $feature): void` — throws `AiLimitExceededException` if disabled + +**Filament Resource Pages** — wrap each header action's `visible()` callback: + +```php +->visible(fn() => AiFeatureGuard::isEnabled('card_suggest')) +``` + +Each service also calls `AiFeatureGuard::assertEnabled(...)` as a safety guard. + +### UI (AI Settings page — "Features" section) + +Four toggle switches: +- AI Card Suggestion +- Ability Re-roll +- Deck Analytics +- Card Swap + +--- + +## 5. Exception & User Feedback + +**`app/Exceptions/AiLimitExceededException.php`** — new exception: + +```php +class AiLimitExceededException extends \RuntimeException {} +``` + +All four AI service classes already catch `AiSuggestionException`. Extend catch blocks to also handle `AiLimitExceededException` and show a `Notification::danger()` with the limit reason (e.g. "Daily budget exceeded", "Hourly request limit reached", "This feature is currently disabled"). + +--- + +## 6. AI Settings Page — Updated Layout + +Reorganize the existing `/admin/ai-settings` page into clear sections: + +1. **Provider** — provider, API key, model (existing) +2. **Parameters** — global default max tokens, temperature (existing) +3. **System Prompt** — card generation prompt (existing) +4. **Features** — 4 feature toggles (new) +5. **Token Caps** — 4 per-feature token inputs (new) +6. **Rate Limiting** — enable toggle, hourly cap, daily cap (new) +7. **Budget** — enable toggle, daily USD, monthly USD + live spend stats (new) + +--- + +## 7. Seeder Updates + +Update `AiSettingSeeder` to seed all new keys with their defaults. No new migrations needed — all settings use the existing `ai_settings` key-value table. + +--- + +## Implementation Order + +1. `AiLimitExceededException` +2. `AiFeatureGuard` + feature toggle settings + UI + `visible()` on actions +3. Per-feature token cap settings in each service + UI inputs +4. `AiRateLimiter` + rate limit settings + UI +5. `AiBudgetGuard` + budget settings + UI with live spend stats +6. `AiProxyService::chat()` — integrate rate limiter and budget guard calls +7. Update `AiSettingSeeder` with all new defaults + +--- + +## Files to Create / Modify + +| Action | File | +|--------|------| +| Create | `app/Exceptions/AiLimitExceededException.php` | +| Create | `app/Services/Ai/AiFeatureGuard.php` | +| Create | `app/Services/Ai/AiRateLimiter.php` | +| Create | `app/Services/Ai/AiBudgetGuard.php` | +| Modify | `app/Services/Ai/AiProxyService.php` | +| Modify | `app/Services/Ai/CardSuggestionService.php` | +| Modify | `app/Services/Ai/AbilityRerollService.php` | +| Modify | `app/Services/Ai/DeckAnalyticsService.php` | +| Modify | `app/Services/Ai/CardSwapService.php` | +| Modify | `app/Filament/Pages/AiSettings.php` | +| Modify | `app/Filament/Resources/CardResource/Pages/CreateCard.php` | +| Modify | `app/Filament/Resources/CardResource/Pages/EditCard.php` | +| Modify | `app/Filament/Resources/DeckResource/Pages/EditDeck.php` | +| Modify | `app/Filament/Resources/DeckResource/Pages/ViewDeck.php` | +| Modify | `database/seeders/AiSettingSeeder.php` |