From c4c13f5566b4af6b6d6f7e10345de7967e2f874d Mon Sep 17 00:00:00 2001 From: Dihak Date: Thu, 21 Aug 2025 15:13:38 +0700 Subject: [PATCH 01/11] feat: implement application edition configuration and conditional logic for billing and WhatsApp features --- app/Http/Controllers/BillingController.php | 4 ++++ .../Controllers/WhatsAppMessageController.php | 11 ++++++++- app/Http/Middleware/HandleInertiaRequests.php | 1 + config/app.php | 13 ++++++++++ resources/js/Layouts/Partials/Sidebar.vue | 24 ++++++++++--------- routes/web.php | 15 ++++++------ 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 511ec9c..be137ac 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -17,6 +17,10 @@ class BillingController extends Controller public function __construct(XenditService $xenditService) { $this->xenditService = $xenditService; + + if (config('app.edition') !== 'cloud') { + abort(404); + } } public function index(Request $request) diff --git a/app/Http/Controllers/WhatsAppMessageController.php b/app/Http/Controllers/WhatsAppMessageController.php index aa66a7c..26dd66e 100644 --- a/app/Http/Controllers/WhatsAppMessageController.php +++ b/app/Http/Controllers/WhatsAppMessageController.php @@ -50,10 +50,19 @@ public function handleIncomingMessage(Request $request) return response()->json(['error' => 'No bot found for this channel'], 404); } + if (config('app.edition') === 'cloud') { + // Check if team has enough credits (150 per response) + if ($channel->team->balance->amount < 150) { + return response()->json(['error' => 'Insufficient credits. Please top up your credits to continue using the service.'], 402); + } + } + $response = $this->aiResponseService->generateResponse($bot, $messageContent, $sender, $channelId, $media); // Record usage transaction and deduct credits - $this->transactionService->recordUsage($channel->team); + if (config('app.edition') === 'cloud') { + $this->transactionService->recordUsage($channel->team); + } return response()->json(['response' => $response]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3f0572d..b61935e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,6 +42,7 @@ public function share(Request $request): array return array_merge(parent::share($request), [ 'flash' => $session->all(), + 'appEdition' => config('app.edition'), ]); } } diff --git a/config/app.php b/config/app.php index edd0b26..fec9bd5 100644 --- a/config/app.php +++ b/config/app.php @@ -17,6 +17,19 @@ 'name' => env('APP_NAME', 'Herobot'), + /* + |-------------------------------------------------------------------------- + | Application Edition + |-------------------------------------------------------------------------- + | + | This value determines the "edition" of your application, which can be + | "cloud" or "self-hosted". This is used to enable or disable certain + | features depending on the edition. + | + */ + + 'edition' => env('APP_EDITION', 'self-hosted'), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/resources/js/Layouts/Partials/Sidebar.vue b/resources/js/Layouts/Partials/Sidebar.vue index ffeb872..39e9254 100644 --- a/resources/js/Layouts/Partials/Sidebar.vue +++ b/resources/js/Layouts/Partials/Sidebar.vue @@ -136,15 +136,17 @@ @@ -174,20 +176,20 @@ import ApplicationLogo from '@/Components/ApplicationLogo.vue'; import BotIcon from '@/Assets/Icons/BotIcon.svg'; const page = usePage(); +const isSelfHosted = page.props.appEdition !== 'cloud'; const navigation = [ { name: 'Dashboard', href: route('dashboard'), icon: HomeIcon, current: route().current('dashboard') }, { name: 'Bot Management', href: route('bots.index'), icon: BotIcon, current: route().current('bots*') }, { name: 'Knowledge', href: route('knowledges.index'), icon: DocumentDuplicateIcon, current: route().current('knowledges*') }, { name: 'Tools', href: route('tools.index'), icon: WrenchScrewdriverIcon, current: route().current('tools*') }, - // { name: 'Reports', href: route('reports'), icon: ChartPieIcon, current: route().current('reports*') }, { name: 'Channels', href: route('channels.index'), icon: LinkIcon, current: route().current('channels*') }, ] const bottomNavigation = [ { name: 'Team Settings', href: route('teams.show', page.props.auth.user.current_team), icon: UserGroupIcon, current: route().current('teams.show') }, + { name: 'Billing & Usage', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*'), hide: isSelfHosted }, { name: 'Settings', href: route('profile.show'), icon: Cog6ToothIcon, current: route().current('profile.show') }, - // { name: 'Billing & Usage', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*') }, ] const switchToTeam = (team) => { diff --git a/routes/web.php b/routes/web.php index f7a61bb..d5f70ef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -81,14 +81,13 @@ Route::post('/bots/{bot}/test-message', [BotController::class, 'testMessage'])->name('bots.test-message'); Route::delete('/bots/{bot}/clear-chat', [BotController::class, 'clearChat'])->name('bots.clear-chat'); - // Route::get('/billing', [BillingController::class, 'index'])->name('billing.index'); - // Route::post('/billing/topup', [BillingController::class, 'topup'])->name('billing.topup'); - // Route::get('/billing/topup/success', [BillingController::class, 'topupSuccess'])->name('billing.topup.success'); - // Route::get('/billing/topup/failure', [BillingController::class, 'topupFailure'])->name('billing.topup.failure'); - - // Route::post('/billing/webhook', [BillingController::class, 'handleWebhook']) - // ->name('billing.webhook') - // ->withoutMiddleware(['auth:sanctum', 'web', 'verified', 'verify_csrf_token']); + Route::get('/billing', [BillingController::class, 'index'])->name('billing.index'); + Route::post('/billing/topup', [BillingController::class, 'topup'])->name('billing.topup'); + Route::get('/billing/topup/success', [BillingController::class, 'topupSuccess'])->name('billing.topup.success'); + Route::get('/billing/topup/failure', [BillingController::class, 'topupFailure'])->name('billing.topup.failure'); + Route::post('/billing/webhook', [BillingController::class, 'handleWebhook']) + ->name('billing.webhook') + ->withoutMiddleware(['auth:sanctum', 'web', 'verified', 'verify_csrf_token']); }); Route::get('/terms', function () { From ef07ac77f4fe0f7d59f6986300217f783f17037f Mon Sep 17 00:00:00 2001 From: Dihak Date: Fri, 22 Aug 2025 15:04:24 +0700 Subject: [PATCH 02/11] feat: add ai usage token history --- app/Http/Controllers/UsageController.php | 69 +++++ .../Controllers/WhatsAppMessageController.php | 20 +- app/Models/TokenUsage.php | 80 ++++++ app/Services/AIResponseService.php | 215 +++++++++++++-- .../Contracts/ChatServiceInterface.php | 4 + .../Contracts/EmbeddingServiceInterface.php | 4 + app/Services/GeminiService.php | 67 ++++- app/Services/OpenAIService.php | 65 ++++- app/Services/TokenPricingService.php | 162 +++++++++++ ...08_22_063003_create_token_usages_table.php | 40 +++ resources/js/Layouts/Partials/Sidebar.vue | 4 +- resources/js/Pages/Usage/Index.vue | 261 ++++++++++++++++++ routes/web.php | 3 + 13 files changed, 941 insertions(+), 53 deletions(-) create mode 100644 app/Http/Controllers/UsageController.php create mode 100644 app/Models/TokenUsage.php create mode 100644 app/Services/TokenPricingService.php create mode 100644 database/migrations/2025_08_22_063003_create_token_usages_table.php create mode 100644 resources/js/Pages/Usage/Index.vue diff --git a/app/Http/Controllers/UsageController.php b/app/Http/Controllers/UsageController.php new file mode 100644 index 0000000..f2f763e --- /dev/null +++ b/app/Http/Controllers/UsageController.php @@ -0,0 +1,69 @@ +user()->currentTeam; + + // Get token usage data for the current team + $usages = TokenUsage::where('team_id', $team->id) + ->with('bot:id,name') + ->orderBy('created_at', 'desc') + ->paginate(50); + + // Calculate summary statistics + $totalCredits = TokenUsage::where('team_id', $team->id)->sum('credits'); + $totalInputTokens = TokenUsage::where('team_id', $team->id)->sum('input_tokens'); + $totalOutputTokens = TokenUsage::where('team_id', $team->id)->sum('output_tokens'); + + // Get usage by provider + $usageByProvider = TokenUsage::where('team_id', $team->id) + ->selectRaw('provider, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens') + ->groupBy('provider') + ->get(); + + // Get usage by model + $usageByModel = TokenUsage::where('team_id', $team->id) + ->selectRaw('provider, model, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, COUNT(*) as usage_count') + ->groupBy('provider', 'model') + ->orderBy('total_credits', 'desc') + ->get(); + + // Get daily usage for the last 30 days + $dailyUsage = TokenUsage::where('team_id', $team->id) + ->selectRaw('DATE(created_at) as date, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens') + ->where('created_at', '>=', now()->subDays(30)) + ->groupBy('date') + ->orderBy('date', 'desc') + ->get(); + + return Inertia::render('Usage/Index', [ + 'usages' => $usages, + 'summary' => [ + 'total_credits' => round($totalCredits, 2), + 'total_input_tokens' => $totalInputTokens, + 'total_output_tokens' => $totalOutputTokens, + 'total_tokens' => $totalInputTokens + $totalOutputTokens, + ], + 'usage_by_provider' => $usageByProvider, + 'usage_by_model' => $usageByModel, + 'daily_usage' => $dailyUsage, + ]); + } +} diff --git a/app/Http/Controllers/WhatsAppMessageController.php b/app/Http/Controllers/WhatsAppMessageController.php index 26dd66e..4d1010e 100644 --- a/app/Http/Controllers/WhatsAppMessageController.php +++ b/app/Http/Controllers/WhatsAppMessageController.php @@ -6,22 +6,22 @@ use App\Models\Channel; use App\Services\AIResponseService; use App\Services\MediaProcessingService; -use App\Services\TransactionService; +use App\Services\TokenPricingService; use Illuminate\Http\Request; class WhatsAppMessageController extends Controller { protected $aiResponseService; - protected $transactionService; + protected $tokenPricingService; protected $mediaProcessingService; public function __construct( AIResponseService $aiResponseService, - TransactionService $transactionService, + TokenPricingService $tokenPricingService, MediaProcessingService $mediaProcessingService ) { $this->aiResponseService = $aiResponseService; - $this->transactionService = $transactionService; + $this->tokenPricingService = $tokenPricingService; $this->mediaProcessingService = $mediaProcessingService; } @@ -50,20 +50,8 @@ public function handleIncomingMessage(Request $request) return response()->json(['error' => 'No bot found for this channel'], 404); } - if (config('app.edition') === 'cloud') { - // Check if team has enough credits (150 per response) - if ($channel->team->balance->amount < 150) { - return response()->json(['error' => 'Insufficient credits. Please top up your credits to continue using the service.'], 402); - } - } - $response = $this->aiResponseService->generateResponse($bot, $messageContent, $sender, $channelId, $media); - // Record usage transaction and deduct credits - if (config('app.edition') === 'cloud') { - $this->transactionService->recordUsage($channel->team); - } - return response()->json(['response' => $response]); } diff --git a/app/Models/TokenUsage.php b/app/Models/TokenUsage.php new file mode 100644 index 0000000..da1a4fc --- /dev/null +++ b/app/Models/TokenUsage.php @@ -0,0 +1,80 @@ + 'integer', + 'output_tokens' => 'integer', + 'tokens_per_second' => 'decimal:2', + 'credits' => 'decimal:6', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * Get the team that owns the token usage. + */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + /** + * Get the bot that owns the token usage. + */ + public function bot(): BelongsTo + { + return $this->belongsTo(Bot::class); + } + + /** + * Get the total tokens used. + */ + public function getTotalTokensAttribute(): int + { + return $this->input_tokens + $this->output_tokens; + } + + /** + * Scope to filter by provider. + */ + public function scopeProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + /** + * Scope to filter by model. + */ + public function scopeModel($query, string $model) + { + return $query->where('model', $model); + } + + /** + * Scope to filter by date range. + */ + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('created_at', [$startDate, $endDate]); + } +} diff --git a/app/Services/AIResponseService.php b/app/Services/AIResponseService.php index b3fc254..07996f4 100644 --- a/app/Services/AIResponseService.php +++ b/app/Services/AIResponseService.php @@ -4,32 +4,51 @@ use App\Services\AIServiceFactory; use App\Services\Contracts\EmbeddingServiceInterface; +use App\Services\Contracts\ChatServiceInterface; +use App\Services\TokenPricingService; +use App\Models\Bot; use App\Models\ChatMedia; use App\Models\Tool; use App\Models\ChatHistory; +use App\Models\TokenUsage; use Illuminate\Support\Facades\Log; - +use Illuminate\Support\Collection; + +/** + * AI Response Service + * + * Handles AI response generation, tool calling, knowledge search, + * and chat history management for bots. + */ class AIResponseService { protected ToolService $toolService; + protected TokenPricingService $tokenPricingService; protected bool $toolCallingEnabled = true; - public function __construct(ToolService $toolService) + /** + * Constructor + * + * @param ToolService $toolService Service for handling tool execution + * @param TokenPricingService $tokenPricingService Service for calculating token costs + */ + public function __construct(ToolService $toolService, TokenPricingService $tokenPricingService) { $this->toolService = $toolService; + $this->tokenPricingService = $tokenPricingService; } /** * Generate AI response for a bot with message and chat history. * - * @param object $bot Instance model bot (memiliki properti "prompt") - * @param string $message Pesan terbaru dari pengguna - * @param string $sender Sender identifier - * @param int|null $channelId Channel ID (nullable for testing) - * @param \App\Models\ChatMedia|null $media Media data (optional) - * @param string $format Output format: 'whatsapp' or 'html' (default: 'whatsapp') - * @return string|bool String berisi jawaban terformat, atau false kalau gagal + * @param Bot $bot Bot instance with prompt property + * @param string $message Latest message from user + * @param string $sender Sender identifier + * @param int|null $channelId Channel ID (nullable for testing) + * @param ChatMedia|null $media Media data (optional) + * @param string $format Output format: 'whatsapp' or 'html' + * @return string|false Formatted response string or false on failure */ - public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $media, $format = 'whatsapp') + public function generateResponse(Bot $bot, string $message, string $sender, ?int $channelId, ?ChatMedia $media = null, string $format = 'whatsapp'): string|false { try { // Get chat history from database @@ -58,7 +77,9 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $embeddingService = AIServiceFactory::createEmbeddingService(); // Search for relevant knowledge using embedding service - $relevantKnowledge = $this->searchSimilarKnowledge($embeddingService, $message, $bot, 3); + $embeddingResult = $this->searchSimilarKnowledge($embeddingService, $message, $bot, 3); + $relevantKnowledge = $embeddingResult['knowledge']; + $embeddingTokenUsage = $embeddingResult['token_usage'] ?? null; // Build system prompt $systemPrompt = $this->buildSystemPrompt($bot, $relevantKnowledge); @@ -72,6 +93,7 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia : []; // Generate response using chat service + $startTime = microtime(true); $response = $chatService->generateResponse( $messages, null, // model parameter @@ -79,10 +101,14 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $media ? $media->mime_type : null, $tools ); + $endTime = microtime(true); + $responseTime = $endTime - $startTime; $toolCalls = null; $toolResponses = null; $rawContent = null; + $chatTokenUsage = $response['token_usage'] ?? null; + $finalTokenUsage = null; // Handle tool calls if present in the response if (is_array($response) && isset($response['tool_calls']) && !empty($response['tool_calls'])) { @@ -119,11 +145,24 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $messages = array_merge($messages, $toolResponses); // Generate final response + $finalStartTime = microtime(true); $finalResponse = $chatService->generateResponse($messages, null, null, null, []); + $finalEndTime = microtime(true); + $finalResponseTime = $finalEndTime - $finalStartTime; + $responseContent = is_array($finalResponse) ? ($finalResponse['content'] ?? '') : $finalResponse; $rawContent = $responseContent; + $finalTokenUsage = $finalResponse['token_usage'] ?? null; + + // Combine token usage from both calls + if ($chatTokenUsage && $finalTokenUsage) { + $chatTokenUsage['input_tokens'] += $finalTokenUsage['input_tokens']; + $chatTokenUsage['output_tokens'] += $finalTokenUsage['output_tokens']; + $chatTokenUsage['total_tokens'] += $finalTokenUsage['total_tokens']; + } + $responseTime += $finalResponseTime; } else { - $responseContent = is_array($response) ? ($response['content'] ?? $response) : $response; + $responseContent = is_array($response) ? ($response['content'] ?? '') : $response; $rawContent = $responseContent; } @@ -135,6 +174,12 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $formattedResponse = $this->convertMarkdownToWhatsApp($responseContent); } + // Record token usage and calculate costs + $this->recordTokenUsage($bot, $chatService, $chatTokenUsage, $responseTime); + if ($embeddingTokenUsage) { + $this->recordTokenUsage($bot, $embeddingService, $embeddingTokenUsage, 0, 'embedding'); + } + // Save assistant response to chat history $this->saveChatHistory([ 'channel_id' => $channelId, @@ -148,7 +193,9 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia 'format' => $format, 'model_used' => get_class($chatService), 'timestamp' => now()->toISOString(), - 'knowledge_used' => $relevantKnowledge->isNotEmpty() + 'knowledge_used' => $relevantKnowledge->isNotEmpty(), + 'token_usage' => $chatTokenUsage, + 'embedding_token_usage' => $embeddingTokenUsage ] ]); @@ -161,8 +208,12 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia /** * Build system prompt with bot prompt and relevant knowledge. + * + * @param Bot $bot Bot instance with prompt property + * @param Collection $relevantKnowledge Collection of relevant knowledge items + * @return string Complete system prompt */ - private function buildSystemPrompt($bot, $relevantKnowledge) + private function buildSystemPrompt(Bot $bot, Collection $relevantKnowledge): string { $systemPrompt = $bot->prompt; @@ -180,8 +231,13 @@ private function buildSystemPrompt($bot, $relevantKnowledge) /** * Build messages array from system prompt, chat history, and current message. + * + * @param string $systemPrompt System prompt text + * @param Collection $chatHistory Collection of chat history items + * @param string $message Current user message + * @return array Array of messages formatted for AI service */ - private function buildMessagesArray($systemPrompt, $chatHistory, $message) + private function buildMessagesArray(string $systemPrompt, Collection $chatHistory, string $message): array { $messages = [ ['role' => 'system', 'content' => $systemPrompt], @@ -221,8 +277,11 @@ private function buildMessagesArray($systemPrompt, $chatHistory, $message) /** * Convert markdown formatting to HTML. + * + * @param string $text Markdown text to convert + * @return string HTML formatted text */ - public function convertMarkdownToHtml($text) + public function convertMarkdownToHtml(string $text): string { // Convert headers: # text to

text

, ## text to

text

, etc. $text = preg_replace_callback('/^(#{1,6})\s+(.*)$/m', function($matches) { @@ -263,8 +322,11 @@ public function convertMarkdownToHtml($text) /** * Convert markdown formatting to WhatsApp-compatible formatting. + * + * @param string $text Markdown text to convert + * @return string WhatsApp formatted text */ - public function convertMarkdownToWhatsApp($text) + public function convertMarkdownToWhatsApp(string $text): string { // Convert italic: *text* or _text_ to _text_ $text = preg_replace('/(?createEmbedding($query); + $embeddingResult = $embeddingService->createEmbedding($query); + $queryEmbedding = $embeddingResult['embeddings'][0] ?? $embeddingResult; + $tokenUsage = $embeddingResult['token_usage'] ?? null; // Get only necessary vectors with optimized query $knowledgeVectors = $bot->knowledge() @@ -311,21 +384,33 @@ public function searchSimilarKnowledge(EmbeddingServiceInterface $embeddingServi }); // Sort and limit results - return $knowledgeVectors->sortByDesc('similarity') + $knowledge = $knowledgeVectors->sortByDesc('similarity') ->take($limit) ->values(); + return [ + 'knowledge' => $knowledge, + 'token_usage' => $tokenUsage + ]; + } catch (\Exception $e) { Log::error('Error searching similar knowledge: ' . $e->getMessage()); - return collect(); + return [ + 'knowledge' => collect(), + 'token_usage' => null + ]; } } /** * Calculate similarity between two vectors using fast C extension if available, * otherwise fallback to PHP implementation. + * + * @param array $vector1 First vector + * @param array $vector2 Second vector + * @return float Similarity score between 0 and 1 */ - protected function calculateSimilarity($vector1, $vector2) + protected function calculateSimilarity(array $vector1, array $vector2): float { if (function_exists('fast_cosine_similarity')) { return fast_cosine_similarity($vector1, $vector2); @@ -336,8 +421,12 @@ protected function calculateSimilarity($vector1, $vector2) /** * Calculate cosine similarity between two vectors using PHP implementation. + * + * @param array $vector1 First vector + * @param array $vector2 Second vector + * @return float Cosine similarity score between -1 and 1 */ - protected function cosineSimilarity($vector1, $vector2) + protected function cosineSimilarity(array $vector1, array $vector2): float { $dotProduct = 0; $norm1 = 0; @@ -357,8 +446,11 @@ protected function cosineSimilarity($vector1, $vector2) /** * Get available tools for a bot. + * + * @param Bot $bot Bot instance with team_id property + * @return array Array of formatted tools for AI service */ - protected function getAvailableToolsForBot($bot): array + protected function getAvailableToolsForBot(Bot $bot): array { $tools = Tool::where('team_id', $bot->team_id) ->where('is_active', true) @@ -378,8 +470,14 @@ protected function getAvailableToolsForBot($bot): array /** * Handle tool calls from AI response. + * + * @param array $toolCalls Array of tool calls from AI response + * @param Bot $bot Bot instance + * @param int|null $channelId Channel ID (optional) + * @param string|null $sender Sender identifier (optional) + * @return array Array of tool responses */ - protected function handleToolCalls(array $toolCalls, $bot, $channelId = null, $sender = null): array + protected function handleToolCalls(array $toolCalls, Bot $bot, ?int $channelId = null, ?string $sender = null): array { $toolResponses = []; @@ -575,6 +673,10 @@ protected function handleToolCalls(array $toolCalls, $bot, $channelId = null, $s * - Must start with a letter or underscore * - Must be alphanumeric (a-z, A-Z, 0-9), underscores (_), dots (.) or dashes (-) * - Maximum length of 64 characters + * + * @param int $id Tool ID to append to function name + * @param string $name Original function name + * @return string Sanitized function name */ private function sanitizeFunctionName(int $id, string $name): string { @@ -607,8 +709,14 @@ private function sanitizeFunctionName(int $id, string $name): string /** * Get chat history for a specific channel, sender, and bot. + * + * @param int $botId Bot ID + * @param int|null $channelId Channel ID (nullable) + * @param string $sender Sender identifier + * @param int $limit Maximum number of history items to retrieve + * @return Collection Collection of chat history items */ - protected function getChatHistory($botId, $channelId, $sender, $limit = 5) + protected function getChatHistory(int $botId, ?int $channelId, string $sender, int $limit = 5): Collection { $query = ChatHistory::where('bot_id', $botId) ->where('sender', $sender) @@ -624,10 +732,63 @@ protected function getChatHistory($botId, $channelId, $sender, $limit = 5) return $query->get()->reverse()->values(); } + /** + * Record token usage and calculate costs. + * + * @param Bot $bot Bot instance with team_id property + * @param ChatServiceInterface|EmbeddingServiceInterface $service AI service instance + * @param array|null $tokenUsage Token usage data with input_tokens and output_tokens + * @param float $responseTime Response time in seconds + * @param string $type Usage type ('chat' or 'embedding') + * @return void + */ + protected function recordTokenUsage(Bot $bot, ChatServiceInterface|EmbeddingServiceInterface $service, ?array $tokenUsage, float $responseTime, string $type = 'chat'): void + { + if (!$tokenUsage || !isset($tokenUsage['input_tokens'], $tokenUsage['output_tokens'])) { + return; + } + + // Determine provider and model + $provider = $service->getProvider(); + $model = $type === 'chat' ? $service->getModel() : $service->getEmbeddingModel(); + + // Calculate tokens per second + $totalTokens = $tokenUsage['output_tokens']; + $tokensPerSecond = $responseTime > 0 && $totalTokens > 0 ? round($totalTokens / $responseTime, 2) : null; + + // Calculate cost + $credits = $this->tokenPricingService->calculateCost( + $provider, + $model, + $tokenUsage['input_tokens'], + $tokenUsage['output_tokens'] + ); + + // Store token usage + TokenUsage::create([ + 'team_id' => $bot->team_id, + 'bot_id' => $bot->id, + 'provider' => $provider, + 'model' => $model, + 'input_tokens' => $tokenUsage['input_tokens'], + 'output_tokens' => $tokenUsage['output_tokens'], + 'tokens_per_second' => $tokensPerSecond, + 'credits' => $credits, + ]); + + // Deduct credits from team balance + if ($bot->team && $bot->team->balance) { + $bot->team->balance->decrement('amount', $credits); + } + } + /** * Save chat history entry. + * + * @param array $data Chat history data to save + * @return ChatHistory Created chat history instance */ - protected function saveChatHistory(array $data) + protected function saveChatHistory(array $data): ChatHistory { return ChatHistory::create($data); } diff --git a/app/Services/Contracts/ChatServiceInterface.php b/app/Services/Contracts/ChatServiceInterface.php index 86c9010..3bb5dbf 100644 --- a/app/Services/Contracts/ChatServiceInterface.php +++ b/app/Services/Contracts/ChatServiceInterface.php @@ -5,4 +5,8 @@ interface ChatServiceInterface { public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string; + + public function getProvider(): string; + + public function getModel(): string; } \ No newline at end of file diff --git a/app/Services/Contracts/EmbeddingServiceInterface.php b/app/Services/Contracts/EmbeddingServiceInterface.php index ea3c428..f07f532 100644 --- a/app/Services/Contracts/EmbeddingServiceInterface.php +++ b/app/Services/Contracts/EmbeddingServiceInterface.php @@ -5,4 +5,8 @@ interface EmbeddingServiceInterface { public function createEmbedding(string|array $text): array; + + public function getProvider(): string; + + public function getEmbeddingModel(): string; } \ No newline at end of file diff --git a/app/Services/GeminiService.php b/app/Services/GeminiService.php index d05d1cb..3d1994b 100644 --- a/app/Services/GeminiService.php +++ b/app/Services/GeminiService.php @@ -29,7 +29,31 @@ public function __construct() ]); } - public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string + /** + * Get the configured provider name + */ + public function getProvider(): string + { + return 'Gemini'; + } + + /** + * Get the configured chat model name + */ + public function getModel(): string + { + return $this->model; + } + + /** + * Get the configured embedding model name + */ + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array { $model = $model ?? $this->model; @@ -114,6 +138,14 @@ public function generateResponse(array $messages, ?string $model = null, ?string throw new \Exception('Invalid Gemini chat response format: no candidates'); } + // Extract token usage data + $usage = $responseData['usageMetadata'] ?? []; + $tokenUsage = [ + 'input_tokens' => $usage['promptTokenCount'] ?? 0, + 'output_tokens' => $usage['candidatesTokenCount'] ?? 0, + 'total_tokens' => $usage['totalTokenCount'] ?? 0, + ]; + // Check for function calls if (isset($candidate['content']['parts'])) { $functionCalls = []; @@ -137,12 +169,16 @@ public function generateResponse(array $messages, ?string $model = null, ?string if (!empty($functionCalls)) { return [ 'content' => $textContent, - 'tool_calls' => $functionCalls + 'tool_calls' => $functionCalls, + 'token_usage' => $tokenUsage ]; } if (!empty($textContent)) { - return $textContent; + return [ + 'content' => $textContent, + 'token_usage' => $tokenUsage + ]; } } @@ -175,7 +211,21 @@ public function createEmbedding(string|array $text): array } $responseData = $response->json(); - return $responseData['embedding']['values'] ?? []; + + Log::info('Gemini Embedding API', [ + 'status' => $response->status(), + 'request' => $payload, + 'response' => $responseData + ]); + + return [ + 'embeddings' => [$responseData['embedding']['values'] ?? []], + 'token_usage' => [ + 'input_tokens' => $responseData['usageMetadata']['promptTokenCount'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usageMetadata']['totalTokenCount'] ?? 0, + ] + ]; } catch (\Exception $e) { Log::error('Gemini Embedding Error', ['error' => $e->getMessage()]); throw $e; @@ -226,7 +276,14 @@ public function createBatchEmbeddings(array $texts): array } } - return $embeddings; + return [ + 'embeddings' => $embeddings, + 'token_usage' => [ + 'input_tokens' => $responseData['usageMetadata']['promptTokenCount'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usageMetadata']['totalTokenCount'] ?? 0, + ] + ]; } catch (\Exception $e) { Log::error('Gemini Batch Embedding Error', ['error' => $e->getMessage()]); throw $e; diff --git a/app/Services/OpenAIService.php b/app/Services/OpenAIService.php index 95df5c8..ddedfc3 100644 --- a/app/Services/OpenAIService.php +++ b/app/Services/OpenAIService.php @@ -30,7 +30,31 @@ public function __construct() ->timeout(30); } - public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string + /** + * Get the configured provider name + */ + public function getProvider(): string + { + return 'OpenAI'; + } + + /** + * Get the configured chat model name + */ + public function getModel(): string + { + return $this->model; + } + + /** + * Get the configured embedding model name + */ + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array { $model = $model ?? $this->model; @@ -72,17 +96,29 @@ public function generateResponse(array $messages, ?string $model = null, ?string throw new \Exception('Invalid OpenAI chat response format: no message'); } + // Extract token usage data + $usage = $responseData['usage'] ?? []; + $tokenUsage = [ + 'input_tokens' => $usage['prompt_tokens'] ?? 0, + 'output_tokens' => $usage['completion_tokens'] ?? 0, + 'total_tokens' => $usage['total_tokens'] ?? 0, + ]; + // Check for tool calls if (isset($message['tool_calls']) && !empty($message['tool_calls'])) { return [ 'content' => $message['content'] ?? '', - 'tool_calls' => $message['tool_calls'] + 'tool_calls' => $message['tool_calls'], + 'token_usage' => $tokenUsage ]; } // Return content if available if (isset($message['content'])) { - return $message['content']; + return [ + 'content' => $message['content'], + 'token_usage' => $tokenUsage + ]; } throw new \Exception('Invalid OpenAI chat response format: no content or tool calls'); @@ -97,10 +133,31 @@ public function createEmbedding(string|array $text): array ]); if ($response->successful()) { - return collect($response->json()['data']) + $responseData = $response->json(); + + Log::info('OpenAI Embedding API', [ + 'status' => $response->status(), + 'request' => [ + 'model' => $this->embeddingModel, + 'input' => $text, + ], + 'response' => $responseData + ]); + + $embeddings = collect($responseData['data']) ->sortBy('index') ->pluck('embedding') ->all(); + + // Return embeddings with token usage + return [ + 'embeddings' => $embeddings, + 'token_usage' => [ + 'input_tokens' => $responseData['usage']['prompt_tokens'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usage']['total_tokens'] ?? 0, + ] + ]; } throw new \Exception('Failed to create embedding: ' . $response->body()); diff --git a/app/Services/TokenPricingService.php b/app/Services/TokenPricingService.php new file mode 100644 index 0000000..6003d6a --- /dev/null +++ b/app/Services/TokenPricingService.php @@ -0,0 +1,162 @@ + [ + // GPT-5 Series + 'gpt-5' => ['input' => 20625, 'output' => 165000], + 'gpt-5-mini' => ['input' => 4125, 'output' => 33000], + 'gpt-5-nano' => ['input' => 825, 'output' => 6600], + 'gpt-5-chat-latest' => ['input' => 20625, 'output' => 165000], + + // GPT-4.1 Series + 'gpt-4.1' => ['input' => 33000, 'output' => 132000], + 'gpt-4.1-mini' => ['input' => 6600, 'output' => 26400], + 'gpt-4.1-nano' => ['input' => 1650, 'output' => 6600], + + // GPT-4o Series + 'gpt-4o' => ['input' => 41250, 'output' => 165000], + 'gpt-4o-mini' => ['input' => 825, 'output' => 3300], // Based on current pricing + + // Embeddings + 'text-embedding-3-small' => ['input' => 165, 'output' => 0], + 'text-embedding-3-large' => ['input' => 1072.5, 'output' => 0], + 'text-embedding-ada-002' => ['input' => 825, 'output' => 0], + ], + 'Gemini' => [ + // Gemini 2.5 Series + 'gemini-2.5-pro' => ['input' => 20625, 'output' => 165000], + 'gemini-2.5-flash' => ['input' => 4950, 'output' => 41250], + 'gemini-2.5-flash-lite' => ['input' => 1650, 'output' => 6600], + + // Embeddings + 'text-embedding-004' => ['input' => 2475, 'output' => 0], + ], + ]; + + /** + * Calculate the cost in credits for token usage. + * + * @param string $provider The AI provider (OpenAI, Gemini) + * @param string $model The model name + * @param int $inputTokens Number of input tokens + * @param int $outputTokens Number of output tokens + * @return float Cost in credits + */ + public function calculateCost(string $provider, string $model, int $inputTokens, int $outputTokens): float + { + $pricing = $this->getPricing($provider, $model); + + if (!$pricing) { + // Fallback pricing if model not found + $inputCost = ($inputTokens / 1000000) * 1000; // 1000 credits per 1M tokens + $outputCost = ($outputTokens / 1000000) * 5000; // 5000 credits per 1M tokens + return round($inputCost + $outputCost, 6); + } + + $inputCost = ($inputTokens / 1000000) * $pricing['input']; + $outputCost = ($outputTokens / 1000000) * $pricing['output']; + + return round($inputCost + $outputCost, 6); + } + + /** + * Get pricing for a specific provider and model. + * + * @param string $provider + * @param string $model + * @return array|null + */ + public function getPricing(string $provider, string $model): ?array + { + return self::PRICING[$provider][$model] ?? null; + } + + /** + * Get all available pricing. + * + * @return array + */ + public function getAllPricing(): array + { + return self::PRICING; + } + + /** + * Check if a provider and model combination is supported. + * + * @param string $provider + * @param string $model + * @return bool + */ + public function isSupported(string $provider, string $model): bool + { + return isset(self::PRICING[$provider][$model]); + } + + /** + * Get the input token price for a model. + * + * @param string $provider + * @param string $model + * @return float Credits per 1M tokens + */ + public function getInputPrice(string $provider, string $model): float + { + $pricing = $this->getPricing($provider, $model); + return $pricing ? $pricing['input'] : 1000; // Fallback + } + + /** + * Get the output token price for a model. + * + * @param string $provider + * @param string $model + * @return float Credits per 1M tokens + */ + public function getOutputPrice(string $provider, string $model): float + { + $pricing = $this->getPricing($provider, $model); + return $pricing ? $pricing['output'] : 5000; // Fallback + } + + /** + * Format credits to display with currency. + * + * @param float $credits + * @return string + */ + public function formatCredits(float $credits): string + { + return number_format($credits, 6) . ' credits'; + } + + /** + * Convert credits to USD. + * + * @param float $credits + * @return float + */ + public function creditsToUsd(float $credits): float + { + return round($credits / 16500, 4); + } + + /** + * Convert USD to credits. + * + * @param float $usd + * @return float + */ + public function usdToCredits(float $usd): float + { + return round($usd * 16500, 6); + } +} diff --git a/database/migrations/2025_08_22_063003_create_token_usages_table.php b/database/migrations/2025_08_22_063003_create_token_usages_table.php new file mode 100644 index 0000000..ab364ab --- /dev/null +++ b/database/migrations/2025_08_22_063003_create_token_usages_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->foreignId('bot_id')->constrained()->onDelete('cascade'); + $table->string('provider'); // 'openai' or 'gemini' + $table->string('model'); // e.g., 'gpt-4o', 'gemini-2.5-flash' + $table->integer('input_tokens'); + $table->integer('output_tokens'); + $table->decimal('tokens_per_second', 8, 2)->nullable(); // TPS calculation + $table->decimal('credits', 12, 6); // Cost in credits (1 credit = 1 Rupiah) - higher precision for small costs + $table->timestamps(); + + // Indexes for performance + $table->index(['team_id', 'created_at']); + $table->index(['bot_id', 'created_at']); + $table->index(['provider', 'model']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('token_usages'); + } +}; diff --git a/resources/js/Layouts/Partials/Sidebar.vue b/resources/js/Layouts/Partials/Sidebar.vue index 39e9254..4dc7f9c 100644 --- a/resources/js/Layouts/Partials/Sidebar.vue +++ b/resources/js/Layouts/Partials/Sidebar.vue @@ -170,6 +170,7 @@ import { XMarkIcon, CreditCardIcon, WrenchScrewdriverIcon, + ChartBarIcon, } from '@heroicons/vue/24/outline' import { LinkIcon } from '@heroicons/vue/20/solid' import ApplicationLogo from '@/Components/ApplicationLogo.vue'; @@ -188,7 +189,8 @@ const navigation = [ const bottomNavigation = [ { name: 'Team Settings', href: route('teams.show', page.props.auth.user.current_team), icon: UserGroupIcon, current: route().current('teams.show') }, - { name: 'Billing & Usage', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*'), hide: isSelfHosted }, + { name: 'Billing', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*'), hide: isSelfHosted }, + { name: 'Usage', href: route('usage.index'), icon: ChartBarIcon, current: route().current('usage*'), hide: isSelfHosted }, { name: 'Settings', href: route('profile.show'), icon: Cog6ToothIcon, current: route().current('profile.show') }, ] diff --git a/resources/js/Pages/Usage/Index.vue b/resources/js/Pages/Usage/Index.vue new file mode 100644 index 0000000..06c4cbd --- /dev/null +++ b/resources/js/Pages/Usage/Index.vue @@ -0,0 +1,261 @@ + + + diff --git a/routes/web.php b/routes/web.php index d5f70ef..7e0afe2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\ChannelController; use App\Http\Controllers\KnowledgeController; use App\Http\Controllers\ToolController; +use App\Http\Controllers\UsageController; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -88,6 +89,8 @@ Route::post('/billing/webhook', [BillingController::class, 'handleWebhook']) ->name('billing.webhook') ->withoutMiddleware(['auth:sanctum', 'web', 'verified', 'verify_csrf_token']); + + Route::get('/usage', [UsageController::class, 'index'])->name('usage.index'); }); Route::get('/terms', function () { From 31b2fa27e35a474180753de16f5f3f7219e87cb5 Mon Sep 17 00:00:00 2001 From: Dihak Date: Sat, 23 Aug 2025 07:45:02 +0700 Subject: [PATCH 03/11] feat: add pricing menu --- app/Http/Controllers/PricingController.php | 50 ++++ app/Services/TokenPricingService.php | 56 +++-- resources/js/Layouts/Partials/Sidebar.vue | 2 + resources/js/Pages/Billing/Index.vue | 266 +++++++++++++-------- resources/js/Pages/Pricing/Index.vue | 148 ++++++++++++ resources/js/Pages/Usage/Index.vue | 263 +++++++++++--------- routes/web.php | 2 + 7 files changed, 549 insertions(+), 238 deletions(-) create mode 100644 app/Http/Controllers/PricingController.php create mode 100644 resources/js/Pages/Pricing/Index.vue diff --git a/app/Http/Controllers/PricingController.php b/app/Http/Controllers/PricingController.php new file mode 100644 index 0000000..aae47cd --- /dev/null +++ b/app/Http/Controllers/PricingController.php @@ -0,0 +1,50 @@ +tokenPricingService = $tokenPricingService; + } + + /** + * Display the pricing page with token pricing for all models. + */ + public function index(Request $request) + { + // Get all pricing data + $allPricing = $this->tokenPricingService->getAllPricing(); + + // Transform pricing data to include both USD and credits + $pricingData = []; + + foreach ($allPricing as $provider => $models) { + $pricingData[$provider] = []; + + foreach ($models as $modelName => $pricing) { + $pricingData[$provider][$modelName] = [ + 'input_usd' => $pricing['input'], + 'output_usd' => $pricing['output'], + 'input_credits' => $this->tokenPricingService->usdToCredits($pricing['input']), + 'output_credits' => $this->tokenPricingService->usdToCredits($pricing['output']), + ]; + } + } + + return Inertia::render('Pricing/Index', [ + 'pricing' => $pricingData, + 'exchange_rate' => [ + 'usd_to_credits' => 16500, + 'credits_to_usd' => 1 / 16500, + ], + ]); + } +} diff --git a/app/Services/TokenPricingService.php b/app/Services/TokenPricingService.php index 6003d6a..254b095 100644 --- a/app/Services/TokenPricingService.php +++ b/app/Services/TokenPricingService.php @@ -5,39 +5,38 @@ class TokenPricingService { /** - * Token pricing in credits per 1M tokens. + * Token pricing in USD per 1M tokens. * 1 Credit = 1 Rupiah, 16,500 Credits = 1 USD */ private const PRICING = [ 'OpenAI' => [ // GPT-5 Series - 'gpt-5' => ['input' => 20625, 'output' => 165000], - 'gpt-5-mini' => ['input' => 4125, 'output' => 33000], - 'gpt-5-nano' => ['input' => 825, 'output' => 6600], - 'gpt-5-chat-latest' => ['input' => 20625, 'output' => 165000], + 'gpt-5' => ['input' => 1.25, 'output' => 10.0], + 'gpt-5-mini' => ['input' => 0.25, 'output' => 2.0], + 'gpt-5-nano' => ['input' => 0.05, 'output' => 0.4], // GPT-4.1 Series - 'gpt-4.1' => ['input' => 33000, 'output' => 132000], - 'gpt-4.1-mini' => ['input' => 6600, 'output' => 26400], - 'gpt-4.1-nano' => ['input' => 1650, 'output' => 6600], + 'gpt-4.1' => ['input' => 2.0, 'output' => 8.0], + 'gpt-4.1-mini' => ['input' => 0.4, 'output' => 1.6], + 'gpt-4.1-nano' => ['input' => 0.1, 'output' => 0.4], // GPT-4o Series - 'gpt-4o' => ['input' => 41250, 'output' => 165000], - 'gpt-4o-mini' => ['input' => 825, 'output' => 3300], // Based on current pricing + 'gpt-4o' => ['input' => 2.5, 'output' => 10.0], + 'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.6], // Embeddings - 'text-embedding-3-small' => ['input' => 165, 'output' => 0], - 'text-embedding-3-large' => ['input' => 1072.5, 'output' => 0], - 'text-embedding-ada-002' => ['input' => 825, 'output' => 0], + 'text-embedding-3-small' => ['input' => 0.01, 'output' => 0], + 'text-embedding-3-large' => ['input' => 0.065, 'output' => 0], + 'text-embedding-ada-002' => ['input' => 0.05, 'output' => 0], ], 'Gemini' => [ // Gemini 2.5 Series - 'gemini-2.5-pro' => ['input' => 20625, 'output' => 165000], - 'gemini-2.5-flash' => ['input' => 4950, 'output' => 41250], - 'gemini-2.5-flash-lite' => ['input' => 1650, 'output' => 6600], + 'gemini-2.5-pro' => ['input' => 1.25, 'output' => 10.0], + 'gemini-2.5-flash' => ['input' => 0.3, 'output' => 2.5], + 'gemini-2.5-flash-lite' => ['input' => 0.1, 'output' => 0.4], // Embeddings - 'text-embedding-004' => ['input' => 2475, 'output' => 0], + 'text-embedding-004' => ['input' => 0.15, 'output' => 0], ], ]; @@ -61,10 +60,15 @@ public function calculateCost(string $provider, string $model, int $inputTokens, return round($inputCost + $outputCost, 6); } - $inputCost = ($inputTokens / 1000000) * $pricing['input']; - $outputCost = ($outputTokens / 1000000) * $pricing['output']; + // Convert USD pricing to credits (1 USD = 16,500 credits) + $inputCostUsd = ($inputTokens / 1000000) * $pricing['input']; + $outputCostUsd = ($outputTokens / 1000000) * $pricing['output']; + $totalCostUsd = $inputCostUsd + $outputCostUsd; + + // Convert to credits + $totalCostCredits = $totalCostUsd * 16500; - return round($inputCost + $outputCost, 6); + return round($totalCostCredits, 6); } /** @@ -111,7 +115,11 @@ public function isSupported(string $provider, string $model): bool public function getInputPrice(string $provider, string $model): float { $pricing = $this->getPricing($provider, $model); - return $pricing ? $pricing['input'] : 1000; // Fallback + if (!$pricing) { + return 1000; // Fallback in credits + } + // Convert USD to credits (1 USD = 16,500 credits) + return $pricing['input'] * 16500; } /** @@ -124,7 +132,11 @@ public function getInputPrice(string $provider, string $model): float public function getOutputPrice(string $provider, string $model): float { $pricing = $this->getPricing($provider, $model); - return $pricing ? $pricing['output'] : 5000; // Fallback + if (!$pricing) { + return 5000; // Fallback in credits + } + // Convert USD to credits (1 USD = 16,500 credits) + return $pricing['output'] * 16500; } /** diff --git a/resources/js/Layouts/Partials/Sidebar.vue b/resources/js/Layouts/Partials/Sidebar.vue index 4dc7f9c..ff6aaad 100644 --- a/resources/js/Layouts/Partials/Sidebar.vue +++ b/resources/js/Layouts/Partials/Sidebar.vue @@ -171,6 +171,7 @@ import { CreditCardIcon, WrenchScrewdriverIcon, ChartBarIcon, + CurrencyDollarIcon, } from '@heroicons/vue/24/outline' import { LinkIcon } from '@heroicons/vue/20/solid' import ApplicationLogo from '@/Components/ApplicationLogo.vue'; @@ -191,6 +192,7 @@ const bottomNavigation = [ { name: 'Team Settings', href: route('teams.show', page.props.auth.user.current_team), icon: UserGroupIcon, current: route().current('teams.show') }, { name: 'Billing', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*'), hide: isSelfHosted }, { name: 'Usage', href: route('usage.index'), icon: ChartBarIcon, current: route().current('usage*'), hide: isSelfHosted }, + { name: 'Pricing', href: route('pricing.index'), icon: CurrencyDollarIcon, current: route().current('pricing*') }, { name: 'Settings', href: route('profile.show'), icon: Cog6ToothIcon, current: route().current('profile.show') }, ] diff --git a/resources/js/Pages/Billing/Index.vue b/resources/js/Pages/Billing/Index.vue index 903b86e..419e510 100644 --- a/resources/js/Pages/Billing/Index.vue +++ b/resources/js/Pages/Billing/Index.vue @@ -1,6 +1,6 @@