Skip to content

Commit 46502f2

Browse files
committed
feat: add monthly quota periods
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
1 parent 21c62a6 commit 46502f2

File tree

9 files changed

+267
-57
lines changed

9 files changed

+267
-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'];
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: 95 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,62 @@ public function __construct(
6264
) {
6365
}
6466

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

199257
/**
200-
* @return int
258+
* @return array
201259
*/
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;
260+
public function getQuotaPeriod(): array {
261+
$value = json_decode(
262+
$this->appConfig->getValueString(Application::APP_ID, 'quota_period', json_encode(Application::DEFAULT_QUOTA_CONFIG)),
263+
true
264+
) ?: Application::DEFAULT_QUOTA_CONFIG;
265+
if (is_int($value)) {
266+
return [
267+
'length' => $value,
268+
'unit' => 'day',
269+
];
270+
}
271+
return $value;
204272
}
205273

206274
/**
@@ -593,14 +661,31 @@ public function setLlmExtraParams(string $llmExtraParams): void {
593661
}
594662

595663
/**
596-
* Setter for quotaPeriod; minimum is 1 day
597-
* @param int $quotaPeriod
664+
* Setter for quotaPeriod; minimum is 1 day.
665+
* Days are floating, and months are set dates
666+
* @param array $quotaPeriod
598667
* @return void
668+
* @throws Exception
599669
*/
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));
670+
public function setQuotaPeriod(array $quotaPeriod): void {
671+
if (!isset($quotaPeriod['length']) || !is_int($quotaPeriod['length'])) {
672+
throw new Exception('Invalid quota period length');
673+
}
674+
$quotaPeriod['length'] = max(1, $quotaPeriod['length']);
675+
if (!isset($quotaPeriod['unit']) || !is_string($quotaPeriod['unit'])) {
676+
throw new Exception('Invalid quota period unit');
677+
}
678+
// Checks month period
679+
if ($quotaPeriod['unit'] === 'month') {
680+
if (!isset($quotaPeriod['day']) || !is_int($quotaPeriod['day'])) {
681+
throw new Exception('Invalid quota period day');
682+
}
683+
$quotaPeriod['day'] = max(1, $quotaPeriod['day']);
684+
$quotaPeriod['day'] = min($quotaPeriod['day'], 28);
685+
} elseif ($quotaPeriod['unit'] !== 'day') {
686+
throw new Exception('Invalid quota period unit');
687+
}
688+
$this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod));
604689
}
605690

606691
/**

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)