diff --git a/appinfo/routes.php b/appinfo/routes.php index beed6668..0a8f9a2e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -20,5 +20,6 @@ ['name' => 'quotaRule#addRule', 'url' => '/quota/rule', 'verb' => 'POST'], ['name' => 'quotaRule#updateRule', 'url' => '/quota/rule', 'verb' => 'PUT'], ['name' => 'quotaRule#deleteRule', 'url' => '/quota/rule', 'verb' => 'DELETE'], + ['name' => 'quotaRule#getQuotaUsage', 'url' => '/quota/download-usage', 'verb' => 'GET'], ], ]; diff --git a/lib/Controller/QuotaRuleController.php b/lib/Controller/QuotaRuleController.php index d830c924..004eb163 100644 --- a/lib/Controller/QuotaRuleController.php +++ b/lib/Controller/QuotaRuleController.php @@ -11,7 +11,10 @@ use OCA\OpenAi\Service\QuotaRuleService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TextPlainResponse; use OCP\IRequest; class QuotaRuleController extends Controller { @@ -79,4 +82,35 @@ public function deleteRule(int $id): DataResponse { return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } + /** + * Gets the quota usage between two dates + * @param int $startDate + * @param int $endDate + * @param int $type + * @return Http\StreamResponse|TextPlainResponse + */ + #[NoCSRFRequired] + public function getQuotaUsage(int $startDate, int $endDate, int $type): Response { + try { + $result = $this->quotaRuleService->getQuotaUsage($startDate, $endDate, $type); + $csv = fopen('php://memory', 'w'); + try { + foreach ($result as $row) { + fputcsv($csv, $row); + } + rewind($csv); + + $response = new Http\StreamResponse($csv, Http::STATUS_OK); + $response->setHeaders([ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="quota_usage.csv"' + ]); + return $response; + } catch (Exception $e) { + return new TextPlainResponse('Failed to get quota usage:' . $e->getMessage(), Http::STATUS_INTERNAL_SERVER_ERROR); + } + } catch (Exception $e) { + return new TextPlainResponse('Failed to get quota usage:' . $e->getMessage(), Http::STATUS_NOT_FOUND); + } + } } diff --git a/lib/Cron/CleanupQuotaDb.php b/lib/Cron/CleanupQuotaDb.php index b6a122c4..30f29c39 100644 --- a/lib/Cron/CleanupQuotaDb.php +++ b/lib/Cron/CleanupQuotaDb.php @@ -30,14 +30,15 @@ public function __construct( protected function run($argument) { $this->logger->debug('Run cleanup job for OpenAI quota db'); $quota = $this->openAiSettingsService->getQuotaPeriod(); - $days = $quota['length']; + $quotaDays = $quota['length']; if ($quota['unit'] == 'month') { - $days *= 30; + $quotaDays *= 30; } + $days = $this->openAiSettingsService->getUsageStorageTime(); $this->quotaUsageMapper->cleanupQuotaUsages( // The mimimum period is limited to DEFAULT_QUOTA_PERIOD to not lose // the stored quota usage data below this limit. - max($days, Application::DEFAULT_QUOTA_PERIOD) + max($quotaDays, $days, Application::DEFAULT_QUOTA_PERIOD) ); } diff --git a/lib/Db/QuotaUsageMapper.php b/lib/Db/QuotaUsageMapper.php index 926815c6..7d00aacf 100644 --- a/lib/Db/QuotaUsageMapper.php +++ b/lib/Db/QuotaUsageMapper.php @@ -82,7 +82,7 @@ public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int { $qb = $this->db->getQueryBuilder(); // Get the sum of the units used in the time period - $qb->select($qb->createFunction('SUM(units)')) + $qb->select($qb->func()->sum('units')) ->from($this->getTableName()) ->where( $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT)) @@ -113,7 +113,7 @@ public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $ $qb = $this->db->getQueryBuilder(); // Get the sum of the units used in the time period - $qb->select($qb->createFunction('SUM(units)')) + $qb->select($qb->func()->sum('units')) ->from($this->getTableName()) ->where( $qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT)) @@ -170,7 +170,7 @@ public function getQuotaUsagesOfUser(string $userId, int $type): array { public function getQuotaUnitsOfUser(string $userId, int $type): int { $qb = $this->db->getQueryBuilder(); - $qb->select($qb->createFunction('SUM(units)')) + $qb->select($qb->func()->sum('units')) ->from($this->getTableName()) ->where( $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) @@ -274,4 +274,51 @@ public function cleanupQuotaUsages(int $timePeriod): void { ); $qb->executeStatement(); } + + /** + * Gets quota usage of all users + * @param int $startTime + * @param int $endTime + * @return array + * @throws Exception + * @throws RuntimeException + */ + public function getUsersQuotaUsage(int $startTime, int $endTime, $type): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('user_id') + ->selectAlias($qb->func()->sum('units'), 'usage') + ->from($this->getTableName()) + ->where($qb->expr()->gte('timestamp', $qb->createNamedParameter($startTime, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->lte('timestamp', $qb->createNamedParameter($endTime, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->groupBy('user_id') + ->orderBy('usage', 'DESC'); + + return $qb->executeQuery()->fetchAll(); + } + /** + * Gets quota usage of all pools + * @param int $startTime + * @param int $endTime + * @param int $type + * @return array + * @throws Exception + * @throws RuntimeException + */ + public function getPoolsQuotaUsage(int $startTime, int $endTime, int $type): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('pool') + ->selectAlias($qb->func()->sum('units'), 'usage') + ->from($this->getTableName()) + ->where($qb->expr()->neq('pool', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->gte('timestamp', $qb->createNamedParameter($startTime, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->lte('timestamp', $qb->createNamedParameter($endTime, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->groupBy('type', 'pool') + ->orderBy('usage', 'DESC'); + + return $qb->executeQuery()->fetchAll(); + } } diff --git a/lib/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php index ad2f14a7..1006789b 100644 --- a/lib/Service/OpenAiSettingsService.php +++ b/lib/Service/OpenAiSettingsService.php @@ -37,6 +37,7 @@ class OpenAiSettingsService { 'llm_extra_params' => 'string', 'quota_period' => 'array', 'quotas' => 'array', + 'usage_storage_time' => 'integer', 'translation_provider_enabled' => 'boolean', 'llm_provider_enabled' => 'boolean', 't2i_provider_enabled' => 'boolean', @@ -119,7 +120,9 @@ public function getQuotaEnd(): int { $startDate = new DateTime('2000-01-' . $quotaPeriod['day']); $months = $startDate->diff($periodEnd)->m + $startDate->diff($periodEnd)->y * 12; $remainder = $months % $quotaPeriod['length']; - $periodEnd = $periodEnd->add(new DateInterval('P' . $quotaPeriod['length'] - $remainder . 'M')); + if ($remainder != 0) { + $periodEnd = $periodEnd->add(new DateInterval('P' . $quotaPeriod['length'] - $remainder . 'M')); + } } } return $periodEnd->getTimestamp(); @@ -308,6 +311,10 @@ public function getQuotas(): array { return $quotas; } + public function getUsageStorageTime() : int { + return $this->appConfig->getValueInt(Application::APP_ID, 'usage_storage_time', Application::DEFAULT_QUOTA_PERIOD, lazy: true); + } + /** * @return boolean */ @@ -395,6 +402,7 @@ public function getAdminConfig(): array { // Updated to get quota period 'quotas' => $this->getQuotas(), // Get quotas from the config value and return it + 'usage_storage_time' => $this->getUsageStorageTime(), 'translation_provider_enabled' => $this->getTranslationProviderEnabled(), 'llm_provider_enabled' => $this->getLlmProviderEnabled(), 't2i_provider_enabled' => $this->getT2iProviderEnabled(), @@ -522,6 +530,15 @@ public function setQuotas(array $quotas): void { $cache->clear(Application::QUOTA_RULES_CACHE_PREFIX); } + /** + * @param int $usageStorageTime + * @return void + */ + public function setUsageStorageTime(int $usageStorageTime): void { + $usageStorageTime = max(1, $usageStorageTime); + $this->appConfig->setValueInt(Application::APP_ID, 'usage_storage_time', $usageStorageTime, lazy: true); + } + /** * @param string $apiKey * @return void @@ -831,6 +848,9 @@ public function setAdminConfig(array $adminConfig): void { if (isset($adminConfig['quotas'])) { $this->setQuotas($adminConfig['quotas']); } + if (isset($adminConfig['usage_storage_time'])) { + $this->setUsageStorageTime(intval($adminConfig['usage_storage_time'])); + } if (isset($adminConfig['use_max_completion_tokens_param'])) { $this->setUseMaxCompletionParam($adminConfig['use_max_completion_tokens_param']); } diff --git a/lib/Service/QuotaRuleService.php b/lib/Service/QuotaRuleService.php index 67f6f163..530170f1 100644 --- a/lib/Service/QuotaRuleService.php +++ b/lib/Service/QuotaRuleService.php @@ -11,11 +11,13 @@ use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Db\EntityType; use OCA\OpenAi\Db\QuotaRuleMapper; +use OCA\OpenAi\Db\QuotaUsageMapper; use OCA\OpenAi\Db\QuotaUserMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\ICacheFactory; use OCP\IGroupManager; +use OCP\IL10N; use OCP\IUserManager; use Psr\Log\LoggerInterface; @@ -27,6 +29,8 @@ public function __construct( private IGroupManager $groupManager, private ICacheFactory $cacheFactory, private IUserManager $userManager, + private QuotaUsageMapper $quotaUsageMapper, + private IL10N $l10n, private LoggerInterface $logger, ) { } @@ -178,4 +182,29 @@ private function validateEntities(array $entities) { } } } + public function getQuotaUsage(int $startDate, int $endDate, int $type): array { + $data = [[$this->l10n->t('Name'), $this->l10n->t('Usage')]]; + $users = $this->quotaUsageMapper->getUsersQuotaUsage($startDate, $endDate, $type); + $pools = $this->quotaUsageMapper->getPoolsQuotaUsage($startDate, $endDate, $type); + $usersIdx = 0; + $poolsIdx = 0; + while ($usersIdx < count($users) && $poolsIdx < count($pools)) { + if ($users[$usersIdx]['usage'] > $pools[$poolsIdx]['usage']) { + $data[] = [$users[$usersIdx]['user_id'], $users[$usersIdx]['usage']]; + $usersIdx++; + } else { + $data[] = [$this->l10n->t('Quota pool for rule %d', $pools[$poolsIdx]['pool']), $pools[$poolsIdx]['usage']]; + $poolsIdx++; + } + } + while ($usersIdx < count($users)) { + $data[] = [$users[$usersIdx]['user_id'], $users[$usersIdx]['usage']]; + $usersIdx++; + } + while ($poolsIdx < count($pools)) { + $data[] = [$this->l10n->t('Quota pool for rule %d', $pools[$poolsIdx]['pool']), $pools[$poolsIdx]['usage']]; + $poolsIdx++; + } + return $data; + } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 0655bb50..05c3442e 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -33,6 +33,8 @@ public function getForm(): TemplateResponse { $adminConfig['basic_password'] = $adminConfig['basic_password'] === '' ? '' : 'dummyPassword'; $isAssistantEnabled = $this->appManager->isEnabledForUser('assistant'); $adminConfig['assistant_enabled'] = $isAssistantEnabled; + $adminConfig['quota_start_date'] = $this->openAiSettingsService->getQuotaStart(); + $adminConfig['quota_end_date'] = $this->openAiSettingsService->getQuotaEnd(); $this->initialStateService->provideInitialState('admin-config', $adminConfig); $rules = $this->quotaRuleService->getRules(); $this->initialStateService->provideInitialState('rules', $rules); diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index ae5e455a..26a3be39 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -546,6 +546,30 @@ +
+ +
+
+ + + + + {{ t('integration_openai', 'Download quota usage') }} + +

{{ t('integration_openai', 'Quota Rules') }}

@@ -605,6 +629,7 @@ import NcInputField from '@nextcloud/vue/components/NcInputField' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSelect from '@nextcloud/vue/components/NcSelect' import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' import axios from '@nextcloud/axios' import { showError, showSuccess } from '@nextcloud/dialogs' @@ -634,12 +659,14 @@ export default { NcTextField, NcInputField, NcNoteCard, + NcDateTimePickerNative, QuotaRules, }, data() { + const state = loadState('integration_openai', 'admin-config') return { - state: loadState('integration_openai', 'admin-config'), + state, // to prevent some browsers to fill fields with remembered passwords readonly: true, models: null, @@ -655,10 +682,25 @@ export default { defaultImageSizeParamHint: t('integration_openai', 'Must be in 256x256 format (default is {default})', { default: '1024x1024' }), DEFAULT_MODEL_ITEM, appSettingsAssistantUrl: generateUrl('/settings/apps/integration/assistant'), + quota_usage: { + quota_type: { id: 0, label: '' }, + start_date: new Date(state.quota_start_date * 1000), + end_date: new Date(state.quota_end_date * 1000), + }, } }, computed: { + quotaTypes() { + return (this.quotaInfo ?? []).map((q, idx) => ({ id: idx, label: q.type })) + }, + downloadQuotaUsageUrl() { + return generateUrl('/apps/integration_openai/quota/download-usage?type={type}&startDate={startDate}&endDate={endDate}', { + type: this.quota_usage.quota_type?.id, + startDate: this.quota_usage.start_date / 1000, + endDate: this.quota_usage.end_date / 1000, + }) + }, modelEndpointUrl() { if (this.state.url === '') { return 'https://api.openai.com/v1/models' @@ -821,6 +863,9 @@ export default { return axios.get(url) .then((response) => { this.quotaInfo = response.data + if (this.quotaInfo.length > 0) { + this.quota_usage.quota_type = { id: 0, label: this.quotaInfo[0].type } + } }) .catch((error) => { showError( @@ -871,6 +916,7 @@ export default { quotas: this.state.quotas, tts_voices: this.state.tts_voices, default_tts_voice: this.state.default_tts_voice, + usage_storage_time: this.state.usage_storage_time, } await this.saveOptions(values, false) }, 2000), @@ -947,8 +993,24 @@ export default { } .line { - display: flex; align-items: center; + } + + .line-gap { + gap: 8px; + align-items: normal; + + .download-button { + align-self: flex-end; + } + + > * { + margin-bottom: 20px; + } + } + + .line, .line-gap { + display: flex; margin-top: 12px; .icon { margin-right: 4px;