From 9add2f770b2a4c63bc6aec8011d4e189df734e9d Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Fri, 15 Aug 2025 15:41:18 -0400 Subject: [PATCH 1/2] feat: add monthly quota periods Signed-off-by: Lukas Schaefer --- .eslintrc.cjs | 11 +-- lib/AppInfo/Application.php | 1 + lib/Cron/CleanupQuotaDb.php | 18 ++-- lib/Db/QuotaUsageMapper.php | 14 +-- lib/Service/OpenAiAPIService.php | 16 ++-- lib/Service/OpenAiSettingsService.php | 105 +++++++++++++++++++--- src/components/AdminSettings.vue | 20 ++--- src/components/PersonalSettings.vue | 16 +++- src/components/QuotaPeriodPicker.vue | 123 ++++++++++++++++++++++++++ 9 files changed, 267 insertions(+), 57 deletions(-) create mode 100644 src/components/QuotaPeriodPicker.vue 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..a612a580 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']; 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..91ad91d5 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,62 @@ public function __construct( ) { } + /** + * @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(); + } + + /** + * @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 +255,20 @@ 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; + if (is_int($value)) { + return [ + 'length' => $value, + 'unit' => 'day', + ]; + } + return $value; } /** @@ -593,14 +661,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 @@
- - - +

{{ t('integration_openai', 'Usage quotas per time period') }} @@ -619,6 +609,7 @@ import { loadState } from '@nextcloud/initial-state' import { confirmPassword } from '@nextcloud/password-confirmation' import { generateUrl } from '@nextcloud/router' import debounce from 'debounce' +import QuotaPeriodPicker from './QuotaPeriodPicker.vue' const DEFAULT_MODEL_ITEM = { id: 'Default' } @@ -626,6 +617,7 @@ export default { name: 'AdminSettings', components: { + QuotaPeriodPicker, OpenAiIcon, KeyOutlineIcon, CloseIcon, @@ -871,7 +863,7 @@ export default { max_tokens: parseInt(this.state.max_tokens), llm_extra_params: this.state.llm_extra_params, default_image_size: this.state.default_image_size, - quota_period: parseInt(this.state.quota_period), + quota_period: this.state.quota_period, quotas: this.state.quotas, tts_voices: this.state.tts_voices, default_tts_voice: this.state.default_tts_voice, diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 7f292bc4..d2a0592c 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -109,6 +109,9 @@ + + {{ quotaRangeText }} +
@@ -135,6 +138,7 @@ import { loadState } from '@nextcloud/initial-state' import { confirmPassword } from '@nextcloud/password-confirmation' import { generateUrl } from '@nextcloud/router' import debounce from 'debounce' +import { formatRelativeTime } from '@nextcloud/l10n' export default { name: 'PersonalSettings', @@ -162,10 +166,18 @@ export default { }, computed: { + quotaRangeText() { + return this.quotaInfo?.period?.unit === 'month' + ? t('integration_openai', 'This quota period is from {startDate} to {endDate}.', { + startDate: formatRelativeTime(this.quotaInfo.start * 1000), + endDate: formatRelativeTime(this.quotaInfo.end * 1000), + }) + : n('integration_openai', 'The quota is kept over a floating period of the last %n day.', + 'The quota is kept over a floating period of the last %n days.', this.quotaInfo.period.length) + }, }, - watch: { - }, + watch: {}, mounted() { this.loadQuotaInfo() diff --git a/src/components/QuotaPeriodPicker.vue b/src/components/QuotaPeriodPicker.vue new file mode 100644 index 00000000..e4051fc4 --- /dev/null +++ b/src/components/QuotaPeriodPicker.vue @@ -0,0 +1,123 @@ + + + + + + From 8a892c6366573db243b77ac765732d22ab5abe05 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Tue, 19 Aug 2025 08:13:20 -0400 Subject: [PATCH 2/2] fix quota period config and other feedback Signed-off-by: Lukas Schaefer --- lib/AppInfo/Application.php | 2 +- lib/Service/OpenAiSettingsService.php | 16 ++++++++++++---- src/components/PersonalSettings.vue | 6 +++--- src/components/QuotaPeriodPicker.vue | 6 +++--- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a612a580..9087c24d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -55,7 +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']; + 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/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php index 91ad91d5..55bf7b45 100644 --- a/lib/Service/OpenAiSettingsService.php +++ b/lib/Service/OpenAiSettingsService.php @@ -65,6 +65,8 @@ public function __construct( } /** + * Gets the timestamp of the beginning of the quota period + * * @return int * @throws Exception */ @@ -93,6 +95,9 @@ public function getQuotaStart(): int { } /** + * 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 */ @@ -262,11 +267,14 @@ public function getQuotaPeriod(): array { $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)) { - return [ - 'length' => $value, - 'unit' => 'day', - ]; + $value = ['length' => $value]; + } + foreach (Application::DEFAULT_QUOTA_CONFIG as $key => $defaultValue) { + if (!isset($value[$key])) { + $value[$key] = $defaultValue; + } } return $value; } diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index d2a0592c..6fd4c7e2 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -168,12 +168,12 @@ export default { computed: { quotaRangeText() { return this.quotaInfo?.period?.unit === 'month' - ? t('integration_openai', 'This quota period is from {startDate} to {endDate}.', { + ? t('integration_openai', 'This quota period is from {startDate} to {endDate}', { startDate: formatRelativeTime(this.quotaInfo.start * 1000), endDate: formatRelativeTime(this.quotaInfo.end * 1000), }) - : n('integration_openai', 'The quota is kept over a floating period of the last %n day.', - 'The quota is kept over a floating period of the last %n days.', this.quotaInfo.period.length) + : n('integration_openai', 'The quota is kept over a floating period of the last %n day', + 'The quota is kept over a floating period of the last %n days', this.quotaInfo.period.length) }, }, diff --git a/src/components/QuotaPeriodPicker.vue b/src/components/QuotaPeriodPicker.vue index e4051fc4..fb0691bb 100644 --- a/src/components/QuotaPeriodPicker.vue +++ b/src/components/QuotaPeriodPicker.vue @@ -6,7 +6,7 @@
{{ - t('integration_openai', 'Daily quotas are floating quotas while monthly reset on a certain day of the month.') + t('integration_openai', 'Daily quotas are floating quotas while monthly reset on a certain day of the month') }}
@@ -85,8 +85,8 @@ export default { }, resetText() { return this.floating - ? n('integration_openai', 'Quota will be enforced based on last %n day of usage.', 'Quota will be enforced based on last %n days of usage.', this.value.length) - : n('integration_openai', 'Quota will reset all users every month on day {day}.', 'Quota will reset for all users every %n months on day {day}.', this.value.length, { day: this.value.day }) + ? n('integration_openai', 'Quota will be enforced based on last %n day of usage', 'Quota will be enforced based on last %n days of usage', this.value.length) + : n('integration_openai', 'Quota will reset all users every month on day {day}', 'Quota will reset for all users every %n months on day {day}', this.value.length, { day: this.value.day }) }, },