Skip to content

Commit 199b0ed

Browse files
authored
Merge pull request #256 from nextcloud/feat/quota-dates
feat: add monthly quota periods
2 parents 454482e + 8a892c6 commit 199b0ed

File tree

9 files changed

+275
-57
lines changed

9 files changed

+275
-57
lines changed

.eslintrc.cjs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@
55

66
module.exports = {
77
globals: {
8-
appVersion: true
8+
appVersion: true,
99
},
1010
parserOptions: {
11-
requireConfigFile: false
11+
requireConfigFile: false,
1212
},
1313
extends: [
14-
'@nextcloud'
14+
'@nextcloud',
1515
],
1616
rules: {
1717
'jsdoc/require-jsdoc': 'off',
1818
'jsdoc/tag-lines': 'off',
1919
'vue/first-attribute-linebreak': 'off',
20-
'import/extensions': 'off'
21-
}
20+
'import/extensions': 'off',
21+
'vue/no-v-model-argument': 'off',
22+
},
2223
}

lib/AppInfo/Application.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class Application extends App implements IBootstrap {
5555
public const MIN_CHUNK_SIZE = 500;
5656
public const DEFAULT_MAX_NUM_OF_TOKENS = 1000;
5757
public const DEFAULT_QUOTA_PERIOD = 30;
58+
public const DEFAULT_QUOTA_CONFIG = ['length' => self::DEFAULT_QUOTA_PERIOD, 'unit' => 'day', 'day' => 1];
5859

5960
public const DEFAULT_OPENAI_TEXT_GENERATION_TIME = 10; // seconds
6061
public const DEFAULT_LOCALAI_TEXT_GENERATION_TIME = 60; // seconds

lib/Cron/CleanupQuotaDb.php

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,35 +11,33 @@
1111

1212
use OCA\OpenAi\AppInfo\Application;
1313
use OCA\OpenAi\Db\QuotaUsageMapper;
14+
use OCA\OpenAi\Service\OpenAiSettingsService;
1415
use OCP\AppFramework\Utility\ITimeFactory;
1516
use OCP\BackgroundJob\TimedJob;
16-
use OCP\IAppConfig;
1717
use Psr\Log\LoggerInterface;
1818

1919
class CleanupQuotaDb extends TimedJob {
2020
public function __construct(
2121
ITimeFactory $time,
2222
private QuotaUsageMapper $quotaUsageMapper,
2323
private LoggerInterface $logger,
24-
private IAppConfig $appConfig,
24+
private OpenAiSettingsService $openAiSettingsService,
2525
) {
2626
parent::__construct($time);
2727
$this->setInterval(60 * 60 * 24); // Daily
2828
}
2929

3030
protected function run($argument) {
3131
$this->logger->debug('Run cleanup job for OpenAI quota db');
32+
$quota = $this->openAiSettingsService->getQuotaPeriod();
33+
$days = $quota['length'];
34+
if ($quota['unit'] == 'month') {
35+
$days *= 30;
36+
}
3237
$this->quotaUsageMapper->cleanupQuotaUsages(
3338
// The mimimum period is limited to DEFAULT_QUOTA_PERIOD to not lose
3439
// the stored quota usage data below this limit.
35-
max(
36-
intval($this->appConfig->getValueString(
37-
Application::APP_ID,
38-
'quota_period',
39-
strval(Application::DEFAULT_QUOTA_PERIOD)
40-
)),
41-
Application::DEFAULT_QUOTA_PERIOD
42-
)
40+
max($days, Application::DEFAULT_QUOTA_PERIOD)
4341
);
4442

4543
}

lib/Db/QuotaUsageMapper.php

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,16 @@ public function getQuotaUsageOfUser(int $id, string $userId): QuotaUsage {
7070

7171
/**
7272
* @param int $type Type of the quota
73-
* @param int $timePeriod Time period in days
73+
* @param int $periodStart Start time of quota
7474
* @return int
7575
* @throws DoesNotExistException
7676
* @throws Exception
7777
* @throws MultipleObjectsReturnedException
7878
* @throws \RuntimeException
7979
*/
80-
public function getQuotaUnitsInTimePeriod(int $type, int $timePeriod): int {
80+
public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int {
8181
$qb = $this->db->getQueryBuilder();
8282

83-
// Get a timestamp of the beginning of the time period
84-
$periodStart = (new DateTime())->sub(new DateInterval('P' . $timePeriod . 'D'))->getTimestamp();
85-
8683
// Get the sum of the units used in the time period
8784
$qb->select($qb->createFunction('SUM(units)'))
8885
->from($this->getTableName())
@@ -103,19 +100,16 @@ public function getQuotaUnitsInTimePeriod(int $type, int $timePeriod): int {
103100
/**
104101
* @param string $userId
105102
* @param int $type Type of the quota
106-
* @param int $timePeriod Time period in days
103+
* @param int $periodStart Start time of quota
107104
* @return int
108105
* @throws DoesNotExistException
109106
* @throws Exception
110107
* @throws MultipleObjectsReturnedException
111108
* @throws \RuntimeException
112109
*/
113-
public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $timePeriod): int {
110+
public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $periodStart): int {
114111
$qb = $this->db->getQueryBuilder();
115112

116-
// Get a timestamp of the beginning of the time period
117-
$periodStart = (new DateTime())->sub(new DateInterval('P' . $timePeriod . 'D'))->getTimestamp();
118-
119113
// Get the sum of the units used in the time period
120114
$qb->select($qb->createFunction('SUM(units)'))
121115
->from($this->getTableName())

lib/Service/OpenAiAPIService.php

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,10 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
247247
return false;
248248
}
249249

250-
$quotaPeriod = $this->openAiSettingsService->getQuotaPeriod();
250+
$quotaStart = $this->openAiSettingsService->getQuotaStart();
251251

252252
try {
253-
$quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaPeriod);
253+
$quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaStart);
254254
} catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) {
255255
$this->logger->warning('Could not retrieve quota usage for user: ' . $userId . ' and quota type: ' . $type . '. Error: ' . $e->getMessage());
256256
throw new Exception('Could not retrieve quota usage.', Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -322,12 +322,14 @@ public function getUserQuotaInfo(string $userId): array {
322322
$quotas = $this->hasOwnOpenAiApiKey($userId) ? Application::DEFAULT_QUOTAS : $this->openAiSettingsService->getQuotas();
323323
// Get quota period
324324
$quotaPeriod = $this->openAiSettingsService->getQuotaPeriod();
325+
$quotaStart = $this->openAiSettingsService->getQuotaStart();
326+
$quotaEnd = $this->openAiSettingsService->getQuotaEnd();
325327
// Get quota usage for each quota type:
326328
$quotaInfo = [];
327329
foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) {
328330
$quotaInfo[$quotaType]['type'] = $this->translatedQuotaType($quotaType);
329331
try {
330-
$quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $quotaType, $quotaPeriod);
332+
$quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $quotaType, $quotaStart);
331333
} catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) {
332334
$this->logger->warning('Could not retrieve quota usage for user: ' . $userId . ' and quota type: ' . $quotaType . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
333335
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 {
339341
return [
340342
'quota_usage' => $quotaInfo,
341343
'period' => $quotaPeriod,
344+
'start' => $quotaStart,
345+
'end' => $quotaEnd,
342346
];
343347
}
344348

@@ -347,14 +351,14 @@ public function getUserQuotaInfo(string $userId): array {
347351
* @throws Exception
348352
*/
349353
public function getAdminQuotaInfo(): array {
350-
// Get quota period
351-
$quotaPeriod = $this->openAiSettingsService->getQuotaPeriod();
354+
// Get quota start time
355+
$startTime = $this->openAiSettingsService->getQuotaStart();
352356
// Get quota usage of all users for each quota type:
353357
$quotaInfo = [];
354358
foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) {
355359
$quotaInfo[$quotaType]['type'] = $this->translatedQuotaType($quotaType);
356360
try {
357-
$quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsInTimePeriod($quotaType, $quotaPeriod);
361+
$quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsInTimePeriod($quotaType, $startTime);
358362
} catch (DoesNotExistException|MultipleObjectsReturnedException|DBException|RuntimeException $e) {
359363
$this->logger->warning('Could not retrieve quota usage for quota type: ' . $quotaType . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
360364
// We can pass detailed error info to the UI here since the user is an admin in any case:

lib/Service/OpenAiSettingsService.php

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace OCA\OpenAi\Service;
99

10+
use DateInterval;
11+
use DateTime;
1012
use Exception;
1113
use OCA\OpenAi\AppInfo\Application;
1214
use OCP\IAppConfig;
@@ -33,7 +35,7 @@ class OpenAiSettingsService {
3335
'max_tokens' => 'integer',
3436
'use_max_completion_tokens_param' => 'boolean',
3537
'llm_extra_params' => 'string',
36-
'quota_period' => 'integer',
38+
'quota_period' => 'array',
3739
'quotas' => 'array',
3840
'translation_provider_enabled' => 'boolean',
3941
'llm_provider_enabled' => 'boolean',
@@ -62,6 +64,67 @@ public function __construct(
6264
) {
6365
}
6466

67+
/**
68+
* Gets the timestamp of the beginning of the quota period
69+
*
70+
* @return int
71+
* @throws Exception
72+
*/
73+
public function getQuotaStart(): int {
74+
$quotaPeriod = $this->getQuotaPeriod();
75+
$now = new DateTime();
76+
77+
if ($quotaPeriod['unit'] === 'day') {
78+
// Get a timestamp of the beginning of the time period
79+
$periodStart = $now->sub(new DateInterval('P' . $quotaPeriod['length'] . 'D'));
80+
} else {
81+
$periodStart = new DateTime(date('Y-m-' . $quotaPeriod['day']));
82+
// Ensure that this isn't in the future
83+
if ($periodStart > $now) {
84+
$periodStart = $periodStart->sub(new DateInterval('P1M'));
85+
}
86+
if ($quotaPeriod['length'] > 1) {
87+
// Calculate number of months since 2000-01 to ensure the start month is consistent
88+
$startDate = new DateTime('2000-01-' . $quotaPeriod['day']);
89+
$months = $startDate->diff($periodStart)->m + $startDate->diff($periodStart)->y * 12;
90+
$remainder = $months % $quotaPeriod['length'];
91+
$periodStart = $periodStart->sub(new DateInterval('P' . $remainder . 'M'));
92+
}
93+
}
94+
return $periodStart->getTimestamp();
95+
}
96+
97+
/**
98+
* Gets the timestamp of the end of the quota period
99+
* if the period is floating, then this will be the current time
100+
*
101+
* @return int
102+
* @throws Exception
103+
*/
104+
public function getQuotaEnd(): int {
105+
$quotaPeriod = $this->getQuotaPeriod();
106+
$now = new DateTime();
107+
108+
if ($quotaPeriod['unit'] === 'day') {
109+
// Get a timestamp of the beginning of the time period
110+
$periodEnd = $now;
111+
} else {
112+
$periodEnd = new DateTime(date('Y-m-' . $quotaPeriod['day']));
113+
// Ensure that this isn't in the past
114+
if ($periodEnd < $now) {
115+
$periodEnd = $periodEnd->add(new DateInterval('P1M'));
116+
}
117+
if ($quotaPeriod['length'] > 1) {
118+
// Calculate number of months since 2000-01 to ensure the start month is consistent
119+
$startDate = new DateTime('2000-01-' . $quotaPeriod['day']);
120+
$months = $startDate->diff($periodEnd)->m + $startDate->diff($periodEnd)->y * 12;
121+
$remainder = $months % $quotaPeriod['length'];
122+
$periodEnd = $periodEnd->add(new DateInterval('P' . $quotaPeriod['length'] - $remainder . 'M'));
123+
}
124+
}
125+
return $periodEnd->getTimestamp();
126+
}
127+
65128
public function invalidateModelsCache(): void {
66129
$cache = $this->cacheFactory->createDistributed(Application::APP_ID);
67130
$cache->clear(Application::MODELS_CACHE_KEY);
@@ -197,10 +260,23 @@ public function getLlmExtraParams(): string {
197260
}
198261

199262
/**
200-
* @return int
263+
* @return array
201264
*/
202-
public function getQuotaPeriod(): int {
203-
return intval($this->appConfig->getValueString(Application::APP_ID, 'quota_period', strval(Application::DEFAULT_QUOTA_PERIOD))) ?: Application::DEFAULT_QUOTA_PERIOD;
265+
public function getQuotaPeriod(): array {
266+
$value = json_decode(
267+
$this->appConfig->getValueString(Application::APP_ID, 'quota_period', json_encode(Application::DEFAULT_QUOTA_CONFIG)),
268+
true
269+
) ?: Application::DEFAULT_QUOTA_CONFIG;
270+
// Migrate from old quota period to new one
271+
if (is_int($value)) {
272+
$value = ['length' => $value];
273+
}
274+
foreach (Application::DEFAULT_QUOTA_CONFIG as $key => $defaultValue) {
275+
if (!isset($value[$key])) {
276+
$value[$key] = $defaultValue;
277+
}
278+
}
279+
return $value;
204280
}
205281

206282
/**
@@ -593,14 +669,31 @@ public function setLlmExtraParams(string $llmExtraParams): void {
593669
}
594670

595671
/**
596-
* Setter for quotaPeriod; minimum is 1 day
597-
* @param int $quotaPeriod
672+
* Setter for quotaPeriod; minimum is 1 day.
673+
* Days are floating, and months are set dates
674+
* @param array $quotaPeriod
598675
* @return void
676+
* @throws Exception
599677
*/
600-
public function setQuotaPeriod(int $quotaPeriod): void {
601-
// Validate input:
602-
$quotaPeriod = max(1, $quotaPeriod);
603-
$this->appConfig->setValueString(Application::APP_ID, 'quota_period', strval($quotaPeriod));
678+
public function setQuotaPeriod(array $quotaPeriod): void {
679+
if (!isset($quotaPeriod['length']) || !is_int($quotaPeriod['length'])) {
680+
throw new Exception('Invalid quota period length');
681+
}
682+
$quotaPeriod['length'] = max(1, $quotaPeriod['length']);
683+
if (!isset($quotaPeriod['unit']) || !is_string($quotaPeriod['unit'])) {
684+
throw new Exception('Invalid quota period unit');
685+
}
686+
// Checks month period
687+
if ($quotaPeriod['unit'] === 'month') {
688+
if (!isset($quotaPeriod['day']) || !is_int($quotaPeriod['day'])) {
689+
throw new Exception('Invalid quota period day');
690+
}
691+
$quotaPeriod['day'] = max(1, $quotaPeriod['day']);
692+
$quotaPeriod['day'] = min($quotaPeriod['day'], 28);
693+
} elseif ($quotaPeriod['unit'] !== 'day') {
694+
throw new Exception('Invalid quota period unit');
695+
}
696+
$this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod));
604697
}
605698

606699
/**

src/components/AdminSettings.vue

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -500,19 +500,9 @@
500500
</h2>
501501
<div class="line">
502502
<!--Time period in days for the token usage-->
503-
<NcInputField
504-
id="openai-api-quota-period"
505-
v-model="state.quota_period"
506-
class="input"
507-
type="number"
508-
:label="t('integration_openai', 'Quota enforcement time period (days)')"
509-
:show-trailing-button="!!state.quota_period"
510-
@update:model-value="onInput()"
511-
@trailing-button-click="state.quota_period = '' ; onInput()">
512-
<template #trailing-button-icon>
513-
<CloseIcon :size="20" />
514-
</template>
515-
</NcInputField>
503+
<QuotaPeriodPicker
504+
v-model:value="state.quota_period"
505+
@update:value="onInput()" />
516506
</div>
517507
<h2>
518508
{{ t('integration_openai', 'Usage quotas per time period') }}
@@ -619,13 +609,15 @@ import { loadState } from '@nextcloud/initial-state'
619609
import { confirmPassword } from '@nextcloud/password-confirmation'
620610
import { generateUrl } from '@nextcloud/router'
621611
import debounce from 'debounce'
612+
import QuotaPeriodPicker from './QuotaPeriodPicker.vue'
622613
623614
const DEFAULT_MODEL_ITEM = { id: 'Default' }
624615
625616
export default {
626617
name: 'AdminSettings',
627618
628619
components: {
620+
QuotaPeriodPicker,
629621
OpenAiIcon,
630622
KeyOutlineIcon,
631623
CloseIcon,
@@ -871,7 +863,7 @@ export default {
871863
max_tokens: parseInt(this.state.max_tokens),
872864
llm_extra_params: this.state.llm_extra_params,
873865
default_image_size: this.state.default_image_size,
874-
quota_period: parseInt(this.state.quota_period),
866+
quota_period: this.state.quota_period,
875867
quotas: this.state.quotas,
876868
tts_voices: this.state.tts_voices,
877869
default_tts_voice: this.state.default_tts_voice,

0 commit comments

Comments
 (0)