diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 946de5a8..dc6b2aff 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,18 +5,19 @@ module.exports = { globals: { - appVersion: true + appVersion: true, }, parserOptions: { - requireConfigFile: false + requireConfigFile: false, }, extends: [ - '@nextcloud' + '@nextcloud', ], rules: { 'jsdoc/require-jsdoc': 'off', 'jsdoc/tag-lines': 'off', 'vue/first-attribute-linebreak': 'off', - 'import/extensions': 'off' - } + 'import/extensions': 'off', + 'vue/no-v-model-argument': 'off', + }, } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index c66749bc..9087c24d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -55,6 +55,7 @@ class Application extends App implements IBootstrap { public const MIN_CHUNK_SIZE = 500; public const DEFAULT_MAX_NUM_OF_TOKENS = 1000; public const DEFAULT_QUOTA_PERIOD = 30; + public const DEFAULT_QUOTA_CONFIG = ['length' => self::DEFAULT_QUOTA_PERIOD, 'unit' => 'day', 'day' => 1]; public const DEFAULT_OPENAI_TEXT_GENERATION_TIME = 10; // seconds public const DEFAULT_LOCALAI_TEXT_GENERATION_TIME = 60; // seconds diff --git a/lib/Cron/CleanupQuotaDb.php b/lib/Cron/CleanupQuotaDb.php index e8df892d..b6a122c4 100644 --- a/lib/Cron/CleanupQuotaDb.php +++ b/lib/Cron/CleanupQuotaDb.php @@ -11,9 +11,9 @@ use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Db\QuotaUsageMapper; +use OCA\OpenAi\Service\OpenAiSettingsService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; -use OCP\IAppConfig; use Psr\Log\LoggerInterface; class CleanupQuotaDb extends TimedJob { @@ -21,7 +21,7 @@ public function __construct( ITimeFactory $time, private QuotaUsageMapper $quotaUsageMapper, private LoggerInterface $logger, - private IAppConfig $appConfig, + private OpenAiSettingsService $openAiSettingsService, ) { parent::__construct($time); $this->setInterval(60 * 60 * 24); // Daily @@ -29,17 +29,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']; + if ($quota['unit'] == 'month') { + $days *= 30; + } $this->quotaUsageMapper->cleanupQuotaUsages( // The mimimum period is limited to DEFAULT_QUOTA_PERIOD to not lose // the stored quota usage data below this limit. - max( - intval($this->appConfig->getValueString( - Application::APP_ID, - 'quota_period', - strval(Application::DEFAULT_QUOTA_PERIOD) - )), - Application::DEFAULT_QUOTA_PERIOD - ) + max($days, Application::DEFAULT_QUOTA_PERIOD) ); } diff --git a/lib/Db/QuotaUsageMapper.php b/lib/Db/QuotaUsageMapper.php index 3ec34c8c..7203cf2d 100644 --- a/lib/Db/QuotaUsageMapper.php +++ b/lib/Db/QuotaUsageMapper.php @@ -70,19 +70,16 @@ public function getQuotaUsageOfUser(int $id, string $userId): QuotaUsage { /** * @param int $type Type of the quota - * @param int $timePeriod Time period in days + * @param int $periodStart Start time of quota * @return int * @throws DoesNotExistException * @throws Exception * @throws MultipleObjectsReturnedException * @throws \RuntimeException */ - public function getQuotaUnitsInTimePeriod(int $type, int $timePeriod): int { + public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int { $qb = $this->db->getQueryBuilder(); - // Get a timestamp of the beginning of the time period - $periodStart = (new DateTime())->sub(new DateInterval('P' . $timePeriod . 'D'))->getTimestamp(); - // Get the sum of the units used in the time period $qb->select($qb->createFunction('SUM(units)')) ->from($this->getTableName()) @@ -103,19 +100,16 @@ public function getQuotaUnitsInTimePeriod(int $type, int $timePeriod): int { /** * @param string $userId * @param int $type Type of the quota - * @param int $timePeriod Time period in days + * @param int $periodStart Start time of quota * @return int * @throws DoesNotExistException * @throws Exception * @throws MultipleObjectsReturnedException * @throws \RuntimeException */ - public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $timePeriod): int { + public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $periodStart): int { $qb = $this->db->getQueryBuilder(); - // Get a timestamp of the beginning of the time period - $periodStart = (new DateTime())->sub(new DateInterval('P' . $timePeriod . 'D'))->getTimestamp(); - // Get the sum of the units used in the time period $qb->select($qb->createFunction('SUM(units)')) ->from($this->getTableName()) diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php index 2cc8d2f8..cad206a7 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -247,10 +247,10 @@ public function isQuotaExceeded(?string $userId, int $type): bool { return false; } - $quotaPeriod = $this->openAiSettingsService->getQuotaPeriod(); + $quotaStart = $this->openAiSettingsService->getQuotaStart(); try { - $quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaPeriod); + $quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaStart); } catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) { $this->logger->warning('Could not retrieve quota usage for user: ' . $userId . ' and quota type: ' . $type . '. Error: ' . $e->getMessage()); throw new Exception('Could not retrieve quota usage.', Http::STATUS_INTERNAL_SERVER_ERROR); @@ -322,12 +322,14 @@ public function getUserQuotaInfo(string $userId): array { $quotas = $this->hasOwnOpenAiApiKey($userId) ? Application::DEFAULT_QUOTAS : $this->openAiSettingsService->getQuotas(); // Get quota period $quotaPeriod = $this->openAiSettingsService->getQuotaPeriod(); + $quotaStart = $this->openAiSettingsService->getQuotaStart(); + $quotaEnd = $this->openAiSettingsService->getQuotaEnd(); // Get quota usage for each quota type: $quotaInfo = []; foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) { $quotaInfo[$quotaType]['type'] = $this->translatedQuotaType($quotaType); try { - $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $quotaType, $quotaPeriod); + $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $quotaType, $quotaStart); } catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) { $this->logger->warning('Could not retrieve quota usage for user: ' . $userId . ' and quota type: ' . $quotaType . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]); throw new Exception($this->l10n->t('Unknown error while retrieving quota usage.'), Http::STATUS_INTERNAL_SERVER_ERROR); @@ -339,6 +341,8 @@ public function getUserQuotaInfo(string $userId): array { return [ 'quota_usage' => $quotaInfo, 'period' => $quotaPeriod, + 'start' => $quotaStart, + 'end' => $quotaEnd, ]; } @@ -347,14 +351,14 @@ public function getUserQuotaInfo(string $userId): array { * @throws Exception */ public function getAdminQuotaInfo(): array { - // Get quota period - $quotaPeriod = $this->openAiSettingsService->getQuotaPeriod(); + // Get quota start time + $startTime = $this->openAiSettingsService->getQuotaStart(); // Get quota usage of all users for each quota type: $quotaInfo = []; foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) { $quotaInfo[$quotaType]['type'] = $this->translatedQuotaType($quotaType); try { - $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsInTimePeriod($quotaType, $quotaPeriod); + $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsInTimePeriod($quotaType, $startTime); } catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) { $this->logger->warning('Could not retrieve quota usage for quota type: ' . $quotaType . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]); // We can pass detailed error info to the UI here since the user is an admin in any case: diff --git a/lib/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php index a81c11d6..55bf7b45 100644 --- a/lib/Service/OpenAiSettingsService.php +++ b/lib/Service/OpenAiSettingsService.php @@ -7,6 +7,8 @@ namespace OCA\OpenAi\Service; +use DateInterval; +use DateTime; use Exception; use OCA\OpenAi\AppInfo\Application; use OCP\IAppConfig; @@ -33,7 +35,7 @@ class OpenAiSettingsService { 'max_tokens' => 'integer', 'use_max_completion_tokens_param' => 'boolean', 'llm_extra_params' => 'string', - 'quota_period' => 'integer', + 'quota_period' => 'array', 'quotas' => 'array', 'translation_provider_enabled' => 'boolean', 'llm_provider_enabled' => 'boolean', @@ -62,6 +64,67 @@ public function __construct( ) { } + /** + * Gets the timestamp of the beginning of the quota period + * + * @return int + * @throws Exception + */ + public function getQuotaStart(): int { + $quotaPeriod = $this->getQuotaPeriod(); + $now = new DateTime(); + + if ($quotaPeriod['unit'] === 'day') { + // Get a timestamp of the beginning of the time period + $periodStart = $now->sub(new DateInterval('P' . $quotaPeriod['length'] . 'D')); + } else { + $periodStart = new DateTime(date('Y-m-' . $quotaPeriod['day'])); + // Ensure that this isn't in the future + if ($periodStart > $now) { + $periodStart = $periodStart->sub(new DateInterval('P1M')); + } + if ($quotaPeriod['length'] > 1) { + // Calculate number of months since 2000-01 to ensure the start month is consistent + $startDate = new DateTime('2000-01-' . $quotaPeriod['day']); + $months = $startDate->diff($periodStart)->m + $startDate->diff($periodStart)->y * 12; + $remainder = $months % $quotaPeriod['length']; + $periodStart = $periodStart->sub(new DateInterval('P' . $remainder . 'M')); + } + } + return $periodStart->getTimestamp(); + } + + /** + * Gets the timestamp of the end of the quota period + * if the period is floating, then this will be the current time + * + * @return int + * @throws Exception + */ + public function getQuotaEnd(): int { + $quotaPeriod = $this->getQuotaPeriod(); + $now = new DateTime(); + + if ($quotaPeriod['unit'] === 'day') { + // Get a timestamp of the beginning of the time period + $periodEnd = $now; + } else { + $periodEnd = new DateTime(date('Y-m-' . $quotaPeriod['day'])); + // Ensure that this isn't in the past + if ($periodEnd < $now) { + $periodEnd = $periodEnd->add(new DateInterval('P1M')); + } + if ($quotaPeriod['length'] > 1) { + // Calculate number of months since 2000-01 to ensure the start month is consistent + $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')); + } + } + return $periodEnd->getTimestamp(); + } + public function invalidateModelsCache(): void { $cache = $this->cacheFactory->createDistributed(Application::APP_ID); $cache->clear(Application::MODELS_CACHE_KEY); @@ -197,10 +260,23 @@ public function getLlmExtraParams(): string { } /** - * @return int + * @return array */ - public function getQuotaPeriod(): int { - return intval($this->appConfig->getValueString(Application::APP_ID, 'quota_period', strval(Application::DEFAULT_QUOTA_PERIOD))) ?: Application::DEFAULT_QUOTA_PERIOD; + public function getQuotaPeriod(): array { + $value = json_decode( + $this->appConfig->getValueString(Application::APP_ID, 'quota_period', json_encode(Application::DEFAULT_QUOTA_CONFIG)), + true + ) ?: Application::DEFAULT_QUOTA_CONFIG; + // Migrate from old quota period to new one + if (is_int($value)) { + $value = ['length' => $value]; + } + foreach (Application::DEFAULT_QUOTA_CONFIG as $key => $defaultValue) { + if (!isset($value[$key])) { + $value[$key] = $defaultValue; + } + } + return $value; } /** @@ -593,14 +669,31 @@ public function setLlmExtraParams(string $llmExtraParams): void { } /** - * Setter for quotaPeriod; minimum is 1 day - * @param int $quotaPeriod + * Setter for quotaPeriod; minimum is 1 day. + * Days are floating, and months are set dates + * @param array $quotaPeriod * @return void + * @throws Exception */ - public function setQuotaPeriod(int $quotaPeriod): void { - // Validate input: - $quotaPeriod = max(1, $quotaPeriod); - $this->appConfig->setValueString(Application::APP_ID, 'quota_period', strval($quotaPeriod)); + public function setQuotaPeriod(array $quotaPeriod): void { + if (!isset($quotaPeriod['length']) || !is_int($quotaPeriod['length'])) { + throw new Exception('Invalid quota period length'); + } + $quotaPeriod['length'] = max(1, $quotaPeriod['length']); + if (!isset($quotaPeriod['unit']) || !is_string($quotaPeriod['unit'])) { + throw new Exception('Invalid quota period unit'); + } + // Checks month period + if ($quotaPeriod['unit'] === 'month') { + if (!isset($quotaPeriod['day']) || !is_int($quotaPeriod['day'])) { + throw new Exception('Invalid quota period day'); + } + $quotaPeriod['day'] = max(1, $quotaPeriod['day']); + $quotaPeriod['day'] = min($quotaPeriod['day'], 28); + } elseif ($quotaPeriod['unit'] !== 'day') { + throw new Exception('Invalid quota period unit'); + } + $this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod)); } /** diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index e630f615..be5cfc79 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -500,19 +500,9 @@