diff --git a/CHANGELOG.md b/CHANGELOG.md
index 230a7d97..5c581cb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## [6.0.0] - unreleased
+
+### Breaking changes
+
+- Providers are now registered per selected model instead of once per task type, so their IDs and names changed. Existing per-task-type provider preferences in the AI admin settings have to be set again.
+- The single service configuration was replaced by a list of connected services. The existing configuration, including the per-modality URL overrides, is migrated to services automatically.
+- The optional `model` input was removed from the providers: a provider always uses the model it was registered for.
+- The deprecated `ITranslationProvider` implementation was removed. The TextToTextTranslate providers cover translation.
+
+### Added
+
+- Connect any number of OpenAI-compatible services, each with its own URL, credentials, request behaviour and quotas
+- Select per service and per modality which models are exposed, including models the service does not list
+- Users can provide their own credentials for each connected service, which lifts that service's quotas
+
+### Changed
+
+- Quota amounts, quota rules and usage are tracked per service. Existing quota rules applied to every service, so each of them is copied to every connected service on upgrade
+- The measured processing time behind the expected runtime of a provider is now recorded per service
+- Model lists are fetched when the admin asks for them instead of being cached, so the daily model refresh job is gone
+
## [5.0.0] - 2026-07-27
### Breaking changes
diff --git a/README.md b/README.md
index ecf9ac6c..94ce2386 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,18 @@
:warning: The smart pickers have been removed from this app
as they are now included in the [Assistant app](https://apps.nextcloud.com/apps/assistant).
-This app implements:
+This app lets you connect any number of OpenAI-compatible services and pick, per service, which of their
+models you want to expose. Every selected model is registered as its own set of providers, named after the
+model, so you can offer several models side by side and choose between them in the AI admin settings.
-* Text generation providers: Free prompt, Summarize, Headline, Context Write, Chat, and Reformulate (using any available large language model)
-* A Translation provider (using any available language model)
-* A SpeechToText provider (using Whisper)
-* An image generation provider
+Per modality, the following providers are registered for each selected model:
+
+* Text models: Free prompt, Chat, Chat with tools, Summarize, Headline, Topics, Context Write, Reformulate,
+ Improve, Emoji, Change tone, Proofread, Reformat paragraphs and Translate (plus OCR, image analysis and
+ audio chat when the service accepts the matching attachments)
+* Image models: image generation, also with an LLM-improved prompt
+* Transcription models: transcription, subtitles and transcription with paragraph reformatting
+* Speech models: text to speech
:warning: Context Write, Summarize, Headline and Reformulate have mainly been tested with OpenAI.
They might work when connecting to other services, without any guarantee.
@@ -107,12 +113,13 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
### Admin settings
There is an "Artificial intelligence" section in the **admin** settings where you can:
-* Choose whether you use OpenAI, a LocalAI instance or another remote service
-* Set a global API key (or basic auth credentials) for the Nextcloud instance
-* Configure default models and quota settings
+* Connect any number of services: OpenAI, LocalAI instances or any other remote service with an OpenAI-compatible API
+* Set the API key (or basic auth credentials) of each service
+* Select, per service and per modality, which models are exposed as providers
+* Configure the request behaviour, the usage quotas and the quota rules of each service, as well as the instance-wide quota period
### Personal settings
-There is an "Artificial intelligence" section in the **personal** settings where users can set their personal API key or basic auth credentials,
-as well as view their usage quota.
+There is an "Artificial intelligence" section in the **personal** settings where users can set their personal API key or basic auth credentials
+for each connected service, as well as view their usage quota per service. Using your own credentials for a service lifts that service's quotas.
Users can also choose to disable the Nextcloud Assistant even if the Assistant app is installed.
diff --git a/appinfo/info.xml b/appinfo/info.xml
index be98c251..01e59ea8 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -11,12 +11,18 @@
⚠️ The smart pickers have been removed from this app
as they are now included in the [Assistant app](https://apps.nextcloud.com/apps/assistant).
-This app implements:
+This app lets you connect any number of OpenAI-compatible services and pick, per service, which of their
+models you want to expose. Every selected model is registered as its own set of providers, named after the
+model, so you can offer several models side by side and choose between them in the AI admin settings.
-* Text generation providers: Free prompt, Summarize, Headline, Context Write, Chat, and Reformulate (using any available large language model)
-* A Translation provider (using any available language model)
-* A SpeechToText provider (using Whisper)
-* An image generation provider
+Per modality, the following providers are registered for each selected model:
+
+* Text models: Free prompt, Chat, Chat with tools, Summarize, Headline, Topics, Context Write, Reformulate,
+ Improve, Emoji, Change tone, Proofread, Reformat paragraphs and Translate (plus OCR, image analysis and
+ audio chat when the service accepts the matching attachments)
+* Image models: image generation, also with an LLM-improved prompt
+* Transcription models: transcription, subtitles and transcription with paragraph reformatting
+* Speech models: text to speech
⚠️ Context Write, Summarize, Headline and Reformulate have mainly been tested with OpenAI.
They might work when connecting to other services, without any guarantee.
@@ -101,7 +107,7 @@ Negative:
Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/).
]]>
- 5.0.0
+ 6.0.0agplJulien VeyssierOpenAi
@@ -121,7 +127,6 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
OCA\OpenAi\Cron\CleanupQuotaDb
- OCA\OpenAi\Cron\RefreshModelsOCA\OpenAi\Settings\Admin
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 0a8f9a2e..9517559f 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -8,12 +8,18 @@
return [
'routes' => [
['name' => 'config#setUserConfig', 'url' => '/config', 'verb' => 'PUT'],
- ['name' => 'config#setSensitiveUserConfig', 'url' => '/config/sensitive', 'verb' => 'PUT'],
['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'],
- ['name' => 'config#setSensitiveAdminConfig', 'url' => '/admin-config/sensitive', 'verb' => 'PUT'],
- ['name' => 'config#autoDetectFeatures', 'url' => '/admin-config/auto-detect-features', 'verb' => 'POST'],
- ['name' => 'openAiAPI#getModels', 'url' => '/models', 'verb' => 'GET'],
+ ['name' => 'service#index', 'url' => '/services', 'verb' => 'GET'],
+ ['name' => 'service#create', 'url' => '/services', 'verb' => 'POST'],
+ ['name' => 'service#update', 'url' => '/services/{id}', 'verb' => 'PUT'],
+ ['name' => 'service#updateSensitive', 'url' => '/services/{id}/sensitive', 'verb' => 'PUT'],
+ ['name' => 'service#destroy', 'url' => '/services/{id}', 'verb' => 'DELETE'],
+ ['name' => 'service#models', 'url' => '/services/{id}/models', 'verb' => 'GET'],
+ ['name' => 'service#autoDetectModalities', 'url' => '/services/{id}/auto-detect-modalities', 'verb' => 'POST'],
+ ['name' => 'service#userCredentials', 'url' => '/services/user-credentials', 'verb' => 'GET'],
+ ['name' => 'service#setUserCredentials', 'url' => '/services/{id}/user-credentials', 'verb' => 'PUT'],
+
['name' => 'openAiAPI#getUserQuotaInfo', 'url' => '/quota-info', 'verb' => 'GET'],
['name' => 'openAiAPI#getAdminQuotaInfo', 'url' => '/admin-quota-info', 'verb' => 'GET'],
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 6fe0bf36..ff2983ea 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -8,32 +8,13 @@
namespace OCA\OpenAi\AppInfo;
use OCA\OpenAi\Capabilities;
+use OCA\OpenAi\Listener\TaskProcessingProviderListener;
use OCA\OpenAi\Notification\Notifier;
-use OCA\OpenAi\OldProcessing\Translation\TranslationProvider as OldTranslationProvider;
-use OCA\OpenAi\TaskProcessing\AudioToAudioChatProvider;
-use OCA\OpenAi\TaskProcessing\AudioToAudioTranslateProvider;
-use OCA\OpenAi\TaskProcessing\AudioToTextEnhancedProvider;
-use OCA\OpenAi\TaskProcessing\AudioToTextProvider;
-use OCA\OpenAi\TaskProcessing\AudioToTextSubtitlesProvider;
-use OCA\OpenAi\TaskProcessing\ChangeToneProvider;
-use OCA\OpenAi\TaskProcessing\ContextWriteProvider;
-use OCA\OpenAi\TaskProcessing\EmojiProvider;
-use OCA\OpenAi\TaskProcessing\HeadlineProvider;
-use OCA\OpenAi\TaskProcessing\ReformulateProvider;
-use OCA\OpenAi\TaskProcessing\SummaryProvider;
-use OCA\OpenAi\TaskProcessing\TextToImageImprovedPromptProvider;
-use OCA\OpenAi\TaskProcessing\TextToImageProvider;
-use OCA\OpenAi\TaskProcessing\TextToSpeechProvider;
-use OCA\OpenAi\TaskProcessing\TextToTextChatProvider;
-use OCA\OpenAi\TaskProcessing\TextToTextImproveProvider;
-use OCA\OpenAi\TaskProcessing\TextToTextProvider;
-use OCA\OpenAi\TaskProcessing\TopicsProvider;
-use OCA\OpenAi\TaskProcessing\TranslateProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
-use OCP\IAppConfig;
+use OCP\TaskProcessing\Events\GetTaskProcessingProvidersEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'integration_openai';
@@ -72,6 +53,15 @@ class Application extends App implements IBootstrap {
public const DEFAULT_LOCALAI_IMAGE_GENERATION_TIME = 90; // seconds
public const EXPECTED_RUNTIME_LOWPASS_FACTOR = 0.1;
+ /**
+ * Prefixes of the app config keys holding the measured processing time of
+ * a service. The ID of the service is appended to them, so that a slow
+ * service does not skew the runtime estimate of a fast one.
+ */
+ public const TEXT_PROCESSING_TIME_KEY = 'text_generation_time';
+ public const IMAGE_PROCESSING_TIME_KEY = 'image_generation_time';
+ public const PROCESSING_TIME_KEYS = [self::TEXT_PROCESSING_TIME_KEY, self::IMAGE_PROCESSING_TIME_KEY];
+
public const QUOTA_TYPE_TEXT = 0;
public const QUOTA_TYPE_IMAGE = 1;
public const QUOTA_TYPE_TRANSCRIPTION = 2;
@@ -84,96 +74,34 @@ class Application extends App implements IBootstrap {
self::QUOTA_TYPE_SPEECH => 0, // 0 = unlimited
];
- public const MODELS_CACHE_KEY = 'models';
public const QUOTA_RULES_CACHE_PREFIX = 'quota_rules';
- public const MODELS_CACHE_TTL = 60 * 30;
public const LANGUAGE_CODES_AND_ENDONYMS = [['en', 'English'], ['zh', '中文'], ['de', 'Deutsch'], ['es', 'Español'], ['ru', 'Русский'], ['ko', '한국어'], ['fr', 'Français'], ['ja', '日本語'], ['pt', 'Português'], ['tr', 'Türkçe'], ['pl', 'Polski'], ['ca', 'Català'], ['nl', 'Nederlands'], ['ar', 'العربية'], ['sv', 'Svenska'], ['it', 'Italiano'], ['id', 'Bahasa Indonesia'], ['hi', 'हिन्दी'], ['fi', 'Suomi'], ['vi', 'Tiếng Việt'], ['he', 'עברית'], ['uk', 'Українська'], ['el', 'Ελληνικά'], ['ms', 'Bahasa Melayu'], ['cs', 'Česky'], ['ro', 'Română'], ['da', 'Dansk'], ['hu', 'Magyar'], ['ta', 'தமிழ்'], ['no', 'Norsk (bokmål / riksmål)'], ['th', 'ไทย / Phasa Thai'], ['ur', 'اردو'], ['hr', 'Hrvatski'], ['bg', 'Български'], ['lt', 'Lietuvių'], ['la', 'Latina'], ['mi', 'Māori'], ['ml', 'മലയാളം'], ['cy', 'Cymraeg'], ['sk', 'Slovenčina'], ['te', 'తెలుగు'], ['fa', 'فارسی'], ['lv', 'Latviešu'], ['bn', 'বাংলা'], ['sr', 'Српски'], ['az', 'Azərbaycanca / آذربايجان'], ['sl', 'Slovenščina'], ['kn', 'ಕನ್ನಡ'], ['et', 'Eesti'], ['mk', 'Македонски'], ['br', 'Brezhoneg'], ['eu', 'Euskara'], ['is', 'Íslenska'], ['hy', 'Հայերեն'], ['ne', 'नेपाली'], ['mn', 'Монгол'], ['bs', 'Bosanski'], ['kk', 'Қазақша'], ['sq', 'Shqip'], ['sw', 'Kiswahili'], ['gl', 'Galego'], ['mr', 'मराठी'], ['pa', 'ਪੰਜਾਬੀ / पंजाबी / پنجابي'], ['si', 'සිංහල'], ['km', 'ភាសាខ្មែរ'], ['sn', 'chiShona'], ['yo', 'Yorùbá'], ['so', 'Soomaaliga'], ['af', 'Afrikaans'], ['oc', 'Occitan'], ['ka', 'ქართული'], ['be', 'Беларуская'], ['tg', 'Тоҷикӣ'], ['sd', 'सिनधि'], ['gu', 'ગુજરાતી'], ['am', 'አማርኛ'], ['yi', 'ייִדיש'], ['lo', 'ລາວ / Pha xa lao'], ['uz', 'Ўзбек'], ['fo', 'Føroyskt'], ['ht', 'Krèyol ayisyen'], ['ps', 'پښتو'], ['tk', 'Туркмен / تركمن'], ['nn', 'Norsk (nynorsk)'], ['mt', 'bil-Malti'], ['sa', 'संस्कृतम्'], ['lb', 'Lëtzebuergesch'], ['my', 'Myanmasa'], ['bo', 'བོད་ཡིག / Bod skad'], ['tl', 'Tagalog'], ['mg', 'Malagasy'], ['as', 'অসমীয়া'], ['tt', 'Tatarça'], ['haw', 'ʻŌlelo Hawaiʻi'], ['ln', 'Lingála'], ['ha', 'هَوُسَ'], ['ba', 'Башҡорт'], ['jw', 'ꦧꦱꦗꦮ'], ['su', 'Basa Sunda'], ['yue', '粤语']];
- public const SERVICE_TYPE_IMAGE = 'image';
- public const SERVICE_TYPE_STT = 'stt';
- public const SERVICE_TYPE_TTS = 'tts';
-
- private IAppConfig $appConfig;
+ /**
+ * The modalities the admin can select models for. Each selected model of a
+ * modality is exposed as one task processing provider per task type of
+ * that modality.
+ */
+ public const MODALITY_TEXT = 'text';
+ public const MODALITY_IMAGE = 'image';
+ public const MODALITY_STT = 'stt';
+ public const MODALITY_TTS = 'tts';
+ /** App config key holding the JSON list of connected services */
+ public const SERVICES_CONFIG_KEY = 'services';
+
+ /** Sent to and accepted from the frontend in place of a stored secret */
+ public const SECRET_PLACEHOLDER = '**********';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
-
- $container = $this->getContainer();
- $this->appConfig = $container->get(IAppConfig::class);
}
public function register(IRegistrationContext $context): void {
- // deprecated APIs
- if ($this->appConfig->getValueString(Application::APP_ID, 'translation_provider_enabled', '1') === '1') {
- $context->registerTranslationProvider(OldTranslationProvider::class);
- }
-
- $translationProviderEnabled = $this->appConfig->getValueString(Application::APP_ID, 'translation_provider_enabled', '1') === '1';
- $sttProviderEnabled = $this->appConfig->getValueString(Application::APP_ID, 'stt_provider_enabled', '1') === '1';
- $ttsProviderEnabled = $this->appConfig->getValueString(Application::APP_ID, 'tts_provider_enabled', '1') === '1';
-
- // Task processing
- if ($translationProviderEnabled) {
- $context->registerTaskProcessingProvider(TranslateProvider::class);
- }
- if ($translationProviderEnabled && $sttProviderEnabled && $ttsProviderEnabled) {
- $context->registerTaskProcessingProvider(AudioToAudioTranslateProvider::class);
- }
- if ($sttProviderEnabled) {
- $context->registerTaskProcessingProvider(AudioToTextProvider::class);
- if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToTextSubtitles')) {
- $context->registerTaskProcessingProvider(AudioToTextSubtitlesProvider::class);
- }
- if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextReformatParagraphs')) {
- $context->registerTaskProcessingProvider(AudioToTextEnhancedProvider::class);
- }
- }
-
- $serviceUrl = $this->appConfig->getValueString(Application::APP_ID, 'url');
- $isUsingOpenAI = $serviceUrl === '' || $serviceUrl === Application::OPENAI_API_BASE_URL;
-
- if ($this->appConfig->getValueString(Application::APP_ID, 'llm_provider_enabled', '1') === '1') {
- $context->registerTaskProcessingProvider(TextToTextProvider::class);
- $context->registerTaskProcessingProvider(TextToTextChatProvider::class);
- $context->registerTaskProcessingProvider(SummaryProvider::class);
- $context->registerTaskProcessingProvider(HeadlineProvider::class);
- $context->registerTaskProcessingProvider(TopicsProvider::class);
- $context->registerTaskProcessingProvider(ContextWriteProvider::class);
- $context->registerTaskProcessingProvider(ReformulateProvider::class);
- $context->registerTaskProcessingProvider(TextToTextImproveProvider::class);
- $context->registerTaskProcessingProvider(EmojiProvider::class);
- $context->registerTaskProcessingProvider(ChangeToneProvider::class);
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\TextToTextChatWithToolsProvider::class);
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\MultimodalChatWithToolsProvider::class);
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ProofreadProvider::class);
- if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextReformatParagraphs')) {
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ReformatParagraphsProvider::class);
- }
- if ($this->appConfig->getValueString(Application::APP_ID, 'multimodal_image_enabled', '1') === '1') {
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ImageToTextOcrProvider::class);
- $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\AnalyzeImagesProvider::class);
- }
- }
- $context->registerTaskProcessingProvider(TextToSpeechProvider::class);
- if ($this->appConfig->getValueString(Application::APP_ID, 't2i_provider_enabled', '1') === '1') {
- $context->registerTaskProcessingProvider(TextToImageProvider::class);
- $context->registerTaskProcessingProvider(TextToImageImprovedPromptProvider::class);
- }
-
- // only register audio chat stuff if we're using OpenAI or stt+llm+tts are enabled
- if (
- $isUsingOpenAI
- || (
- $this->appConfig->getValueString(Application::APP_ID, 'stt_provider_enabled', '1') === '1'
- && $this->appConfig->getValueString(Application::APP_ID, 'llm_provider_enabled', '1') === '1'
- && $this->appConfig->getValueString(Application::APP_ID, 'tts_provider_enabled', '1') === '1'
- )
- ) {
- if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) {
- $context->registerTaskProcessingProvider(AudioToAudioChatProvider::class);
- }
- }
+ // The task processing providers of this app depend on the admin's
+ // service and model selection, so they cannot be registered as
+ // classes. They are built per (service, model, task type) instead.
+ $context->registerEventListener(GetTaskProcessingProvidersEvent::class, TaskProcessingProviderListener::class);
$context->registerCapability(Capabilities::class);
$context->registerNotifierService(Notifier::class);
diff --git a/lib/Capabilities.php b/lib/Capabilities.php
index b9dcab54..a415b61c 100644
--- a/lib/Capabilities.php
+++ b/lib/Capabilities.php
@@ -10,19 +10,19 @@
namespace OCA\OpenAi;
use OCA\OpenAi\AppInfo\Application;
-use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServicesService;
use OCP\Capabilities\IPublicCapability;
class Capabilities implements IPublicCapability {
public function __construct(
- private OpenAiAPIService $openAiAPIService,
+ private ServicesService $servicesService,
) {
}
public function getCapabilities(): array {
return [
Application::APP_ID => [
- 'uses_openai' => $this->openAiAPIService->isUsingOpenAi(),
+ 'uses_openai' => $this->servicesService->hasOpenAiService(),
],
];
}
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 17c6d053..39b304c3 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -8,12 +8,10 @@
namespace OCA\OpenAi\Controller;
use Exception;
-use OCA\OpenAi\Service\OpenAiAPIService;
use OCA\OpenAi\Service\OpenAiSettingsService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
-use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
@@ -22,7 +20,6 @@ public function __construct(
string $appName,
IRequest $request,
private OpenAiSettingsService $openAiSettingsService,
- private OpenAiAPIService $openAiAPIService,
private ?string $userId,
) {
parent::__construct($appName, $request);
@@ -36,26 +33,6 @@ public function __construct(
*/
#[NoAdminRequired]
public function setUserConfig(array $values): DataResponse {
- if (isset($values['api_key']) || isset($values['basic_password']) || isset($values['basic_user'])) {
- return new DataResponse('', Http::STATUS_BAD_REQUEST);
- }
- try {
- $this->openAiSettingsService->setUserConfig($this->userId, $values);
- } catch (Exception $e) {
- return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST);
- }
- return new DataResponse('');
- }
-
- /**
- * Set sensitive config values
- *
- * @param array $values key/value pairs to store in config
- * @return DataResponse
- */
- #[NoAdminRequired]
- #[PasswordConfirmationRequired]
- public function setSensitiveUserConfig(array $values): DataResponse {
try {
$this->openAiSettingsService->setUserConfig($this->userId, $values);
} catch (Exception $e) {
@@ -71,29 +48,6 @@ public function setSensitiveUserConfig(array $values): DataResponse {
* @return DataResponse
*/
public function setAdminConfig(array $values): DataResponse {
- $prefixes = ['', 'image_', 'tts_', 'stt_'];
- foreach ($prefixes as $prefix) {
- if (isset($values[$prefix . 'api_key']) || isset($values[$prefix . 'basic_password']) || isset($values[$prefix . 'basic_user']) || isset($values[$prefix . 'url'])) {
- return new DataResponse('', Http::STATUS_BAD_REQUEST);
- }
- }
- try {
- $this->openAiSettingsService->setAdminConfig($values);
- } catch (Exception $e) {
- return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST);
- }
-
- return new DataResponse('');
- }
-
- /**
- * Set sensitive admin config values
- *
- * @param array $values key/value pairs to store in app config
- * @return DataResponse
- */
- #[PasswordConfirmationRequired]
- public function setSensitiveAdminConfig(array $values): DataResponse {
try {
$this->openAiSettingsService->setAdminConfig($values);
} catch (Exception $e) {
@@ -103,16 +57,4 @@ public function setSensitiveAdminConfig(array $values): DataResponse {
return new DataResponse('');
}
- /**
- * Set admin config values
- * @return DataResponse
- */
- public function autoDetectFeatures(): DataResponse {
- try {
- $config = $this->openAiAPIService->autoDetectFeatures();
- return new DataResponse($config);
- } catch (Exception $e) {
- return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
- }
- }
}
diff --git a/lib/Controller/OpenAiAPIController.php b/lib/Controller/OpenAiAPIController.php
index 731c6444..280ade42 100644
--- a/lib/Controller/OpenAiAPIController.php
+++ b/lib/Controller/OpenAiAPIController.php
@@ -25,20 +25,6 @@ public function __construct(
parent::__construct($appName, $request);
}
- /**
- * @param string|null $serviceType
- * @return DataResponse
- */
- public function getModels(?string $serviceType = null): DataResponse {
- try {
- $response = $this->openAiAPIService->getModels(null, true, $serviceType);
- return new DataResponse($response);
- } catch (Exception $e) {
- $code = $e->getCode() === 0 ? Http::STATUS_BAD_REQUEST : intval($e->getCode());
- return new DataResponse(['error' => $e->getMessage()], $code);
- }
- }
-
/**
* Get quota usage and limits
* @return DataResponse
diff --git a/lib/Controller/QuotaRuleController.php b/lib/Controller/QuotaRuleController.php
index 004eb163..90e2088c 100644
--- a/lib/Controller/QuotaRuleController.php
+++ b/lib/Controller/QuotaRuleController.php
@@ -28,11 +28,12 @@ public function __construct(
/**
* POST /rule Creates a new empty rule returning the value of the rule
+ * @param string|null $serviceId the service the rule applies to
* @return DataResponse
*/
- public function addRule(): DataResponse {
+ public function addRule(?string $serviceId = null): DataResponse {
try {
- $result = $this->quotaRuleService->addRule();
+ $result = $this->quotaRuleService->addRule($serviceId);
return new DataResponse($result);
} catch (Exception $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
@@ -42,7 +43,7 @@ public function addRule(): DataResponse {
/**
* PUT /rule
* @param int $id
- * @param array $rule expects: type, amount, priority, pool, entities[]
+ * @param array $rule expects: type, amount, priority, pool, service_id, entities[]
* @return DataResponse
*/
public function updateRule(int $id, array $rule): DataResponse {
@@ -58,6 +59,9 @@ public function updateRule(int $id, array $rule): DataResponse {
if (!isset($rule['pool']) || !is_bool($rule['pool'])) {
return new DataResponse(['error' => 'Missing or invalid pool value'], Http::STATUS_BAD_REQUEST);
}
+ if (!isset($rule['service_id']) || !is_string($rule['service_id'])) {
+ return new DataResponse(['error' => 'Missing or invalid service'], Http::STATUS_BAD_REQUEST);
+ }
if (!isset($rule['entities']) || !is_array($rule['entities'])) {
return new DataResponse(['error' => 'Missing or invalid entities'], Http::STATUS_BAD_REQUEST);
}
@@ -90,9 +94,9 @@ public function deleteRule(int $id): DataResponse {
* @return Http\StreamResponse|TextPlainResponse
*/
#[NoCSRFRequired]
- public function getQuotaUsage(int $startDate, int $endDate, int $type): Response {
+ public function getQuotaUsage(int $startDate, int $endDate, int $type, ?string $serviceId = null): Response {
try {
- $result = $this->quotaRuleService->getQuotaUsage($startDate, $endDate, $type);
+ $result = $this->quotaRuleService->getQuotaUsage($startDate, $endDate, $type, $serviceId);
$csv = fopen('php://memory', 'w');
try {
foreach ($result as $row) {
diff --git a/lib/Controller/ServiceController.php b/lib/Controller/ServiceController.php
new file mode 100644
index 00000000..c86e82e2
--- /dev/null
+++ b/lib/Controller/ServiceController.php
@@ -0,0 +1,178 @@
+servicesService->getServicesForFrontend());
+ }
+
+ /**
+ * Add a new, empty service. Its properties are set through update().
+ */
+ public function create(): DataResponse {
+ try {
+ $service = $this->servicesService->addService();
+ } catch (Exception $e) {
+ return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+ return new DataResponse($service->jsonSerializeRedacted());
+ }
+
+ /**
+ * Update the given properties of a service
+ *
+ * The URL and the credentials can only be set through
+ * {@see self::updateSensitive()}.
+ *
+ * @param string $id ID of the service
+ * @param array $values properties to change
+ */
+ public function update(string $id, array $values): DataResponse {
+ foreach (self::SENSITIVE_PROPERTIES as $property) {
+ // array_key_exists, so that an explicit null does not slip past
+ if (array_key_exists($property, $values)) {
+ return new DataResponse(['error' => $property . ' can only be set through the sensitive endpoint'], Http::STATUS_BAD_REQUEST);
+ }
+ }
+ return $this->doUpdate($id, $values);
+ }
+
+ /**
+ * Update the URL and the credentials of a service
+ *
+ * Secrets that are sent back unchanged (as the placeholder the frontend
+ * received) are kept.
+ *
+ * @param string $id ID of the service
+ * @param array $values properties to change
+ */
+ #[PasswordConfirmationRequired]
+ public function updateSensitive(string $id, array $values): DataResponse {
+ return $this->doUpdate($id, $values);
+ }
+
+ /**
+ * @param array $values properties to change
+ */
+ private function doUpdate(string $id, array $values): DataResponse {
+ try {
+ $service = $this->servicesService->updateService($id, $values);
+ } catch (Exception $e) {
+ return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+ return new DataResponse($service->jsonSerializeRedacted());
+ }
+
+ /**
+ * Delete a service, which unregisters all providers it exposed
+ *
+ * @param string $id ID of the service
+ */
+ #[PasswordConfirmationRequired]
+ public function destroy(string $id): DataResponse {
+ try {
+ $this->servicesService->deleteService($id);
+ } catch (Exception $e) {
+ return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+ return new DataResponse('');
+ }
+
+ /**
+ * The model list of a service, freshly fetched from it
+ *
+ * @param string $id ID of the service
+ */
+ public function models(string $id): DataResponse {
+ try {
+ $service = $this->servicesService->getServiceOrFail($id);
+ return new DataResponse($this->openAiAPIService->getModels(null, $service));
+ } catch (Exception $e) {
+ $code = $e->getCode() === 0 ? Http::STATUS_BAD_REQUEST : intval($e->getCode());
+ return new DataResponse(['error' => $e->getMessage()], $code);
+ }
+ }
+
+ /**
+ * Detect which modalities a service supports and switch off the others
+ *
+ * @param string $id ID of the service
+ */
+ public function autoDetectModalities(string $id): DataResponse {
+ try {
+ $service = $this->servicesService->getServiceOrFail($id);
+ return new DataResponse($this->openAiAPIService->autoDetectModalities($service));
+ } catch (Exception $e) {
+ return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * The credentials the current user provided for each service, redacted
+ */
+ #[NoAdminRequired]
+ public function userCredentials(): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse('', Http::STATUS_UNAUTHORIZED);
+ }
+ return new DataResponse($this->servicesService->getUserCredentialsForFrontend($this->userId));
+ }
+
+ /**
+ * Store the credentials the current user provides for one service
+ *
+ * @param string $id ID of the service
+ * @param array $values any of api_key, basic_user, basic_password
+ */
+ #[NoAdminRequired]
+ #[PasswordConfirmationRequired]
+ public function setUserCredentials(string $id, array $values): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse('', Http::STATUS_UNAUTHORIZED);
+ }
+ try {
+ $this->servicesService->setUserCredentials($this->userId, $id, $values);
+ } catch (Exception $e) {
+ return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
+ }
+ return new DataResponse('');
+ }
+}
diff --git a/lib/Cron/RefreshModels.php b/lib/Cron/RefreshModels.php
deleted file mode 100644
index a53e8380..00000000
--- a/lib/Cron/RefreshModels.php
+++ /dev/null
@@ -1,36 +0,0 @@
-setInterval(60 * 60 * 24); // Daily
- }
-
- protected function run($argument) {
- $this->logger->debug('Run daily model refresh job');
- $this->openAIAPIService->getModels(null, true);
- $this->openAIAPIService->getModels(null, true, Application::SERVICE_TYPE_TTS);
- $this->openAIAPIService->getModels(null, true, Application::SERVICE_TYPE_STT);
- $this->openAIAPIService->getModels(null, true, Application::SERVICE_TYPE_IMAGE);
-
- }
-}
diff --git a/lib/Db/QuotaRule.php b/lib/Db/QuotaRule.php
index cd9acbed..4f47d691 100644
--- a/lib/Db/QuotaRule.php
+++ b/lib/Db/QuotaRule.php
@@ -22,6 +22,8 @@
* @method void setPriority(int $priority)
* @method int getPool()
* @method void setPool(int $pool)
+ * @method string getServiceId()
+ * @method void setServiceId(string $serviceId)
*/
class QuotaRule extends Entity implements JsonSerializable {
/** @var int */
@@ -32,12 +34,15 @@ class QuotaRule extends Entity implements JsonSerializable {
protected $priority;
/** @var int */
protected $pool;
+ /** @var string */
+ protected $serviceId;
public function __construct() {
$this->addType('type', Types::INTEGER);
$this->addType('amount', Types::INTEGER);
$this->addType('priority', Types::INTEGER);
$this->addType('pool', Types::INTEGER);
+ $this->addType('serviceId', Types::STRING);
}
public function jsonSerialize(): array {
@@ -46,7 +51,8 @@ public function jsonSerialize(): array {
'type' => $this->getType(),
'amount' => $this->getAmount(),
'priority' => $this->getPriority(),
- 'pool' => $this->getPool()
+ 'pool' => $this->getPool(),
+ 'service_id' => $this->getServiceId(),
];
}
}
diff --git a/lib/Db/QuotaRuleMapper.php b/lib/Db/QuotaRuleMapper.php
index d0835af5..38080346 100644
--- a/lib/Db/QuotaRuleMapper.php
+++ b/lib/Db/QuotaRuleMapper.php
@@ -38,15 +38,36 @@ public function getRules(): array {
}
/**
+ * The rules of one service, in the order they are displayed
+ *
+ * @return QuotaRule[]
+ * @throws Exception
+ */
+ public function getRulesOfService(string $serviceId): array {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where(
+ $qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
+ );
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * The rule of a service that applies to the given user, if there is one
+ *
* @param int $quotaType
* @param string $userId
* @param array $groups
+ * @param string $serviceId the service the request is made to
* @return QuotaRule
* @throws DoesNotExistException
* @throws Exception
* @throws MultipleObjectsReturnedException
*/
- public function getRule(int $quotaType, string $userId, array $groups): QuotaRule {
+ public function getRule(int $quotaType, string $userId, array $groups, string $serviceId): QuotaRule {
$qb = $this->db->getQueryBuilder();
$qb->select('r.*')
@@ -54,6 +75,8 @@ public function getRule(int $quotaType, string $userId, array $groups): QuotaRul
->leftJoin('r', 'openai_quota_user', 'u', 'r.id = u.rule_id')
->where(
$qb->expr()->eq('r.type', $qb->createNamedParameter($quotaType, IQueryBuilder::PARAM_INT))
+ )->andWhere(
+ $qb->expr()->eq('r.service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
)->andWhere(
$qb->expr()->orX(
$qb->expr()->andX(
@@ -76,10 +99,11 @@ public function getRule(int $quotaType, string $userId, array $groups): QuotaRul
* @param int $amount
* @param int $priority
* @param int $pool
+ * @param string $serviceId the service the rule applies to
* @return int
* @throws Exception
*/
- public function addRule(int $quotaType, int $amount, int $priority, int $pool): int {
+ public function addRule(int $quotaType, int $amount, int $priority, int $pool, string $serviceId): int {
$qb = $this->db->getQueryBuilder();
$qb->insert($this->getTableName())
@@ -88,7 +112,8 @@ public function addRule(int $quotaType, int $amount, int $priority, int $pool):
'type' => $qb->createNamedParameter($quotaType, IQueryBuilder::PARAM_INT),
'amount' => $qb->createNamedParameter($amount, IQueryBuilder::PARAM_INT),
'priority' => $qb->createNamedParameter($priority, IQueryBuilder::PARAM_INT),
- 'pool' => $qb->createNamedParameter($pool, IQueryBuilder::PARAM_INT)
+ 'pool' => $qb->createNamedParameter($pool, IQueryBuilder::PARAM_INT),
+ 'service_id' => $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR)
]
);
$qb->executeStatement();
@@ -100,10 +125,11 @@ public function addRule(int $quotaType, int $amount, int $priority, int $pool):
* @param int $amount
* @param int $priority
* @param int $pool
+ * @param string $serviceId the service the rule applies to
* @return void
* @throws Exception
*/
- public function updateRule(int $id, int $quotaType, int $amount, int $priority, int $pool): void {
+ public function updateRule(int $id, int $quotaType, int $amount, int $priority, int $pool, string $serviceId): void {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
@@ -111,6 +137,7 @@ public function updateRule(int $id, int $quotaType, int $amount, int $priority,
->set('amount', $qb->createNamedParameter($amount, IQueryBuilder::PARAM_INT))
->set('priority', $qb->createNamedParameter($priority, IQueryBuilder::PARAM_INT))
->set('pool', $qb->createNamedParameter($pool, IQueryBuilder::PARAM_INT))
+ ->set('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
->where(
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
@@ -127,4 +154,17 @@ public function deleteRule(int $id): void {
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
+
+ /**
+ * Delete every rule of a service, used when the service itself is deleted
+ *
+ * @throws Exception
+ */
+ public function deleteRulesOfService(string $serviceId): void {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->delete($this->getTableName())
+ ->where($qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR)));
+ $qb->executeStatement();
+ }
}
diff --git a/lib/Db/QuotaUsage.php b/lib/Db/QuotaUsage.php
index fab01cdc..e1cc7587 100644
--- a/lib/Db/QuotaUsage.php
+++ b/lib/Db/QuotaUsage.php
@@ -24,6 +24,8 @@
* @method void setTimestamp(int $timestamp)
* @method int getPool()
* @method void setPool(int $pool)
+ * @method string getServiceId()
+ * @method void setServiceId(string $serviceId)
*/
class QuotaUsage extends Entity implements JsonSerializable {
/** @var string */
@@ -36,6 +38,8 @@ class QuotaUsage extends Entity implements JsonSerializable {
protected $timestamp;
/** @var int */
protected $pool;
+ /** @var string */
+ protected $serviceId;
public function __construct() {
$this->addType('user_id', Types::STRING);
@@ -43,6 +47,7 @@ public function __construct() {
$this->addType('units', Types::INTEGER);
$this->addType('timestamp', Types::INTEGER);
$this->addType('pool', Types::INTEGER);
+ $this->addType('service_id', Types::STRING);
}
public function jsonSerialize(): array {
@@ -52,7 +57,8 @@ public function jsonSerialize(): array {
'type' => $this->getType(),
'units' => $this->getUnits(),
'timestamp' => $this->getTimestamp(),
- 'pool' => $this->getPool()
+ 'pool' => $this->getPool(),
+ 'service_id' => $this->getServiceId(),
];
}
}
diff --git a/lib/Db/QuotaUsageMapper.php b/lib/Db/QuotaUsageMapper.php
index 7d00aacf..edfa758e 100644
--- a/lib/Db/QuotaUsageMapper.php
+++ b/lib/Db/QuotaUsageMapper.php
@@ -72,13 +72,14 @@ public function getQuotaUsageOfUser(int $id, string $userId): QuotaUsage {
/**
* @param int $type Type of the quota
* @param int $periodStart Start time of quota
+ * @param string|null $serviceId only count the usage of this service
* @return int
* @throws DoesNotExistException
* @throws Exception
* @throws MultipleObjectsReturnedException
* @throws \RuntimeException
*/
- public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int {
+ public function getQuotaUnitsInTimePeriod(int $type, int $periodStart, ?string $serviceId = null): int {
$qb = $this->db->getQueryBuilder();
// Get the sum of the units used in the time period
@@ -90,6 +91,11 @@ public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int {
->andWhere(
$qb->expr()->gt('timestamp', $qb->createNamedParameter($periodStart, IQueryBuilder::PARAM_INT))
);
+ if ($serviceId !== null) {
+ $qb->andWhere(
+ $qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
+ );
+ }
// Execute the query and return the result
$result = (int)$qb->executeQuery()->fetchOne();
@@ -103,13 +109,14 @@ public function getQuotaUnitsInTimePeriod(int $type, int $periodStart): int {
* @param int $type Type of the quota
* @param int $periodStart Start time of quota
* @param int|null $pool
+ * @param string|null $serviceId only count the usage of this service
* @return int
* @throws DoesNotExistException
* @throws Exception
* @throws MultipleObjectsReturnedException
* @throws RuntimeException
*/
- public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $periodStart, ?int $pool = null): int {
+ public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $periodStart, ?int $pool = null, ?string $serviceId = null): int {
$qb = $this->db->getQueryBuilder();
// Get the sum of the units used in the time period
@@ -130,6 +137,11 @@ public function getQuotaUnitsOfUserInTimePeriod(string $userId, int $type, int $
$qb->expr()->eq('pool', $qb->createNamedParameter($pool, IQueryBuilder::PARAM_INT))
);
}
+ if ($serviceId !== null) {
+ $qb->andWhere(
+ $qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
+ );
+ }
// Execute the query and return the result
$result = (int)$qb->executeQuery()->fetchOne();
@@ -190,16 +202,18 @@ public function getQuotaUnitsOfUser(string $userId, int $type): int {
* @param int $type
* @param int $units
* @param int $pool
+ * @param string $serviceId the service the usage happened on
* @return QuotaUsage
* @throws Exception
*/
- public function createQuotaUsage(string $userId, int $type, int $units, int $pool = -1): QuotaUsage {
+ public function createQuotaUsage(string $userId, int $type, int $units, int $pool = -1, string $serviceId = ''): QuotaUsage {
$quotaUsage = new QuotaUsage();
$quotaUsage->setUserId($userId);
$quotaUsage->setType($type);
$quotaUsage->setUnits($units);
$quotaUsage->setPool($pool);
+ $quotaUsage->setServiceId($serviceId);
$quotaUsage->setTimestamp((new DateTime())->getTimestamp());
$insertedQuotaUsage = $this->insert($quotaUsage);
@@ -283,7 +297,7 @@ public function cleanupQuotaUsages(int $timePeriod): void {
* @throws Exception
* @throws RuntimeException
*/
- public function getUsersQuotaUsage(int $startTime, int $endTime, $type): array {
+ public function getUsersQuotaUsage(int $startTime, int $endTime, $type, ?string $serviceId = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id')
@@ -294,6 +308,9 @@ public function getUsersQuotaUsage(int $startTime, int $endTime, $type): array {
->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT)))
->groupBy('user_id')
->orderBy('usage', 'DESC');
+ if ($serviceId !== null) {
+ $qb->andWhere($qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR)));
+ }
return $qb->executeQuery()->fetchAll();
}
@@ -306,7 +323,7 @@ public function getUsersQuotaUsage(int $startTime, int $endTime, $type): array {
* @throws Exception
* @throws RuntimeException
*/
- public function getPoolsQuotaUsage(int $startTime, int $endTime, int $type): array {
+ public function getPoolsQuotaUsage(int $startTime, int $endTime, int $type, ?string $serviceId = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('pool')
@@ -318,6 +335,9 @@ public function getPoolsQuotaUsage(int $startTime, int $endTime, int $type): arr
->andWhere($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT)))
->groupBy('type', 'pool')
->orderBy('usage', 'DESC');
+ if ($serviceId !== null) {
+ $qb->andWhere($qb->expr()->eq('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR)));
+ }
return $qb->executeQuery()->fetchAll();
}
diff --git a/lib/Listener/TaskProcessingProviderListener.php b/lib/Listener/TaskProcessingProviderListener.php
new file mode 100644
index 00000000..4ac773db
--- /dev/null
+++ b/lib/Listener/TaskProcessingProviderListener.php
@@ -0,0 +1,44 @@
+
+ */
+class TaskProcessingProviderListener implements IEventListener {
+ public function __construct(
+ private ProviderFactory $providerFactory,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function handle(Event $event): void {
+ if (!$event instanceof GetTaskProcessingProvidersEvent) {
+ return;
+ }
+ try {
+ foreach ($this->providerFactory->getProviders() as $provider) {
+ $event->addProvider($provider);
+ }
+ } catch (Throwable $e) {
+ $this->logger->error('Could not build the OpenAI/LocalAI task processing providers', ['exception' => $e]);
+ }
+ }
+}
diff --git a/lib/Migration/Version030104Date20241011192830.php b/lib/Migration/Version030104Date20241011192830.php
index 84babe4b..71996e5e 100644
--- a/lib/Migration/Version030104Date20241011192830.php
+++ b/lib/Migration/Version030104Date20241011192830.php
@@ -10,14 +10,15 @@
namespace OCA\OpenAi\Migration;
use Closure;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\AppInfo\Application;
+use OCP\IAppConfig;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version030104Date20241011192830 extends SimpleMigrationStep {
public function __construct(
- private OpenAiSettingsService $openAiSettingsService,
+ private IAppConfig $appConfig,
) {
}
@@ -27,10 +28,12 @@ public function __construct(
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
- $value = $this->openAiSettingsService->getServiceUrl();
+ // this runs before the migration to multiple services, so the URL is
+ // still stored in a single app config value
+ $value = $this->appConfig->getValueString(Application::APP_ID, 'url', lazy: true);
if ($value !== '' && !str_ends_with(rtrim($value, '/ '), '/v1')) {
$newValue = rtrim($value, '/') . '/v1';
- $this->openAiSettingsService->setServiceUrl($newValue);
+ $this->appConfig->setValueString(Application::APP_ID, 'url', $newValue, lazy: true);
}
}
}
diff --git a/lib/Migration/Version030900Date20251006152735.php b/lib/Migration/Version030900Date20251006152735.php
index 44731f36..5e0d4790 100644
--- a/lib/Migration/Version030900Date20251006152735.php
+++ b/lib/Migration/Version030900Date20251006152735.php
@@ -10,28 +10,20 @@
namespace OCA\OpenAi\Migration;
use Closure;
-use OCA\OpenAi\Service\OpenAiAPIService;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version030900Date20251006152735 extends SimpleMigrationStep {
- public function __construct(
- private OpenAIAPIService $openAIAPIService,
- ) {
- }
-
/**
* @param IOutput $output
* @param Closure $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
- // we refresh the model list to make sure they are stored in oc_appconfig
- // so they are available immediately after the app upgrade to populate the task types enum values
- try {
- $this->openAIAPIService->getModels(null, true);
- } catch (\Exception) {
- }
+ // This used to refresh the model list into oc_appconfig, so that the
+ // task types had enum values right after the upgrade. The models a
+ // service exposes are configured explicitly now and the list is only
+ // fetched when the admin asks for it, so there is nothing left to do.
}
}
diff --git a/lib/Migration/Version060000Date20260908120000.php b/lib/Migration/Version060000Date20260908120000.php
new file mode 100644
index 00000000..6637cd27
--- /dev/null
+++ b/lib/Migration/Version060000Date20260908120000.php
@@ -0,0 +1,531 @@
+ [
+ 'text2text' => 'text2text',
+ 'text2text:chat' => 'text2text:chat',
+ 'text2text:chatwithtools' => 'text2text:chatwithtools',
+ 'text2text:multimodal-chatwithtools' => 'text2text:multimodal-chatwithtools',
+ 'text2text:summary' => 'text2text:summary',
+ 'text2text:headline' => 'text2text:headline',
+ 'text2text:topics' => 'text2text:topics',
+ 'text2text:emoji' => 'generateemoji',
+ 'text2text:proofread' => 'text2text:proofread',
+ 'text2text:reformatparagraphs' => 'text2text:reformatparagraphs',
+ 'contextwrite' => 'contextwrite',
+ 'reformulate' => 'text2text:reformulation',
+ 'improve' => 'text2text:improve',
+ 'changetone' => 'text2text:changetone',
+ 'translate' => 'text2text:translate',
+ 'image2text-ocr' => 'image2text:ocr',
+ 'analyze-images' => 'analyze-images',
+ 'audio2audio:chat' => 'audio2audio:chat',
+ ],
+ Application::MODALITY_IMAGE => [
+ 'text2image' => 'text2image',
+ 'text2image-improved-prompt' => 'text2image-improved-prompt',
+ ],
+ Application::MODALITY_STT => [
+ 'audio2text' => 'audio2text',
+ 'audio2text-subtitles' => 'audio2text:subtitles',
+ 'audio2text-enhanced' => 'audio2text-enhanced',
+ 'audio2audio:translate' => 'audio2audio:translate',
+ ],
+ Application::MODALITY_TTS => [
+ 'text2speech' => 'text2speech',
+ ],
+ ];
+
+ public function __construct(
+ private IAppConfig $appConfig,
+ private IConfig $config,
+ private IDBConnection $db,
+ private IJobList $jobList,
+ private ServicesService $servicesService,
+ ) {
+ }
+
+ /**
+ * @param IOutput $output
+ * @param Closure(): ISchemaWrapper $schemaClosure
+ * @param array $options
+ * @return null|ISchemaWrapper
+ */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ if (!$schema->hasTable('openai_quota_usage')) {
+ return null;
+ }
+ $table = $schema->getTable('openai_quota_usage');
+ if ($table->hasColumn('service_id')) {
+ return null;
+ }
+ $table->addColumn('service_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ 'default' => '',
+ ]);
+ $table->addIndex(['service_id'], 'oai_quota_service');
+ return $schema;
+ }
+
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure
+ * @param array $options
+ */
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ // The model lists are fetched on demand now, so the job that used to
+ // warm their cache is gone. This is outside the guard below because it
+ // also has to clean up after an upgrade that stopped halfway.
+ $this->jobList->remove('OCA\\OpenAi\\Cron\\RefreshModels');
+
+ if ($this->appConfig->getValueString(Application::APP_ID, Application::SERVICES_CONFIG_KEY, '', lazy: true) !== '') {
+ // already migrated
+ return;
+ }
+
+ // The service list is the marker this migration is guarded by, so it is
+ // written only once every other step has succeeded. Until then nothing
+ // observable has changed and an aborted upgrade can simply be re-run.
+ $mainService = $this->buildMainService('s1');
+ $services = [$mainService];
+ /** @var array $serviceIdByQuotaType */
+ $serviceIdByQuotaType = [
+ Application::QUOTA_TYPE_TEXT => $mainService->getId(),
+ Application::QUOTA_TYPE_IMAGE => $mainService->getId(),
+ Application::QUOTA_TYPE_TRANSCRIPTION => $mainService->getId(),
+ Application::QUOTA_TYPE_SPEECH => $mainService->getId(),
+ ];
+ /** @var array $serviceByModality */
+ $serviceByModality = [
+ Application::MODALITY_TEXT => $mainService,
+ Application::MODALITY_IMAGE => $mainService,
+ Application::MODALITY_STT => $mainService,
+ Application::MODALITY_TTS => $mainService,
+ ];
+
+ foreach ($this->getOverrides() as $prefix => [$modality, $quotaType]) {
+ $service = $this->buildOverrideService($prefix, $modality, 's' . (count($services) + 1));
+ if ($service === null) {
+ continue;
+ }
+ $services[] = $service;
+ $serviceIdByQuotaType[$quotaType] = $service->getId();
+ $serviceByModality[$modality] = $service;
+ }
+
+ // All three of these are re-runnable: the credential migration moves one
+ // preference at a time, the usage attribution only touches rows that
+ // have no service yet, and the provider preferences are rewritten from
+ // IDs that the new configuration cannot produce again.
+ $this->migrateUserCredentials($mainService->getId());
+ $this->attributeQuotaUsage($serviceIdByQuotaType);
+ $this->migrateProviderPreferences($serviceByModality, $output);
+
+ // The ID generator is brought in sync with the IDs handed out above
+ // before the service list is written, so that an upgrade aborting
+ // between the two cannot hand out an ID that is already in use.
+ $this->appConfig->setValueInt(Application::APP_ID, 'service_id_counter', count($services));
+ $this->servicesService->setServices($services);
+
+ $output->info('Migrated the OpenAI/LocalAI configuration to ' . count($services) . ' service(s)');
+
+ foreach (self::OBSOLETE_CONFIG_KEYS as $key) {
+ $this->appConfig->deleteKey(Application::APP_ID, $key);
+ }
+ }
+
+ /**
+ * The modality overrides of the old configuration, and the quota type of
+ * the usage they accounted for
+ *
+ * @return array
+ */
+ private function getOverrides(): array {
+ return [
+ 'image_' => [Application::MODALITY_IMAGE, Application::QUOTA_TYPE_IMAGE],
+ 'stt_' => [Application::MODALITY_STT, Application::QUOTA_TYPE_TRANSCRIPTION],
+ 'tts_' => [Application::MODALITY_TTS, Application::QUOTA_TYPE_SPEECH],
+ ];
+ }
+
+ /**
+ * The main configuration becomes the first service. It serves every
+ * modality that was enabled and not overridden by its own URL.
+ */
+ private function buildMainService(string $id): ServiceConfig {
+ $values = [
+ 'name' => $this->getString('service_name'),
+ 'url' => $this->getString('url'),
+ 'api_key' => $this->getString('api_key'),
+ 'basic_user' => $this->getString('basic_user'),
+ 'basic_password' => $this->getString('basic_password'),
+ 'use_basic_auth' => $this->getBool('use_basic_auth', false),
+ 'request_timeout' => $this->getInt('request_timeout', Application::OPENAI_DEFAULT_REQUEST_TIMEOUT),
+ 'chat_endpoint_enabled' => $this->getBool('chat_endpoint_enabled', true),
+ 'use_max_completion_tokens_param' => $this->getNullableBool('use_max_completion_tokens_param'),
+ 'llm_extra_params' => $this->getString('llm_extra_params'),
+ 'max_tokens' => $this->getInt('max_tokens', Application::DEFAULT_MAX_NUM_OF_TOKENS),
+ 'chunk_size' => $this->getInt('chunk_size', Application::DEFAULT_CHUNK_SIZE),
+ 'multimodal_image_enabled' => $this->getBool('multimodal_image_enabled', true),
+ // these defaults are the ones of the old configuration, so that a
+ // service keeps accepting what it accepted before the upgrade
+ 'multimodal_audio_enabled' => $this->getBool('multimodal_audio_enabled', true),
+ 'multimodal_video_enabled' => $this->getBool('multimodal_video_enabled', false),
+ 'multimodal_document_enabled' => $this->getBool('multimodal_document_enabled', true),
+ 'tts_voices' => $this->getArray('tts_voices', Application::DEFAULT_SPEECH_VOICES),
+ 'default_tts_voice' => $this->getString('default_speech_voice') ?: Application::DEFAULT_SPEECH_VOICE,
+ 'default_image_size' => $this->getString('default_image_size') ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE,
+ 'image_request_auth' => $this->getNullableBool('image_request_auth'),
+ 'quotas' => $this->getQuotas(),
+ // The models that were configured as defaults are the ones to expose
+ 'text_enabled' => $this->getBool('llm_provider_enabled', true),
+ 'translation_enabled' => $this->getBool('translation_provider_enabled', true),
+ 'text_models' => [$this->getString('default_completion_model_id') ?: Application::DEFAULT_COMPLETION_MODEL_ID],
+ ];
+
+ // a modality with its own URL is migrated to a service of its own below
+ foreach ($this->getOverrides() as $prefix => [$modality, $_]) {
+ $overridden = $this->getString($prefix . 'url') !== '';
+ $values[$modality . '_enabled'] = !$overridden && $this->isModalityEnabledInOldConfig($modality);
+ $values[$modality . '_models'] = $overridden ? [] : [$this->getOldModel($modality)];
+ }
+
+ return ServiceConfig::fromArray($id, $values);
+ }
+
+ /**
+ * A modality that had its own URL becomes a service that serves only that
+ * modality
+ */
+ private function buildOverrideService(string $prefix, string $modality, string $id): ?ServiceConfig {
+ $url = $this->getString($prefix . 'url');
+ if ($url === '') {
+ return null;
+ }
+ $values = [
+ 'name' => $this->getString($prefix . 'service_name'),
+ 'url' => $url,
+ 'api_key' => $this->getString($prefix . 'api_key'),
+ 'basic_user' => $this->getString($prefix . 'basic_user'),
+ 'basic_password' => $this->getString($prefix . 'basic_password'),
+ 'use_basic_auth' => $this->getBool($prefix . 'use_basic_auth', false),
+ 'request_timeout' => $this->getInt($prefix . 'request_timeout', Application::OPENAI_DEFAULT_REQUEST_TIMEOUT),
+ 'tts_voices' => $this->getArray('tts_voices', Application::DEFAULT_SPEECH_VOICES),
+ 'default_tts_voice' => $this->getString('default_speech_voice') ?: Application::DEFAULT_SPEECH_VOICE,
+ 'default_image_size' => $this->getString('default_image_size') ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE,
+ 'image_request_auth' => $this->getNullableBool('image_request_auth'),
+ 'quotas' => $this->getQuotas(),
+ 'text_enabled' => false,
+ 'image_enabled' => false,
+ 'stt_enabled' => false,
+ 'tts_enabled' => false,
+ 'translation_enabled' => $this->getBool('translation_provider_enabled', true),
+ ];
+ $values[$modality . '_enabled'] = $this->isModalityEnabledInOldConfig($modality);
+ $values[$modality . '_models'] = [$this->getOldModel($modality)];
+
+ return ServiceConfig::fromArray($id, $values);
+ }
+
+ private function isModalityEnabledInOldConfig(string $modality): bool {
+ return match ($modality) {
+ Application::MODALITY_IMAGE => $this->getBool('t2i_provider_enabled', true),
+ Application::MODALITY_STT => $this->getBool('stt_provider_enabled', true),
+ Application::MODALITY_TTS => $this->getBool('tts_provider_enabled', true),
+ default => $this->getBool('llm_provider_enabled', true),
+ };
+ }
+
+ /**
+ * The model that was configured as the default for one of the overridable
+ * modalities. The "Default" pseudo model means the service serves one
+ * fixed model, which is what the old configuration fell back to for these.
+ */
+ private function getOldModel(string $modality): string {
+ $key = match ($modality) {
+ Application::MODALITY_IMAGE => 'default_image_model_id',
+ Application::MODALITY_STT => 'default_stt_model_id',
+ Application::MODALITY_TTS => 'default_speech_model_id',
+ };
+ return $this->getString($key) ?: Application::DEFAULT_MODEL_ID;
+ }
+
+ /**
+ * The credentials users provided for the old single service now belong to
+ * the service the main configuration was migrated to
+ */
+ private function migrateUserCredentials(string $serviceId): void {
+ $keys = ['api_key', 'basic_user', 'basic_password'];
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('userid', 'configkey', 'configvalue')
+ ->from('preferences')
+ ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->in('configkey', $qb->createNamedParameter($keys, IQueryBuilder::PARAM_STR_ARRAY)));
+ $result = $qb->executeQuery();
+ while ($row = $result->fetch()) {
+ if (!is_string($row['configvalue']) || $row['configvalue'] === '') {
+ continue;
+ }
+ // the values are encrypted the same way as the new ones, so they can
+ // be moved over as they are
+ $this->config->setUserValue(
+ (string)$row['userid'],
+ Application::APP_ID,
+ 'service_' . $serviceId . '_' . $row['configkey'],
+ $row['configvalue'],
+ );
+ $this->config->deleteUserValue((string)$row['userid'], Application::APP_ID, (string)$row['configkey']);
+ }
+ $result->closeCursor();
+ }
+
+ /**
+ * The providers this app registers are now one per service and model, so
+ * all of their IDs changed. Rewrite the ones the admin picked in the AI
+ * admin settings, so that a task type stays on this app instead of
+ * silently falling back to whichever provider the server finds first.
+ *
+ * Preferences naming a provider of another app are left untouched, and so
+ * is one naming a modality that the old configuration had switched off:
+ * that one did not resolve before the upgrade either.
+ *
+ * @param array $serviceByModality
+ */
+ private function migrateProviderPreferences(array $serviceByModality, IOutput $output): void {
+ try {
+ $stored = $this->appConfig->getValueString('core', self::PROVIDER_PREFERENCES_KEY, '', lazy: true);
+ $preferences = $stored === '' ? null : json_decode($stored, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable) {
+ // A list that cannot be read here cannot be read by the server
+ // either, so it is already falling back to the first available
+ // provider. Not worth failing the upgrade over.
+ return;
+ }
+ if (!is_array($preferences)) {
+ return;
+ }
+
+ /** @var array $newIdByOldId */
+ $newIdByOldId = [];
+ foreach (self::NEW_SLUG_BY_OLD_SLUG as $modality => $slugs) {
+ $service = $serviceByModality[$modality];
+ $model = $service->getFirstModel($modality);
+ if ($model === null) {
+ continue;
+ }
+ $prefix = Application::APP_ID . '-' . $service->getId() . '-' . self::slugifyModel($model) . '-';
+ foreach ($slugs as $oldSlug => $newSlug) {
+ $newIdByOldId[Application::APP_ID . '-' . $oldSlug] = $prefix . $newSlug;
+ }
+ }
+
+ $migrated = 0;
+ foreach ($preferences as $taskTypeId => $providerId) {
+ if (is_string($providerId) && isset($newIdByOldId[$providerId])) {
+ $preferences[$taskTypeId] = $newIdByOldId[$providerId];
+ $migrated++;
+ }
+ }
+ if ($migrated === 0) {
+ return;
+ }
+
+ $this->appConfig->setValueString(
+ 'core',
+ self::PROVIDER_PREFERENCES_KEY,
+ json_encode($preferences, JSON_THROW_ON_ERROR),
+ lazy: true,
+ );
+ $output->info('Migrated ' . $migrated . ' preferred task processing provider(s) to the new provider IDs');
+ }
+
+ /**
+ * Model names can contain characters that don't belong in an ID.
+ *
+ * Kept in step with {@see \OCA\OpenAi\TaskProcessing\ProviderIdentity::slugifyModel()}
+ * rather than calling it, so that this migration keeps producing the IDs
+ * that were current when it ran, whatever the providers do later.
+ */
+ private static function slugifyModel(string $model): string {
+ return preg_replace('/[^A-Za-z0-9._-]/', '_', $model) ?? $model;
+ }
+
+ /**
+ * Existing usage rows have no service. Attribute them to the service the
+ * modality of their quota type was served by, so the quotas of the current
+ * period keep being enforced across the upgrade.
+ *
+ * @param array $serviceIdByQuotaType
+ */
+ private function attributeQuotaUsage(array $serviceIdByQuotaType): void {
+ foreach ($serviceIdByQuotaType as $quotaType => $serviceId) {
+ try {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('openai_quota_usage')
+ ->set('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
+ ->where($qb->expr()->eq('type', $qb->createNamedParameter($quotaType, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->eq('service_id', $qb->createNamedParameter('', IQueryBuilder::PARAM_STR)),
+ $qb->expr()->isNull('service_id'),
+ ));
+ $qb->executeStatement();
+ } catch (Throwable $e) {
+ // usage attribution is not worth failing the upgrade for
+ continue;
+ }
+ }
+ }
+
+ /**
+ * Read a value of the old configuration as a string.
+ *
+ * The old configuration wrote its values with different typed setters
+ * ('chunk_size' as an int, most of the others as strings), and IAppConfig
+ * throws when the getter does not match the type a value was stored with.
+ * The upgrade must not abort over that, so the stored type decides which
+ * getter is used.
+ */
+ private function getString(string $key): string {
+ try {
+ $type = $this->appConfig->getValueType(Application::APP_ID, $key, lazy: true);
+ } catch (AppConfigUnknownKeyException) {
+ return '';
+ }
+ return match ($type) {
+ IAppConfig::VALUE_INT => (string)$this->appConfig->getValueInt(Application::APP_ID, $key, lazy: true),
+ IAppConfig::VALUE_FLOAT => (string)$this->appConfig->getValueFloat(Application::APP_ID, $key, lazy: true),
+ IAppConfig::VALUE_BOOL => $this->appConfig->getValueBool(Application::APP_ID, $key, lazy: true) ? '1' : '0',
+ IAppConfig::VALUE_ARRAY => json_encode(
+ $this->appConfig->getValueArray(Application::APP_ID, $key, lazy: true),
+ JSON_THROW_ON_ERROR,
+ ),
+ default => $this->appConfig->getValueString(Application::APP_ID, $key, '', lazy: true),
+ };
+ }
+
+ private function getInt(string $key, int $default): int {
+ $value = $this->getString($key);
+ return $value === '' ? $default : (int)$value;
+ }
+
+ private function getBool(string $key, bool $default): bool {
+ $value = $this->getString($key);
+ return $value === '' ? $default : $value === '1';
+ }
+
+ private function getNullableBool(string $key): ?bool {
+ $value = $this->getString($key);
+ return $value === '' ? null : $value === '1';
+ }
+
+ /**
+ * @param list $default
+ * @return list
+ */
+ private function getArray(string $key, array $default): array {
+ $value = $this->getString($key);
+ if ($value === '') {
+ return $default;
+ }
+ $decoded = json_decode($value, true);
+ return is_array($decoded) && $decoded !== [] ? array_values($decoded) : $default;
+ }
+
+ /**
+ * @return array
+ */
+ private function getQuotas(): array {
+ $quotas = json_decode($this->getString('quotas'), true);
+ if (!is_array($quotas)) {
+ return Application::DEFAULT_QUOTAS;
+ }
+ $result = Application::DEFAULT_QUOTAS;
+ foreach (array_keys(Application::DEFAULT_QUOTAS) as $type) {
+ if (isset($quotas[$type]) && is_numeric($quotas[$type])) {
+ $result[$type] = max(0, (int)$quotas[$type]);
+ }
+ }
+ return $result;
+ }
+}
diff --git a/lib/Migration/Version060000Date20260914120000.php b/lib/Migration/Version060000Date20260914120000.php
new file mode 100644
index 00000000..a2869303
--- /dev/null
+++ b/lib/Migration/Version060000Date20260914120000.php
@@ -0,0 +1,173 @@
+hasTable('openai_quota_rule')) {
+ return null;
+ }
+ $table = $schema->getTable('openai_quota_rule');
+ if ($table->hasColumn('service_id')) {
+ return null;
+ }
+ $table->addColumn('service_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 64,
+ 'default' => '',
+ ]);
+ $table->addIndex(['service_id'], 'oai_rule_service');
+ return $schema;
+ }
+
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure
+ * @param array $options
+ */
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $ruleIds = $this->getUnassignedRuleIds();
+ if ($ruleIds === []) {
+ return;
+ }
+ $serviceIds = array_map(
+ static fn (ServiceConfig $service) => $service->getId(),
+ $this->servicesService->getServices(),
+ );
+ if ($serviceIds === []) {
+ // Without a service there is nothing a rule could govern. The rules
+ // are left as they are, so that an admin who connects a service
+ // later can assign them to it instead of writing them again.
+ $output->warning('Leaving ' . count($ruleIds) . ' quota rule(s) unassigned: no service is configured');
+ return;
+ }
+
+ $copies = 0;
+ foreach ($ruleIds as $ruleId) {
+ foreach (array_slice($serviceIds, 1) as $serviceId) {
+ $this->copyRule($ruleId, $serviceId);
+ $copies++;
+ }
+ // last, so that an upgrade aborting in between leaves the rule to be
+ // picked up again by a re-run rather than half-migrated
+ $this->assignRule($ruleId, $serviceIds[0]);
+ }
+
+ $output->info('Migrated ' . count($ruleIds) . ' quota rule(s) to ' . count($serviceIds) . ' service(s), creating ' . $copies . ' copies');
+ }
+
+ /**
+ * The rules that still apply to every service
+ *
+ * @return list
+ */
+ private function getUnassignedRuleIds(): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('id')
+ ->from('openai_quota_rule')
+ ->where($qb->expr()->orX(
+ $qb->expr()->eq('service_id', $qb->createNamedParameter('', IQueryBuilder::PARAM_STR)),
+ $qb->expr()->isNull('service_id'),
+ ));
+ $result = $qb->executeQuery();
+ $ids = array_map(static fn ($row) => (int)$row['id'], $result->fetchAll());
+ $result->closeCursor();
+ return $ids;
+ }
+
+ private function assignRule(int $ruleId, string $serviceId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update('openai_quota_rule')
+ ->set('service_id', $qb->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($ruleId, IQueryBuilder::PARAM_INT)));
+ $qb->executeStatement();
+ }
+
+ /**
+ * Copy a rule, with the users and groups it applies to, over to a service
+ */
+ private function copyRule(int $ruleId, string $serviceId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('type', 'amount', 'priority', 'pool')
+ ->from('openai_quota_rule')
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($ruleId, IQueryBuilder::PARAM_INT)));
+ $result = $qb->executeQuery();
+ $rule = $result->fetch();
+ $result->closeCursor();
+ if ($rule === false) {
+ return;
+ }
+
+ $insert = $this->db->getQueryBuilder();
+ $insert->insert('openai_quota_rule')
+ ->values([
+ 'type' => $insert->createNamedParameter((int)$rule['type'], IQueryBuilder::PARAM_INT),
+ 'amount' => $insert->createNamedParameter((int)$rule['amount'], IQueryBuilder::PARAM_INT),
+ 'priority' => $insert->createNamedParameter((int)$rule['priority'], IQueryBuilder::PARAM_INT),
+ 'pool' => $insert->createNamedParameter((int)$rule['pool'], IQueryBuilder::PARAM_INT),
+ 'service_id' => $insert->createNamedParameter($serviceId, IQueryBuilder::PARAM_STR),
+ ]);
+ $insert->executeStatement();
+ $copyId = $insert->getLastInsertId();
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('entity_type', 'entity_id')
+ ->from('openai_quota_user')
+ ->where($qb->expr()->eq('rule_id', $qb->createNamedParameter($ruleId, IQueryBuilder::PARAM_INT)));
+ $result = $qb->executeQuery();
+ $entities = $result->fetchAll();
+ $result->closeCursor();
+
+ foreach ($entities as $entity) {
+ $insert = $this->db->getQueryBuilder();
+ $insert->insert('openai_quota_user')
+ ->values([
+ 'rule_id' => $insert->createNamedParameter($copyId, IQueryBuilder::PARAM_INT),
+ 'entity_type' => $insert->createNamedParameter((int)$entity['entity_type'], IQueryBuilder::PARAM_INT),
+ 'entity_id' => $insert->createNamedParameter((string)$entity['entity_id'], IQueryBuilder::PARAM_STR),
+ ]);
+ $insert->executeStatement();
+ }
+ }
+}
diff --git a/lib/OldProcessing/Translation/TranslationProvider.php b/lib/OldProcessing/Translation/TranslationProvider.php
deleted file mode 100644
index b9d6da0e..00000000
--- a/lib/OldProcessing/Translation/TranslationProvider.php
+++ /dev/null
@@ -1,126 +0,0 @@
-openAiAPIService->getServiceName();
- }
-
- public function getAvailableLanguages(): array {
- $cache = $this->cacheFactory->createDistributed('integration_openai');
- if ($cached = $cache->get('languages')) {
- return array_map(function ($entry) {
- return $entry instanceof LanguageTuple ? $entry : LanguageTuple::fromArray($entry);
- }, $cached);
- }
-
- $languages = TranslateService::getStaticLanguages();
-
- $availableLanguages = [];
- foreach ($languages as $sourceLanguage) {
- foreach ($languages as $targetLanguage) {
- if ($targetLanguage['code'] === $sourceLanguage['code']) {
- continue;
- }
-
- $availableLanguages[] = new LanguageTuple(
- $sourceLanguage['code'],
- $sourceLanguage['name'],
- $targetLanguage['code'],
- $targetLanguage['name']
- );
- }
- }
-
- $cache->set('languages', $availableLanguages, 3600);
- return $availableLanguages;
- }
-
- public function detectLanguage(string $text): ?string {
- $prompt = 'What language is this (answer with the language name only, in English): ' . $text;
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($this->userId, $adminModel, $prompt, null, null, 1, 100);
- $completion = $completion['messages'];
- } else {
- $completion = $this->openAiAPIService->createCompletion($this->userId, $prompt, 1, $adminModel, 100);
- }
- } catch (Exception $e) {
- throw new RuntimeException('Failed to detect language for input', 0, $e);
- }
-
- if (count($completion) > 0) {
- return array_pop($completion);
- }
- return null;
- }
-
- public function translate(?string $fromLanguage, string $toLanguage, string $text): string {
- $cacheKey = ($fromLanguage ?? '') . '/' . $toLanguage . '/' . md5($text);
-
- $cache = $this->cacheFactory->createDistributed('integration_openai');
- if ($cached = $cache->get($cacheKey)) {
- return $cached;
- }
-
- try {
- $coreLanguages = TranslateService::getCoreLanguagesByCode();
-
- $toLanguage = $coreLanguages[$toLanguage];
- if ($fromLanguage !== null) {
- $this->logger->debug('OpenAI translation FROM[' . $fromLanguage . '] TO[' . $toLanguage . ']', ['app' => Application::APP_ID]);
- $fromLanguage = $coreLanguages[$fromLanguage] ?? $fromLanguage;
- $prompt = 'Translate from ' . $fromLanguage . ' to ' . $toLanguage . ': ' . $text;
- } else {
- $this->logger->debug('OpenAI translation TO[' . $toLanguage . ']', ['app' => Application::APP_ID]);
- $prompt = 'Translate to ' . $toLanguage . ': ' . $text;
- }
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
-
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($this->userId, $adminModel, $prompt, null, null, 1, PHP_INT_MAX);
- $completion = $completion['messages'];
- } else {
- $completion = $this->openAiAPIService->createCompletion($this->userId, $prompt, 1, $adminModel, 4000);
- }
-
- if (count($completion) > 0) {
- return array_pop($completion);
- }
-
- } catch (Exception $e) {
- throw new RuntimeException("Failed translate from {$fromLanguage} to {$toLanguage}", 0, $e);
- }
- throw new RuntimeException("Failed translate from {$fromLanguage} to {$toLanguage}");
- }
-}
diff --git a/lib/Service/ChunkService.php b/lib/Service/ChunkService.php
index 33d66ce7..f6d07827 100644
--- a/lib/Service/ChunkService.php
+++ b/lib/Service/ChunkService.php
@@ -11,21 +11,16 @@
* Helper Service to help with chunking
*/
class ChunkService {
- public function __construct(
- private OpenAiSettingsService $openAiSettingsService,
- ) {
- }
-
/**
- * @param string $prompt
+ * @param ServiceConfig $service the service the prompt is sent to
* @param bool $outputChunking If the output is about the same size as the input so output tokens matter. Ex: translate
* @param int|null $maxTokens The maximum number of output tokens if specified by the user
* @return array
*/
- public function chunkSplitPrompt(string $prompt, bool $outputChunking = false, ?int $maxTokens = null): array {
- $chunkSize = $this->openAiSettingsService->getChunkSize();
+ public function chunkSplitPrompt(ServiceConfig $service, string $prompt, bool $outputChunking = false, ?int $maxTokens = null): array {
+ $chunkSize = $service->getChunkSize();
if ($outputChunking) {
- $maxTokens = $maxTokens ?? $this->openAiSettingsService->getMaxTokens();
+ $maxTokens = $maxTokens ?? $service->getMaxTokens();
$chunkSize = min($chunkSize, $maxTokens);
}
diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php
index f8ded2ad..097731c9 100644
--- a/lib/Service/OpenAiAPIService.php
+++ b/lib/Service/OpenAiAPIService.php
@@ -31,10 +31,8 @@
use OCP\Notification\IManager as INotificationManager;
use OCP\TaskProcessing\Exception\ProcessingException;
use OCP\TaskProcessing\Exception\UserFacingProcessingException;
-use OCP\TaskProcessing\ShapeEnumValue;
use Psr\Log\LoggerInterface;
use RuntimeException;
-use Throwable;
use function json_encode;
/**
@@ -42,7 +40,6 @@
*/
class OpenAiAPIService {
private IClient $client;
- private array $modelsMemoryCache = [];
public function __construct(
private LoggerInterface $logger,
@@ -55,6 +52,7 @@ public function __construct(
private OpenAiFileService $openAiFileService,
private INotificationManager $notificationManager,
private QuotaRuleService $quotaRuleService,
+ private ServicesService $servicesService,
IClientService $clientService,
private bool $isCLI,
) {
@@ -67,80 +65,12 @@ public function __construct(
* @param string $userId It can be an empty string
* @param int $type
* @param int $usage
+ * @param ServiceConfig $service the service the usage happened on
* @throws Exception If there is an error creating the quota usage.
*/
- public function createQuotaUsage(string $userId, int $type, int $usage) {
- $rule = $this->quotaRuleService->getRule($type, $userId);
- $this->quotaUsageMapper->createQuotaUsage($userId, $type, $usage, $rule['pool'] ? $rule['id'] : -1);
- }
-
- /**
- * @param ?string $serviceType
- * @return bool
- */
- public function isUsingOpenAi(?string $serviceType = null): bool {
- $serviceUrl = '';
- if ($serviceType === Application::SERVICE_TYPE_IMAGE) {
- $serviceUrl = $this->openAiSettingsService->getImageServiceUrl();
- } elseif ($serviceType === Application::SERVICE_TYPE_STT) {
- $serviceUrl = $this->openAiSettingsService->getSttServiceUrl();
- } elseif ($serviceType === Application::SERVICE_TYPE_TTS) {
- $serviceUrl = $this->openAiSettingsService->getTtsServiceUrl();
- }
- if ($serviceUrl === '') {
- $serviceUrl = $this->openAiSettingsService->getServiceUrl();
- }
- return $serviceUrl === '' || $serviceUrl === Application::OPENAI_API_BASE_URL;
- }
-
- /**
- * @param ?string $serviceType
- * @return bool
- */
- public function isUsingOpenRouter(?string $serviceType = null): bool {
- $serviceUrl = '';
- if ($serviceType === Application::SERVICE_TYPE_IMAGE) {
- $serviceUrl = $this->openAiSettingsService->getImageServiceUrl();
- } elseif ($serviceType === Application::SERVICE_TYPE_STT) {
- $serviceUrl = $this->openAiSettingsService->getSttServiceUrl();
- } elseif ($serviceType === Application::SERVICE_TYPE_TTS) {
- $serviceUrl = $this->openAiSettingsService->getTtsServiceUrl();
- }
- if ($serviceUrl === '') {
- $serviceUrl = $this->openAiSettingsService->getServiceUrl();
- }
- // Return true if the service URL references OpenRouter (e.g., openrouter.ai)
- return str_starts_with(strtolower($serviceUrl), 'https://openrouter.ai');
- }
-
- /**
- * @param ?string $serviceType
- *
- * @return string
- */
- public function getServiceName(?string $serviceType = null): string {
- if ($this->isUsingOpenAi($serviceType)) {
- if ($serviceType === Application::SERVICE_TYPE_IMAGE) {
- return $this->l10n->t('OpenAI\'s Image Generation');
- }
- if ($serviceType === Application::SERVICE_TYPE_TTS) {
- $this->l10n->t('OpenAI\'s Text to Speech');
- }
- return 'OpenAI';
- } else {
- $serviceName = $this->openAiSettingsService->getServiceName();
- if ($serviceType === Application::SERVICE_TYPE_IMAGE && $this->openAiSettingsService->imageOverrideEnabled()) {
- $serviceName = $this->openAiSettingsService->getImageServiceName();
- } elseif ($serviceType === Application::SERVICE_TYPE_STT && $this->openAiSettingsService->sttOverrideEnabled()) {
- $serviceName = $this->openAiSettingsService->getSttServiceName();
- } elseif ($serviceType === Application::SERVICE_TYPE_TTS && $this->openAiSettingsService->ttsOverrideEnabled()) {
- $serviceName = $this->openAiSettingsService->getTtsServiceName();
- }
- if ($serviceName === '') {
- return 'LocalAI';
- }
- return $serviceName;
- }
+ public function createQuotaUsage(string $userId, int $type, int $usage, ServiceConfig $service): void {
+ $rule = $this->quotaRuleService->getRule($type, $userId, $service);
+ $this->quotaUsageMapper->createQuotaUsage($userId, $type, $usage, $rule['pool'] ? $rule['id'] : -1, $service->getId());
}
/**
@@ -164,87 +94,22 @@ private function isModelListValid($models): bool {
}
/**
+ * Get the model list of a service, freshly fetched from it.
+ *
+ * The list is not cached: it only feeds the model picker of the admin
+ * settings, which asks for it when the admin refreshes a service, and a
+ * stale list there would be worse than a slightly slower page.
+ *
* @param ?string $userId
- * @param bool $refresh
- * @param ?string $serviceType
- * @return array|string[]
+ * @param ServiceConfig $service
+ * @return array the model list response, with the models in the 'data' key
* @throws Exception
*/
- public function getModels(?string $userId, bool $refresh = false, ?string $serviceType = null): array {
- // Use default service type if service type is not overridden
- if ($serviceType === Application::SERVICE_TYPE_IMAGE && !$this->openAiSettingsService->imageOverrideEnabled()) {
- $serviceType = null;
- } elseif ($serviceType === Application::SERVICE_TYPE_STT && !$this->openAiSettingsService->sttOverrideEnabled()) {
- $serviceType = null;
- } elseif ($serviceType === Application::SERVICE_TYPE_TTS && !$this->openAiSettingsService->ttsOverrideEnabled()) {
- $serviceType = null;
- }
- $cache = $this->cacheFactory->createDistributed(Application::APP_ID);
- $userCacheKey = Application::MODELS_CACHE_KEY . '_' . ($userId ?? '') . '_' . ($serviceType ?? 'main');
- $adminCacheKey = Application::MODELS_CACHE_KEY . '-main' . '_' . ($serviceType ?? 'main');
- $dbCacheKey = $serviceType ? 'models' . '_' . $serviceType : 'models';
- $memoryCacheKey = $serviceType ?? 'default';
-
- if (!$refresh) {
- if (array_key_exists($memoryCacheKey, $this->modelsMemoryCache)) {
- $this->logger->debug('Getting OpenAI models from the memory cache');
- return $this->modelsMemoryCache[$memoryCacheKey];
- }
-
- // try to get models from the user cache first
- if ($userId !== null) {
- $userCachedModels = $cache->get($userCacheKey);
- if ($userCachedModels) {
- $this->logger->debug('Getting OpenAI models from user cache for user ' . $userId);
- $this->modelsMemoryCache[$memoryCacheKey] = $userCachedModels;
- return $userCachedModels;
- }
- }
-
- // if the user has an API key or uses basic auth, skip the admin cache
- if ($userId === null || (
- $this->openAiSettingsService->getUserApiKey($userId, false) === ''
- && (
- !$this->openAiSettingsService->getUseBasicAuth()
- || $this->openAiSettingsService->getUserBasicUser($userId) === ''
- || $this->openAiSettingsService->getUserBasicPassword($userId) === ''
- )
- )) {
- // here we know there is either no user cache or userId is null
- // so if there is no user-defined service credentials
- // we try to get the models from the admin cache
- if ($adminCachedModels = $cache->get($adminCacheKey)) {
- $this->logger->debug('Getting OpenAI models from the main distributed cache');
- $this->modelsMemoryCache[$memoryCacheKey] = $adminCachedModels;
- return $adminCachedModels;
- }
- }
-
- // if we don't need to refresh to model list and it's not been found in the cache, it is obtained from the DB
- $modelsObjectString = $this->appConfig->getValueString(Application::APP_ID, $dbCacheKey, '{"data":[],"object":"list"}');
- $fallbackModels = [
- 'data' => [],
- 'object' => 'list',
- ];
- try {
- $newCache = json_decode($modelsObjectString, true) ?? $fallbackModels;
- } catch (Throwable $e) {
- $this->logger->warning('Could not decode the model JSON string', ['model_string', $modelsObjectString, 'exception' => $e]);
- $newCache = $fallbackModels;
- }
- $cache->set($userId !== null ? $userCacheKey : $adminCacheKey, $newCache, Application::MODELS_CACHE_TTL);
- $this->modelsMemoryCache[$memoryCacheKey] = $newCache;
- return $newCache;
- }
-
- // we know we are refreshing so we clear the caches and make the network request
- $cache->remove($adminCacheKey);
- $cache->remove($userCacheKey);
-
+ public function getModels(?string $userId, ServiceConfig $service): array {
try {
- $this->logger->debug('Actually getting OpenAI models with a network request');
- $params = $this->isUsingOpenRouter($serviceType) ? ['output_modalities' => 'all'] : [];
- $modelsResponse = $this->request($userId, 'models', $params, serviceType: $serviceType);
+ $this->logger->debug('Getting the models of service ' . $service->getId() . ' with a network request');
+ $params = $service->isUsingOpenRouter() ? ['output_modalities' => 'all'] : [];
+ $modelsResponse = $this->request($userId, $service, 'models', $params);
} catch (Exception $e) {
$this->logger->warning('Error retrieving models (exc): ' . $e->getMessage());
throw $e;
@@ -263,59 +128,19 @@ public function getModels(?string $userId, bool $refresh = false, ?string $servi
throw new Exception($this->l10n->t('Invalid models response received'), Http::STATUS_INTERNAL_SERVER_ERROR);
}
- $cache->set($userId !== null ? $userCacheKey : $adminCacheKey, $modelsResponse, Application::MODELS_CACHE_TTL);
- $this->modelsMemoryCache[$memoryCacheKey] = $modelsResponse;
- // we always store the model list after getting it
- $modelsObjectString = json_encode($modelsResponse);
- $this->appConfig->setValueString(Application::APP_ID, $dbCacheKey, $modelsObjectString);
return $modelsResponse;
}
- /**
- * @param string $userId
- */
- private function hasOwnOpenAiApiKey(string $userId): bool {
- if (!$this->isUsingOpenAi()) {
- return false;
- }
-
- if ($this->openAiSettingsService->getUserApiKey($userId) !== '') {
- return true;
- }
-
- return false;
- }
-
- /**
- * @param string|null $userId
- * @return array
- */
- public function getModelEnumValues(?string $userId, ?string $serviceType = null): array {
- try {
- $modelResponse = $this->getModels($userId, false, $serviceType);
- $modelEnumValues = array_map(function (array $model) {
- return new ShapeEnumValue($model['id'], $model['id']);
- }, $modelResponse['data'] ?? []);
- if ($this->isUsingOpenAi()) {
- array_unshift($modelEnumValues, new ShapeEnumValue($this->l10n->t('Default'), 'Default'));
- }
- return $modelEnumValues;
- } catch (Throwable $e) {
- // avoid flooding the logs with errors from calls of task processing
- $this->logger->info('Error getting model enum values', ['exception' => $e]);
- return [];
- }
- }
-
/**
* Check whether quota is exceeded for a user
*
* @param string|null $userId
* @param int $type
+ * @param ServiceConfig $service the service the request would be made to
* @return bool
* @throws Exception
*/
- public function isQuotaExceeded(?string $userId, int $type): bool {
+ public function isQuotaExceeded(?string $userId, int $type, ServiceConfig $service): bool {
if ($userId === null) {
$this->logger->warning('Cannot check quota for anonymous user', ['app' => Application::APP_ID]);
return false;
@@ -325,13 +150,15 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
throw new Exception('Invalid quota type', Http::STATUS_BAD_REQUEST);
}
- if ($this->hasOwnOpenAiApiKey($userId)) {
- // User has specified own OpenAI API key, no quota limit:
+ if ($this->servicesService->userHasOwnCredentials($userId, $service)) {
+ // User has specified their own credentials for this service, no quota limit:
return false;
}
- $rule = $this->quotaRuleService->getRule($type, $userId);
+ $rule = $this->quotaRuleService->getRule($type, $userId, $service);
$quota = $rule['amount'];
$pool = $rule['pool'] ? $rule['id'] : null;
+ // quota rules belong to a service, so a budget is always spent on one
+ $serviceId = $service->getId();
if ($quota === 0) {
// Unlimited quota:
@@ -341,7 +168,7 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
$quotaStart = $this->openAiSettingsService->getQuotaStart();
try {
- $quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaStart, $pool);
+ $quotaUsage = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $type, $quotaStart, $pool, $serviceId);
} 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);
@@ -350,7 +177,7 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
return false;
}
$cache = $this->cacheFactory->createLocal(Application::APP_ID);
- if ($cache->get('quota_exceeded_' . $userId . '_' . $type) === null) {
+ if ($cache->get('quota_exceeded_' . $userId . '_' . $type . '_' . $service->getId()) === null) {
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setUser($userId)
@@ -358,7 +185,7 @@ public function isQuotaExceeded(?string $userId, int $type): bool {
->setObject('quota_exceeded', (string)$type)
->setSubject('quota_exceeded', ['type' => $type]);
$this->notificationManager->notify($notification);
- $cache->set('quota_exceeded_' . $userId . '_' . $type, true, 3600);
+ $cache->set('quota_exceeded_' . $userId . '_' . $type . '_' . $service->getId(), true, 3600);
}
return true;
}
@@ -404,41 +231,53 @@ public function translatedQuotaUnit(int $type): string {
}
/**
+ * Quota usage and limits of a user, per service
+ *
* @param string $userId
- * @return array
+ * @return array{services: list>, period: array, start: int, end: int}
* @throws Exception
*/
public function getUserQuotaInfo(string $userId): array {
- // Get quota limits (if the user has specified an own OpenAI API key, no quota limit, just supply default values as fillers)
- $ownApikey = $this->hasOwnOpenAiApiKey($userId);
- // 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, $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);
- }
- if ($ownApikey) {
- $quotaInfo[$quotaType]['limit'] = Application::DEFAULT_QUOTAS[$quotaType];
- } else {
- $rule = $this->quotaRuleService->getRule($quotaType, $userId);
- $quotaInfo[$quotaType]['limit'] = $rule['amount'];
- if ($rule['pool']) {
- $quotaInfo[$quotaType]['used_pool'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod($userId, $quotaType, $quotaStart, $rule['id']);
+
+ $services = [];
+ foreach ($this->servicesService->getServices() as $service) {
+ // if the user has their own credentials for a service, no quota applies to it
+ $ownCredentials = $this->servicesService->userHasOwnCredentials($userId, $service);
+ $quotaInfo = [];
+ foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) {
+ $rule = $ownCredentials ? null : $this->quotaRuleService->getRule($quotaType, $userId, $service);
+ $quotaInfo[$quotaType] = [
+ 'type' => $this->translatedQuotaType($quotaType),
+ 'unit' => $this->translatedQuotaUnit($quotaType),
+ 'limit' => $rule === null ? 0 : $rule['amount'],
+ ];
+ try {
+ $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod(
+ $userId, $quotaType, $quotaStart, null, $service->getId(),
+ );
+ if ($rule !== null && $rule['pool']) {
+ $quotaInfo[$quotaType]['used_pool'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod(
+ $userId, $quotaType, $quotaStart, $rule['id'], $service->getId(),
+ );
+ }
+ } 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);
}
}
- $quotaInfo[$quotaType]['unit'] = $this->translatedQuotaUnit($quotaType);
+ $services[] = [
+ 'id' => $service->getId(),
+ 'name' => $service->getDisplayName(),
+ 'has_own_credentials' => $ownCredentials,
+ 'quota_usage' => $quotaInfo,
+ ];
}
return [
- 'quota_usage' => $quotaInfo,
+ 'services' => $services,
'period' => $quotaPeriod,
'start' => $quotaStart,
'end' => $quotaEnd,
@@ -446,27 +285,37 @@ public function getUserQuotaInfo(string $userId): array {
}
/**
- * @return array
+ * Instance-wide quota usage, per service
+ *
+ * @return list>
* @throws Exception
*/
public function getAdminQuotaInfo(): array {
- // 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, $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:
- throw new Exception('Could not retrieve quota usage: ' . $e->getMessage(), Http::STATUS_INTERNAL_SERVER_ERROR);
+ $services = [];
+ foreach ($this->servicesService->getServices() as $service) {
+ $quotaInfo = [];
+ foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) {
+ $quotaInfo[$quotaType] = [
+ 'type' => $this->translatedQuotaType($quotaType),
+ 'unit' => $this->translatedQuotaUnit($quotaType),
+ 'limit' => $service->getQuota($quotaType),
+ ];
+ try {
+ $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsInTimePeriod($quotaType, $startTime, $service->getId());
+ } 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:
+ throw new Exception('Could not retrieve quota usage: ' . $e->getMessage(), Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
}
- $quotaInfo[$quotaType]['unit'] = $this->translatedQuotaUnit($quotaType);
+ $services[] = [
+ 'id' => $service->getId(),
+ 'name' => $service->getDisplayName(),
+ 'quota_usage' => $quotaInfo,
+ ];
}
-
- return $quotaInfo;
+ return $services;
}
/**
@@ -481,6 +330,7 @@ public function getAdminQuotaInfo(): array {
*/
public function createCompletion(
?string $userId,
+ ServiceConfig $service,
string $prompt,
int $n,
string $model,
@@ -488,26 +338,28 @@ public function createCompletion(
?array $extraParams = null,
): array {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT, $service)) {
throw new Exception($this->l10n->t('Text generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
- $maxTokensLimit = $this->openAiSettingsService->getMaxTokens();
+ $maxTokensLimit = $service->getMaxTokens();
if ($maxTokens === null || $maxTokens > $maxTokensLimit) {
$maxTokens = $maxTokensLimit;
}
- $params = [
- 'model' => $model === Application::DEFAULT_MODEL_ID ? Application::DEFAULT_COMPLETION_MODEL_ID : $model,
- 'prompt' => $prompt,
- 'max_tokens' => $maxTokens,
- 'n' => $n,
- ];
+ $params = [];
+ $modelParam = $this->modelParam($service, $model, Application::DEFAULT_COMPLETION_MODEL_ID);
+ if ($modelParam !== null) {
+ $params['model'] = $modelParam;
+ }
+ $params['prompt'] = $prompt;
+ $params['max_tokens'] = $maxTokens;
+ $params['n'] = $n;
if ($userId !== null) {
$params['user'] = $userId;
}
- $adminExtraParams = $this->getAdminExtraParams('llm_extra_params');
+ $adminExtraParams = $service->getLlmExtraParamsArray();
if ($adminExtraParams !== null) {
$params = array_merge($adminExtraParams, $params);
}
@@ -515,7 +367,7 @@ public function createCompletion(
$params = array_merge($extraParams, $params);
}
- $response = $this->request($userId, 'completions', $params, 'POST');
+ $response = $this->request($userId, $service, 'completions', $params, 'POST');
if (!isset($response['choices'])) {
$this->logger->warning('Text generation error: ' . json_encode($response));
@@ -525,7 +377,7 @@ public function createCompletion(
if (isset($response['usage'], $response['usage']['total_tokens'])) {
$usage = $response['usage']['total_tokens'];
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_TEXT . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -545,6 +397,7 @@ public function createCompletion(
public function createStreamedChatCompletion(
?string $userId,
+ ServiceConfig $service,
string $model,
?string $userPrompt = null,
?string $systemPrompt = null,
@@ -556,12 +409,13 @@ public function createStreamedChatCompletion(
?array $tools = null,
?array $files = null,
): \Generator {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT, $service)) {
throw new Exception($this->l10n->t('Text generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
$params = $this->buildChatCompletionRequestParams(
$userId,
+ $service,
$model,
$userPrompt,
$systemPrompt,
@@ -577,12 +431,12 @@ public function createStreamedChatCompletion(
$response = $this->request(
$userId,
+ $service,
'chat/completions',
$params,
'POST',
null,
true,
- null,
0,
true,
);
@@ -592,7 +446,7 @@ public function createStreamedChatCompletion(
if (isset($streamResult['usage']['total_tokens'])) {
$usage = $streamResult['usage']['total_tokens'];
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_TEXT . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -603,6 +457,7 @@ public function createStreamedChatCompletion(
public function createChatCompletion(
?string $userId,
+ ServiceConfig $service,
string $model,
?string $userPrompt = null,
?string $systemPrompt = null,
@@ -615,7 +470,7 @@ public function createChatCompletion(
?array $files = null,
): array {
$response = $this->requestChatCompletion(
- $userId, $model, $userPrompt, $systemPrompt, $history,
+ $userId, $service, $model, $userPrompt, $systemPrompt, $history,
$n, $maxTokens, $extraParams, $toolMessage, $tools, $files,
false,
);
@@ -623,7 +478,7 @@ public function createChatCompletion(
if (isset($response['usage'], $response['usage']['total_tokens'])) {
$usage = $response['usage']['total_tokens'];
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TEXT, $usage, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_TEXT . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -650,6 +505,7 @@ public function createChatCompletion(
*/
public function requestChatCompletion(
?string $userId,
+ ServiceConfig $service,
string $model,
?string $userPrompt = null,
?string $systemPrompt = null,
@@ -662,12 +518,13 @@ public function requestChatCompletion(
?array $files = null,
bool $stream = false,
): array {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT, $service)) {
throw new Exception($this->l10n->t('Text generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
$params = $this->buildChatCompletionRequestParams(
$userId,
+ $service,
$model,
$userPrompt,
$systemPrompt,
@@ -681,7 +538,7 @@ public function requestChatCompletion(
$stream,
);
- return $this->request($userId, 'chat/completions', $params, 'POST');
+ return $this->request($userId, $service, 'chat/completions', $params, 'POST');
}
/**
@@ -701,6 +558,7 @@ public function requestChatCompletion(
*/
private function buildChatCompletionRequestParams(
?string $userId,
+ ServiceConfig $service,
string $model,
?string $userPrompt = null,
?string $systemPrompt = null,
@@ -713,16 +571,14 @@ private function buildChatCompletionRequestParams(
?array $files = null,
bool $stream = false,
): array {
- $modelRequestParam = $model === Application::DEFAULT_MODEL_ID
- ? Application::DEFAULT_COMPLETION_MODEL_ID
- : $model;
+ $modelRequestParam = $this->modelParam($service, $model, Application::DEFAULT_COMPLETION_MODEL_ID);
$messages = [];
if ($systemPrompt !== null) {
$messages[] = [
// o1-* models don't support system messages
// system prompts as a user message seems to work fine though
- 'role' => ($this->isUsingOpenAi() && str_starts_with($modelRequestParam, 'o1-'))
+ 'role' => ($service->isUsingOpenAi() && str_starts_with($modelRequestParam ?? '', 'o1-'))
? 'user'
: 'system',
'content' => $systemPrompt,
@@ -774,7 +630,7 @@ private function buildChatCompletionRequestParams(
}
// If the history contains a file that isn't supported anymore we should skip it so the chat isn't broken
try {
- $content = array_merge($content, $this->openAiFileService->buildFileContentFromId($item['file_id'], $userId, $item['ocp_task_id'] ?? null));
+ $content = array_merge($content, $this->openAiFileService->buildFileContentFromId($item['file_id'], $userId, $item['ocp_task_id'] ?? null, $service));
} catch (ProcessingException|UserFacingProcessingException $e) {
$this->logger->warning('Could not build file content from id: ' . $item['file_id'] . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -794,7 +650,7 @@ private function buildChatCompletionRequestParams(
}
$content = [];
foreach ($files as $file) {
- $content = array_merge($content, $this->openAiFileService->buildFileContentFromFile($file));
+ $content = array_merge($content, $this->openAiFileService->buildFileContentFromFile($file, $service));
}
if ($userPrompt !== null) {
$content[] = [
@@ -824,18 +680,19 @@ private function buildChatCompletionRequestParams(
}
}
- $params = [
- 'model' => $modelRequestParam,
- 'messages' => $messages,
- 'n' => $n,
- 'stream' => $stream,
- ];
+ $params = [];
+ if ($modelRequestParam !== null) {
+ $params['model'] = $modelRequestParam;
+ }
+ $params['messages'] = $messages;
+ $params['n'] = $n;
+ $params['stream'] = $stream;
- $maxTokensLimit = $this->openAiSettingsService->getMaxTokens();
+ $maxTokensLimit = $service->getMaxTokens();
if ($maxTokens === null || $maxTokens > $maxTokensLimit) {
$maxTokens = $maxTokensLimit;
}
- if ($this->openAiSettingsService->getUseMaxCompletionTokensParam()) {
+ if ($service->getUseMaxCompletionTokensParam()) {
// max_tokens is now deprecated https://platform.openai.com/docs/api-reference/chat/create
$params['max_completion_tokens'] = $maxTokens;
} else {
@@ -846,18 +703,18 @@ private function buildChatCompletionRequestParams(
if ($tools !== null && count($tools) > 0) {
$params['tools'] = $tools;
}
- if ($userId !== null && $this->isUsingOpenAi()) {
+ if ($userId !== null && $service->isUsingOpenAi()) {
$params['user'] = $userId;
}
- $adminExtraParams = $this->getAdminExtraParams('llm_extra_params');
+ $adminExtraParams = $service->getLlmExtraParamsArray();
if ($adminExtraParams !== null) {
$params = array_merge($adminExtraParams, $params);
}
if ($extraParams !== null) {
$params = array_merge($extraParams, $params);
}
- if ($stream && $this->isUsingOpenAi()) {
+ if ($stream && $service->isUsingOpenAi()) {
$params['stream_options'] = array_merge(
is_array($params['stream_options'] ?? null) ? $params['stream_options'] : [],
['include_usage' => true],
@@ -868,19 +725,18 @@ private function buildChatCompletionRequestParams(
}
/**
- * @param string $configKey
- * @return array|null
+ * The value of the model request parameter, or null when it should not be
+ * sent at all.
+ *
+ * The "Default" pseudo model means the service serves one fixed model and
+ * does not expect the parameter. OpenAI always requires it, so we fall back
+ * to a sensible default there.
*/
- private function getAdminExtraParams(string $configKey): ?array {
- $stringValue = $this->appConfig->getValueString(Application::APP_ID, $configKey, lazy: true);
- if ($stringValue === '') {
- return null;
+ private function modelParam(ServiceConfig $service, string $model, string $openAiFallback): ?string {
+ if ($model !== Application::DEFAULT_MODEL_ID) {
+ return $model;
}
- $arrayValue = json_decode($stringValue, true);
- if (!is_array($arrayValue)) {
- return null;
- }
- return $arrayValue;
+ return $service->isUsingOpenAi() ? $openAiFallback : null;
}
/**
@@ -892,12 +748,14 @@ private function getAdminExtraParams(string $configKey): ?array {
*/
public function transcribeBase64Mp3(
?string $userId,
+ ServiceConfig $service,
string $audioBase64,
bool $translate = true,
string $model = Application::DEFAULT_MODEL_ID,
): string {
return $this->transcribe(
$userId,
+ $service,
base64_decode(str_replace('data:audio/mp3;base64,', '', $audioBase64)),
$translate,
$model
@@ -916,6 +774,7 @@ public function transcribeBase64Mp3(
*/
public function transcribeFile(
?string $userId,
+ ServiceConfig $service,
File $file,
bool $translate = false,
string $model = Application::DEFAULT_MODEL_ID,
@@ -923,7 +782,7 @@ public function transcribeFile(
string $responseFormat = 'verbose_json',
): string {
try {
- $transcriptionResponse = $this->transcribe($userId, $file->getContent(), $translate, $model, $language, $responseFormat);
+ $transcriptionResponse = $this->transcribe($userId, $service, $file->getContent(), $translate, $model, $language, $responseFormat);
} catch (NotPermittedException|LockedException|GenericFileException $e) {
$this->logger->warning('Could not read audio file: ' . $file->getPath() . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
throw new Exception($this->l10n->t('Could not read audio file.'), Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -944,25 +803,24 @@ public function transcribeFile(
*/
public function transcribe(
?string $userId,
+ ServiceConfig $service,
string $audioFileContent,
bool $translate = true,
string $model = Application::DEFAULT_MODEL_ID,
string $language = 'default',
string $responseFormat = 'verbose_json', // Verbose needed for extraction of audio duration
): string {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TRANSCRIPTION)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TRANSCRIPTION, $service)) {
throw new Exception($this->l10n->t('Audio transcription quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
- // enforce whisper for OpenAI
- if ($this->isUsingOpenAi()) {
- $model = Application::DEFAULT_TRANSCRIPTION_MODEL_ID;
- }
- $params = [
- 'model' => $model === Application::DEFAULT_MODEL_ID ? Application::DEFAULT_TRANSCRIPTION_MODEL_ID : $model,
- 'file' => $audioFileContent,
- 'response_format' => $responseFormat,
- ];
+ $params = [];
+ $modelParam = $this->modelParam($service, $model, Application::DEFAULT_TRANSCRIPTION_MODEL_ID);
+ if ($modelParam !== null) {
+ $params['model'] = $modelParam;
+ }
+ $params['file'] = $audioFileContent;
+ $params['response_format'] = $responseFormat;
// Gets the user's preferred language if it's not the default one
if ($language === 'default') {
$language = $this->openAiSettingsService->getUserSTTLanguage($userId);
@@ -973,7 +831,7 @@ public function transcribe(
$endpoint = $translate ? 'audio/translations' : 'audio/transcriptions';
$contentType = 'multipart/form-data';
- $response = $this->request($userId, $endpoint, $params, 'POST', $contentType, serviceType: Application::SERVICE_TYPE_STT);
+ $response = $this->request($userId, $service, $endpoint, $params, 'POST', $contentType);
if (in_array($responseFormat, Application::SUPPORTED_SUBTITLE_FORMATS)) {
if (!isset($response['body'])) {
@@ -994,7 +852,7 @@ public function transcribe(
$audioDuration = ($hours * 3600) + ($minutes * 60) + $seconds + $millisecondAdjustment;
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TRANSCRIPTION, $audioDuration);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TRANSCRIPTION, $audioDuration, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_TRANSCRIPTION . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -1024,7 +882,7 @@ public function transcribe(
}
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TRANSCRIPTION, $audioDuration);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_TRANSCRIPTION, $audioDuration, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_TRANSCRIPTION . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -1043,12 +901,13 @@ public function transcribe(
*/
public function requestImageCreation(
?string $userId,
+ ServiceConfig $service,
string $prompt,
string $model,
int $n = 1,
string $size = Application::DEFAULT_DEFAULT_IMAGE_SIZE,
): array {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_IMAGE)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_IMAGE, $service)) {
throw new Exception($this->l10n->t('Image generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
@@ -1056,17 +915,20 @@ public function requestImageCreation(
'prompt' => $prompt,
'size' => $size,
'n' => $n,
- 'model' => $model === Application::DEFAULT_MODEL_ID ? Application::DEFAULT_IMAGE_MODEL_ID : $model,
];
+ $modelParam = $this->modelParam($service, $model, Application::DEFAULT_IMAGE_MODEL_ID);
+ if ($modelParam !== null) {
+ $params['model'] = $modelParam;
+ }
- $apiResponse = $this->request($userId, 'images/generations', $params, 'POST', serviceType: Application::SERVICE_TYPE_IMAGE);
+ $apiResponse = $this->request($userId, $service, 'images/generations', $params, 'POST');
if (!isset($apiResponse['data']) || !is_array($apiResponse['data'])) {
$this->logger->warning('OpenAI image generation error', ['api_response' => $apiResponse]);
throw new Exception($this->l10n->t('Unknown image generation error'), Http::STATUS_INTERNAL_SERVER_ERROR);
} else {
try {
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_IMAGE, $n);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_IMAGE, $n, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_IMAGE . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}
@@ -1078,39 +940,22 @@ public function requestImageCreation(
* @param string|null $userId
* @return array
*/
- public function getImageRequestOptions(?string $userId): array {
+ public function getImageRequestOptions(?string $userId, ServiceConfig $service): array {
+ $service = $this->servicesService->applyUserCredentials($service, $userId);
$requestOptions = [
- 'timeout' => $this->openAiSettingsService->getRequestTimeout(),
+ 'timeout' => $service->getRequestTimeout(),
'headers' => [
'User-Agent' => Application::USER_AGENT,
],
];
- if ($this->openAiSettingsService->getIsImageRetrievalAuthenticated()) {
- if ($this->openAiSettingsService->imageOverrideEnabled()) {
- $useBasicAuth = $this->openAiSettingsService->getAdminImageUseBasicAuth();
-
- $apiKey = $this->openAiSettingsService->getAdminImageApiKey();
- $basicUser = $this->openAiSettingsService->getAdminImageBasicUser();
- $basicPassword = $this->openAiSettingsService->getAdminImageBasicPassword();
-
- $requestOptions['timeout'] = $this->openAiSettingsService->getImageRequestTimeout();
- } else {
- // image service settings are not overridden
- $useBasicAuth = $this->openAiSettingsService->getUseBasicAuth();
-
- // this has no equivalent when the service URL is overridden
- // so the user-defined credentials will be ignored
- $apiKey = $this->openAiSettingsService->getUserApiKey($userId, true);
- $basicUser = $this->openAiSettingsService->getUserBasicUser($userId, true);
- $basicPassword = $this->openAiSettingsService->getUserBasicPassword($userId, true);
- }
- if ($useBasicAuth) {
- if ($basicUser !== '' && $basicPassword !== '') {
- $requestOptions['headers']['Authorization'] = 'Basic ' . base64_encode($basicUser . ':' . $basicPassword);
+ if ($service->getImageRequestAuth()) {
+ if ($service->usesBasicAuth()) {
+ if ($service->getBasicUser() !== '' && $service->getBasicPassword() !== '') {
+ $requestOptions['headers']['Authorization'] = 'Basic ' . base64_encode($service->getBasicUser() . ':' . $service->getBasicPassword());
}
} else {
- $requestOptions['headers']['Authorization'] = 'Bearer ' . $apiKey;
+ $requestOptions['headers']['Authorization'] = 'Bearer ' . $service->getApiKey();
}
}
return $requestOptions;
@@ -1127,28 +972,32 @@ public function getImageRequestOptions(?string $userId): array {
*/
public function requestSpeechCreation(
?string $userId,
+ ServiceConfig $service,
string $prompt,
string $model,
string $voice,
float $speed = 1,
): array {
- if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_SPEECH)) {
+ if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_SPEECH, $service)) {
throw new Exception($this->l10n->t('Speech generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}
$params = [
'input' => $prompt,
'voice' => $voice === Application::DEFAULT_MODEL_ID ? Application::DEFAULT_SPEECH_VOICE : $voice,
- 'model' => $model === Application::DEFAULT_MODEL_ID ? Application::DEFAULT_SPEECH_MODEL_ID : $model,
- 'response_format' => 'mp3',
- 'speed' => $speed,
];
+ $modelParam = $this->modelParam($service, $model, Application::DEFAULT_SPEECH_MODEL_ID);
+ if ($modelParam !== null) {
+ $params['model'] = $modelParam;
+ }
+ $params['response_format'] = 'mp3';
+ $params['speed'] = $speed;
- $apiResponse = $this->request($userId, 'audio/speech', $params, 'POST', serviceType: Application::SERVICE_TYPE_TTS);
+ $apiResponse = $this->request($userId, $service, 'audio/speech', $params, 'POST');
try {
$charCount = mb_strlen($prompt);
- $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_SPEECH, $charCount);
+ $this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_SPEECH, $charCount, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_SPEECH . '. Error: ' . $e->getMessage());
}
@@ -1156,51 +1005,72 @@ public function requestSpeechCreation(
}
/**
- * @return int
+ * The measured processing time of a service, in seconds
*/
- public function getExpTextProcessingTime(): int {
- return $this->isUsingOpenAi()
- ? intval($this->appConfig->getValueString(Application::APP_ID, 'openai_text_generation_time', strval(Application::DEFAULT_OPENAI_TEXT_GENERATION_TIME), lazy: true))
- : intval($this->appConfig->getValueString(Application::APP_ID, 'localai_text_generation_time', strval(Application::DEFAULT_LOCALAI_TEXT_GENERATION_TIME), lazy: true));
+ public function getExpTextProcessingTime(ServiceConfig $service): int {
+ return $this->getExpProcessingTime(
+ $service,
+ Application::TEXT_PROCESSING_TIME_KEY,
+ $service->isUsingOpenAi()
+ ? Application::DEFAULT_OPENAI_TEXT_GENERATION_TIME
+ : Application::DEFAULT_LOCALAI_TEXT_GENERATION_TIME,
+ );
}
- /**
- * @param int $runtime
- * @return void
- */
- public function updateExpTextProcessingTime(int $runtime): void {
- $oldTime = floatval($this->getExpTextProcessingTime());
- $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * $oldTime + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime);
-
- if ($this->isUsingOpenAi()) {
- $this->appConfig->setValueString(Application::APP_ID, 'openai_text_generation_time', strval(intval($newTime)), lazy: true);
- } else {
- $this->appConfig->setValueString(Application::APP_ID, 'localai_text_generation_time', strval(intval($newTime)), lazy: true);
- }
+ public function updateExpTextProcessingTime(int $runtime, ServiceConfig $service): void {
+ $this->updateExpProcessingTime(
+ $service,
+ Application::TEXT_PROCESSING_TIME_KEY,
+ $this->getExpTextProcessingTime($service),
+ $runtime,
+ );
}
/**
- * @return int
+ * The measured image processing time of a service, in seconds
*/
- public function getExpImgProcessingTime(): int {
- return $this->isUsingOpenAi(Application::SERVICE_TYPE_IMAGE)
- ? intval($this->appConfig->getValueString(Application::APP_ID, 'openai_image_generation_time', strval(Application::DEFAULT_OPENAI_IMAGE_GENERATION_TIME), lazy: true))
- : intval($this->appConfig->getValueString(Application::APP_ID, 'localai_image_generation_time', strval(Application::DEFAULT_LOCALAI_IMAGE_GENERATION_TIME), lazy: true));
+ public function getExpImgProcessingTime(ServiceConfig $service): int {
+ return $this->getExpProcessingTime(
+ $service,
+ Application::IMAGE_PROCESSING_TIME_KEY,
+ $service->isUsingOpenAi()
+ ? Application::DEFAULT_OPENAI_IMAGE_GENERATION_TIME
+ : Application::DEFAULT_LOCALAI_IMAGE_GENERATION_TIME,
+ );
+ }
+
+ public function updateExpImgProcessingTime(int $runtime, ServiceConfig $service): void {
+ $this->updateExpProcessingTime(
+ $service,
+ Application::IMAGE_PROCESSING_TIME_KEY,
+ $this->getExpImgProcessingTime($service),
+ $runtime,
+ );
}
/**
- * @param int $runtime
- * @return void
+ * The processing times are measured per service, so that a slow service
+ * does not skew the runtime estimate of a fast one. Until a service has
+ * answered once, the default of the kind of service it is applies.
*/
- public function updateExpImgProcessingTime(int $runtime): void {
- $oldTime = floatval($this->getExpImgProcessingTime());
- $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * $oldTime + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime);
+ private function getExpProcessingTime(ServiceConfig $service, string $key, int $default): int {
+ return $this->appConfig->getValueInt(
+ Application::APP_ID,
+ $key . '_' . $service->getId(),
+ $default,
+ lazy: true,
+ );
+ }
- if ($this->isUsingOpenAi(Application::SERVICE_TYPE_IMAGE)) {
- $this->appConfig->setValueString(Application::APP_ID, 'openai_image_generation_time', strval(intval($newTime)), lazy: true);
- } else {
- $this->appConfig->setValueString(Application::APP_ID, 'localai_image_generation_time', strval(intval($newTime)), lazy: true);
- }
+ private function updateExpProcessingTime(ServiceConfig $service, string $key, int $oldTime, int $runtime): void {
+ $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * floatval($oldTime)
+ + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime);
+ $this->appConfig->setValueInt(
+ Application::APP_ID,
+ $key . '_' . $service->getId(),
+ intval($newTime),
+ lazy: true,
+ );
}
/**
@@ -1211,25 +1081,24 @@ public function updateExpImgProcessingTime(int $runtime): void {
* @param string $method HTTP query method
* @param string|null $contentType
* @param bool $logErrors if set to false error logs will be suppressed
- * @param string|null $serviceType
* @param int $retryCount number of retries that have been attempted so far
* @return array decoded request result or error
* @throws Exception|UserFacingProcessingException
*/
public function request(
- ?string $userId, string $endPoint, array $params = [], string $method = 'GET',
- ?string $contentType = null, bool $logErrors = true, ?string $serviceType = null,
+ ?string $userId, ServiceConfig $service, string $endPoint, array $params = [], string $method = 'GET',
+ ?string $contentType = null, bool $logErrors = true,
int $retryCount = 0,
bool $stream = false,
): array {
try {
- $context = $this->getRequestContext($userId, $serviceType);
- $serviceUrl = $context['serviceUrl'];
- $apiKey = $context['apiKey'];
- $basicUser = $context['basicUser'];
- $basicPassword = $context['basicPassword'];
- $useBasicAuth = $context['useBasicAuth'];
- $timeout = $context['timeout'];
+ // the user's own credentials take precedence over the admin ones
+ $service = $this->servicesService->applyUserCredentials($service, $userId);
+ $serviceUrl = $service->getRequestUrl();
+ $apiKey = $service->getApiKey();
+ $basicUser = $service->getBasicUser();
+ $basicPassword = $service->getBasicPassword();
+ $timeout = $service->getRequestTimeout();
$url = rtrim($serviceUrl, '/') . '/' . $endPoint;
$options = [
@@ -1243,7 +1112,7 @@ public function request(
return ['error' => 'An API key is required for api.openai.com'];
}
- if ($this->isUsingOpenAi($serviceType) || !$useBasicAuth) {
+ if (!$service->usesBasicAuth()) {
if ($apiKey !== '') {
$options['headers']['Authorization'] = 'Bearer ' . $apiKey;
}
@@ -1253,7 +1122,7 @@ public function request(
}
}
- if (!$this->isUsingOpenAi($serviceType)) {
+ if (!$service->isUsingOpenAi()) {
$options['nextcloud']['allow_local_address'] = true;
}
@@ -1366,7 +1235,7 @@ public function request(
}
$this->logger->warning("Rate limit exceeded, retrying in $sleep seconds", ['retry_count' => $retryCount]);
sleep($sleep);
- return $this->request($userId, $endPoint, $params, $method, $contentType, $logErrors, $serviceType, $retryCount + 1, $stream);
+ return $this->request($userId, $service, $endPoint, $params, $method, $contentType, $logErrors, $retryCount + 1, $stream);
} else {
$this->logger->warning('Rate limit exceeded, maximum retries reached', ['retry_count' => $retryCount]);
}
@@ -1393,14 +1262,14 @@ public function request(
throw new UserFacingProcessingException(
$this->l10n->t('API request error: ') . $errorMessage,
intval($e->getCode()),
- userFacingMessage: $this->l10n->t('%s API error: Invalid API key or invalid Basic Authentication. Contact your system administrator.', [$this->getServiceName()]),
+ userFacingMessage: $this->l10n->t('%s API error: Invalid API key or invalid Basic Authentication. Contact your system administrator.', [$service->getDisplayName()]),
);
}
if ($e->getResponse()->getStatusCode() >= 500) {
throw new UserFacingProcessingException(
$this->l10n->t('API request error: ') . $errorMessage,
intval($e->getCode()),
- userFacingMessage: $this->l10n->t('%s API error: AI backend is currently not available. Contact your system administrator.', [$this->getServiceName()]),
+ userFacingMessage: $this->l10n->t('%s API error: AI backend is currently not available. Contact your system administrator.', [$service->getDisplayName()]),
);
}
throw new Exception(
@@ -1414,63 +1283,11 @@ public function request(
throw new UserFacingProcessingException(
$this->l10n->t('API connection error: ') . $e->getMessage(),
intval($e->getCode()),
- userFacingMessage: $this->l10n->t('%s API error: AI backend is currently not reachable. Contact your system administrator.', [$this->getServiceName()]),
+ userFacingMessage: $this->l10n->t('%s API error: AI backend is currently not reachable. Contact your system administrator.', [$service->getDisplayName()]),
);
}
}
- /**
- * @param string|null $userId
- * @param string|null $serviceType
- * @return array{serviceUrl: string, apiKey: string, basicUser: string, basicPassword: string, useBasicAuth: bool, timeout: int}
- */
- private function getRequestContext(?string $userId, ?string $serviceType = null): array {
- if ($serviceType === Application::SERVICE_TYPE_IMAGE && $this->openAiSettingsService->imageOverrideEnabled()) {
- return [
- 'serviceUrl' => $this->openAiSettingsService->getImageServiceUrl(),
- 'apiKey' => $this->openAiSettingsService->getAdminImageApiKey(),
- 'basicUser' => $this->openAiSettingsService->getAdminImageBasicUser(),
- 'basicPassword' => $this->openAiSettingsService->getAdminImageBasicPassword(),
- 'useBasicAuth' => $this->openAiSettingsService->getAdminImageUseBasicAuth(),
- 'timeout' => $this->openAiSettingsService->getImageRequestTimeout(),
- ];
- }
- if ($serviceType === Application::SERVICE_TYPE_STT && $this->openAiSettingsService->sttOverrideEnabled()) {
- return [
- 'serviceUrl' => $this->openAiSettingsService->getSttServiceUrl(),
- 'apiKey' => $this->openAiSettingsService->getAdminSttApiKey(),
- 'basicUser' => $this->openAiSettingsService->getAdminSttBasicUser(),
- 'basicPassword' => $this->openAiSettingsService->getAdminSttBasicPassword(),
- 'useBasicAuth' => $this->openAiSettingsService->getAdminSttUseBasicAuth(),
- 'timeout' => $this->openAiSettingsService->getSttRequestTimeout(),
- ];
- }
- if ($serviceType === Application::SERVICE_TYPE_TTS && $this->openAiSettingsService->ttsOverrideEnabled()) {
- return [
- 'serviceUrl' => $this->openAiSettingsService->getTtsServiceUrl(),
- 'apiKey' => $this->openAiSettingsService->getAdminTtsApiKey(),
- 'basicUser' => $this->openAiSettingsService->getAdminTtsBasicUser(),
- 'basicPassword' => $this->openAiSettingsService->getAdminTtsBasicPassword(),
- 'useBasicAuth' => $this->openAiSettingsService->getAdminTtsUseBasicAuth(),
- 'timeout' => $this->openAiSettingsService->getTtsRequestTimeout(),
- ];
- }
-
- $serviceUrl = $this->openAiSettingsService->getServiceUrl();
- if ($serviceUrl === '') {
- $serviceUrl = Application::OPENAI_API_BASE_URL;
- }
-
- return [
- 'serviceUrl' => $serviceUrl,
- 'apiKey' => $this->openAiSettingsService->getUserApiKey($userId, true),
- 'basicUser' => $this->openAiSettingsService->getUserBasicUser($userId, true),
- 'basicPassword' => $this->openAiSettingsService->getUserBasicPassword($userId, true),
- 'useBasicAuth' => $this->openAiSettingsService->getUseBasicAuth(),
- 'timeout' => $this->openAiSettingsService->getRequestTimeout(),
- ];
- }
-
/**
* @param array $response
* @return array{messages: array, reasoning_messages: array, tool_calls: array, audio_messages: list>}
@@ -1551,12 +1368,10 @@ private function normalizeChatCompletionResponse(array $response): array {
}
/**
- * Check if the T2I provider is available
- *
- * @return bool whether the T2I provider is available
+ * Check whether a service can generate images
*/
- public function isT2IAvailable(): bool {
- if ($this->openAiSettingsService->imageOverrideEnabled() || $this->isUsingOpenAi()) {
+ public function isT2IAvailable(ServiceConfig $service): bool {
+ if ($service->isUsingOpenAi()) {
return true;
}
try {
@@ -1564,7 +1379,7 @@ public function isT2IAvailable(): bool {
'prompt' => 'a',
'model' => 'invalid-model',
];
- $this->request(null, 'images/generations', $params, 'POST', logErrors: false, serviceType: Application::SERVICE_TYPE_IMAGE);
+ $this->request(null, $service, 'images/generations', $params, 'POST', logErrors: false);
} catch (Exception $e) {
return $e->getCode() !== Http::STATUS_NOT_FOUND && $e->getCode() !== Http::STATUS_UNAUTHORIZED;
}
@@ -1572,12 +1387,10 @@ public function isT2IAvailable(): bool {
}
/**
- * Check if the STT provider is available
- *
- * @return bool whether the STT provider is available
+ * Check whether a service can transcribe audio
*/
- public function isSTTAvailable(): bool {
- if ($this->openAiSettingsService->sttOverrideEnabled() || $this->isUsingOpenAi()) {
+ public function isSTTAvailable(ServiceConfig $service): bool {
+ if ($service->isUsingOpenAi()) {
return true;
}
try {
@@ -1585,7 +1398,7 @@ public function isSTTAvailable(): bool {
'model' => 'invalid-model',
'file' => 'a',
];
- $this->request(null, 'audio/translations', $params, 'POST', 'multipart/form-data', logErrors: false, serviceType: Application::SERVICE_TYPE_STT);
+ $this->request(null, $service, 'audio/translations', $params, 'POST', 'multipart/form-data', logErrors: false);
} catch (Exception $e) {
return $e->getCode() !== Http::STATUS_NOT_FOUND && $e->getCode() !== Http::STATUS_UNAUTHORIZED;
}
@@ -1593,12 +1406,10 @@ public function isSTTAvailable(): bool {
}
/**
- * Check if the TTS provider is available
- *
- * @return bool whether the TTS provider is available
+ * Check whether a service can generate speech
*/
- public function isTTSAvailable(): bool {
- if ($this->openAiSettingsService->ttsOverrideEnabled() || $this->isUsingOpenAi()) {
+ public function isTTSAvailable(ServiceConfig $service): bool {
+ if ($service->isUsingOpenAi()) {
return true;
}
try {
@@ -1609,7 +1420,7 @@ public function isTTSAvailable(): bool {
'response_format' => 'mp3',
];
- $this->request(null, 'audio/speech', $params, 'POST', logErrors: false, serviceType: Application::SERVICE_TYPE_TTS);
+ $this->request(null, $service, 'audio/speech', $params, 'POST', logErrors: false);
} catch (Exception $e) {
return $e->getCode() !== Http::STATUS_NOT_FOUND && $e->getCode() !== Http::STATUS_UNAUTHORIZED;
}
@@ -1617,17 +1428,19 @@ public function isTTSAvailable(): bool {
}
/**
- * Updates the admin config with the availability of the providers
+ * Detect which modalities a service supports and switch off the ones it
+ * does not. The text modality is always assumed to be available.
*
- * @return array the updated config
+ * @return array the detected modality switches
* @throws Exception
*/
- public function autoDetectFeatures(): array {
- $config = [];
- $config['t2i_provider_enabled'] = $this->isT2IAvailable();
- $config['stt_provider_enabled'] = $this->isSTTAvailable();
- $config['tts_provider_enabled'] = $this->isTTSAvailable();
- $this->openAiSettingsService->setAdminConfig($config);
- return $config;
+ public function autoDetectModalities(ServiceConfig $service): array {
+ $detected = [
+ 'image_enabled' => $this->isT2IAvailable($service),
+ 'stt_enabled' => $this->isSTTAvailable($service),
+ 'tts_enabled' => $this->isTTSAvailable($service),
+ ];
+ $this->servicesService->updateService($service->getId(), $detected);
+ return $detected;
}
}
diff --git a/lib/Service/OpenAiFileService.php b/lib/Service/OpenAiFileService.php
index d5e9c6a2..936108a5 100644
--- a/lib/Service/OpenAiFileService.php
+++ b/lib/Service/OpenAiFileService.php
@@ -9,7 +9,6 @@
namespace OCA\OpenAi\Service;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Vendor\RtfHtmlPhp\Document;
use OCA\OpenAi\Vendor\RtfHtmlPhp\Html\HtmlFormatter;
use OCA\OpenAi\Vendor\Smalot\PdfParser\Parser;
@@ -72,7 +71,6 @@ class OpenAiFileService {
public function __construct(
private IL10N $l10n,
- private OpenAiSettingsService $openAiSettingsService,
private IRootFolder $rootFolder,
private ITaskProcessingManager $taskProcessingManager,
private LoggerInterface $logger,
@@ -85,11 +83,12 @@ public function __construct(
* @param int $fileId The ID of the file to build content from.
* @param ?string $userId The user ID.
* @param ?int $taskId The ID of the task
+ * @param ServiceConfig $service The service the content is built for
* @return list> Content parts suitable for OpenAI chat message content.
* @throws ProcessingException
* @throws UserFacingProcessingException
*/
- public function buildFileContentFromId(int $fileId, ?string $userId, ?int $taskId): array {
+ public function buildFileContentFromId(int $fileId, ?string $userId, ?int $taskId, ServiceConfig $service): array {
$file = null;
if ($taskId !== null) {
$task = $this->taskProcessingManager->getUserTask($taskId, $userId);
@@ -108,23 +107,24 @@ public function buildFileContentFromId(int $fileId, ?string $userId, ?int $taskI
$userFolder = $this->rootFolder->getUserFolder($userId);
$file = $userFolder->getFirstNodeById($fileId);
}
- return $this->buildFileContentFromFile($file);
+ return $this->buildFileContentFromFile($file, $service);
}
/**
* Builds file content from a File object.
*
* @param ?File $file The file to build content from.
+ * @param ServiceConfig $service The service the content is built for
* @return list> Content parts suitable for OpenAI chat message content.
* @throws ProcessingException
* @throws UserFacingProcessingException
*/
- public function buildFileContentFromFile(?File $file): array {
+ public function buildFileContentFromFile(?File $file, ServiceConfig $service): array {
if (!$file instanceof File || !$file->isReadable()) {
throw new ProcessingException('File is not readable');
}
// Maximum file size for openai is 50MB.
- if ($this->isUsingOpenAi() && $file->getSize() > self::MAX_FILE_SIZE_BYTES) {
+ if ($service->isUsingOpenAi() && $file->getSize() > self::MAX_FILE_SIZE_BYTES) {
throw new UserFacingProcessingException(
'Filesize of input files too large. Max is 50MB',
0,
@@ -139,15 +139,15 @@ public function buildFileContentFromFile(?File $file): array {
$fileType = mime_content_type($file->fopen('rb'));
}
if (str_starts_with($fileType, 'image/')) {
- return $this->buildImageContent($file, $fileType);
+ return $this->buildImageContent($file, $fileType, $service);
// OpenAI only supports this for very specific models and support is not that common
} elseif (str_starts_with($fileType, 'audio/')) {
- return $this->buildAudioContent($file, $fileType);
+ return $this->buildAudioContent($file, $fileType, $service);
// OpenAI does not currently support video attachments
} elseif (str_starts_with($fileType, 'video/')) {
- return $this->buildVideoContent($file, $fileType);
+ return $this->buildVideoContent($file, $fileType, $service);
} elseif ($fileType === 'application/pdf') {
- return $this->buildDocumentContent($file, $fileType);
+ return $this->buildDocumentContent($file, $fileType, $service);
} elseif ($fileType === 'text/rtf') {
return $this->buildRtfContent($file);
} else {
@@ -158,8 +158,8 @@ public function buildFileContentFromFile(?File $file): array {
/**
* @return list
*/
- private function buildImageContent(File $file, string $fileType): array {
- if (!$this->openAiSettingsService->getMultimodalImageEnabled()) {
+ private function buildImageContent(File $file, string $fileType, ServiceConfig $service): array {
+ if (!$service->getMultimodalImageEnabled()) {
throw new UserFacingProcessingException(
'Image attachments are disabled',
0,
@@ -167,7 +167,7 @@ private function buildImageContent(File $file, string $fileType): array {
$this->l10n->t('Image attachments are unsupported.'),
);
}
- if ($this->isUsingOpenAi() && !in_array($fileType, self::VALID_IMAGE_MIME_TYPES, true)) {
+ if ($service->isUsingOpenAi() && !in_array($fileType, self::VALID_IMAGE_MIME_TYPES, true)) {
throw new UserFacingProcessingException(
'Invalid input file type for OpenAI ' . $fileType,
0,
@@ -186,8 +186,8 @@ private function buildImageContent(File $file, string $fileType): array {
/**
* @return list
*/
- private function buildAudioContent(File $file, string $fileType): array {
- if (!$this->openAiSettingsService->getMultimodalAudioEnabled()) {
+ private function buildAudioContent(File $file, string $fileType, ServiceConfig $service): array {
+ if (!$service->getMultimodalAudioEnabled()) {
throw new UserFacingProcessingException(
'Audio attachments are disabled',
0,
@@ -217,8 +217,8 @@ private function buildAudioContent(File $file, string $fileType): array {
/**
* @return list
*/
- private function buildVideoContent(File $file, string $fileType): array {
- if (!$this->openAiSettingsService->getMultimodalVideoEnabled()) {
+ private function buildVideoContent(File $file, string $fileType, ServiceConfig $service): array {
+ if (!$service->getMultimodalVideoEnabled()) {
throw new UserFacingProcessingException(
'Video attachments are disabled',
0,
@@ -237,8 +237,8 @@ private function buildVideoContent(File $file, string $fileType): array {
/**
* @return list
*/
- private function buildDocumentContent(File $file, string $fileType): array {
- if (!$this->openAiSettingsService->getMultimodalDocumentEnabled()) {
+ private function buildDocumentContent(File $file, string $fileType, ServiceConfig $service): array {
+ if (!$service->getMultimodalDocumentEnabled()) {
$this->logger->info('Falling back to extracting text from pdf for file', ['fileId' => $file->getId()]);
$parser = new Parser();
$pdf = $parser->parseContent(stream_get_contents($file->fopen('rb')));
@@ -288,9 +288,4 @@ private function buildTextContent(File $file, string $fileType): array {
'text' => 'Filename:' . $file->getName() . "\nContent:\n" . stream_get_contents($file->fopen('rb')),
]];
}
-
- private function isUsingOpenAi(): bool {
- $serviceUrl = $this->openAiSettingsService->getServiceUrl();
- return $serviceUrl === '' || $serviceUrl === Application::OPENAI_API_BASE_URL;
- }
}
diff --git a/lib/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php
index 8d244b76..8e48a555 100644
--- a/lib/Service/OpenAiSettingsService.php
+++ b/lib/Service/OpenAiSettingsService.php
@@ -12,83 +12,30 @@
use Exception;
use OCA\OpenAi\AppInfo\Application;
use OCP\IAppConfig;
-use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\PreConditionNotMetException;
-use OCP\Security\ICrypto;
+/**
+ * Instance-wide settings of the app.
+ *
+ * Everything that belongs to one connected service (URL, credentials, request
+ * behaviour, selected models, quota amounts) lives in {@see ServicesService}
+ * instead. What remains here is global: the quota period, how long usage is
+ * stored and the user preferences that are not tied to a service.
+ */
class OpenAiSettingsService {
private const ADMIN_CONFIG_TYPES = [
- 'request_timeout' => 'integer',
- 'url' => 'string',
- 'service_name' => 'string',
- 'api_key' => 'string',
- 'default_completion_model_id' => 'string',
- 'default_stt_model_id' => 'string',
- 'default_tts_model_id' => 'string',
- 'tts_voices' => 'array',
- 'default_tts_voice' => 'string',
- 'default_image_model_id' => 'string',
- 'default_image_size' => 'string',
- 'image_request_auth' => 'boolean',
- 'chunk_size' => 'integer',
- 'max_tokens' => 'integer',
- 'use_max_completion_tokens_param' => 'boolean',
- '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',
- 'stt_provider_enabled' => 'boolean',
- 'tts_provider_enabled' => 'boolean',
- 'multimodal_image_enabled' => 'boolean',
- 'multimodal_audio_enabled' => 'boolean',
- 'multimodal_video_enabled' => 'boolean',
- 'multimodal_document_enabled' => 'boolean',
- 'chat_endpoint_enabled' => 'boolean',
- 'basic_user' => 'string',
- 'basic_password' => 'string',
- 'use_basic_auth' => 'boolean',
-
- 'image_url' => 'string',
- 'image_service_name' => 'string',
- 'image_api_key' => 'string',
- 'image_basic_user' => 'string',
- 'image_basic_password' => 'string',
- 'image_use_basic_auth' => 'boolean',
- 'image_request_timeout' => 'integer',
-
- 'stt_url' => 'string',
- 'stt_service_name' => 'string',
- 'stt_api_key' => 'string',
- 'stt_basic_user' => 'string',
- 'stt_basic_password' => 'string',
- 'stt_use_basic_auth' => 'boolean',
- 'stt_request_timeout' => 'integer',
-
- 'tts_url' => 'string',
- 'tts_service_name' => 'string',
- 'tts_api_key' => 'string',
- 'tts_basic_user' => 'string',
- 'tts_basic_password' => 'string',
- 'tts_use_basic_auth' => 'boolean',
- 'tts_request_timeout' => 'integer',
];
private const USER_CONFIG_TYPES = [
- 'api_key' => 'string',
- 'basic_user' => 'string',
- 'basic_password' => 'string',
'stt_language' => 'string',
];
public function __construct(
private IConfig $config,
private IAppConfig $appConfig,
- private ICrypto $crypto,
- private ICacheFactory $cacheFactory,
) {
}
@@ -155,149 +102,9 @@ public function getQuotaEnd(): int {
return $periodEnd->getTimestamp();
}
- public function invalidateModelsCache(): void {
- $cache = $this->cacheFactory->createDistributed(Application::APP_ID);
- $cache->clear(Application::MODELS_CACHE_KEY);
- }
-
////////////////////////////////////////////
//////////// Getters for settings //////////
- /**
- * @return string
- * @throws Exception
- */
- public function getAdminApiKey(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'api_key', lazy: true);
- }
-
- /**
- * SIC! Does not fall back on the admin api by default
- * @param null|string $userId
- * @param boolean $fallBackOnAdminValue
- * @return string
- * @throws Exception
- */
- public function getUserApiKey(?string $userId, bool $fallBackOnAdminValue = false): string {
- $fallBackApiKey = $fallBackOnAdminValue ? $this->getAdminApiKey() : '';
- if ($userId === null) {
- return $fallBackApiKey;
- }
- $encryptedUserApiKey = $this->config->getUserValue($userId, Application::APP_ID, 'api_key');
- $userApiKey = $encryptedUserApiKey === '' ? '' : $this->crypto->decrypt($encryptedUserApiKey);
- return $userApiKey ?: $fallBackApiKey;
- }
-
- /**
- * @param string|null $userId
- * @return string
- */
- public function getUserSTTLanguage(?string $userId): string {
- return $this->config->getUserValue($userId, Application::APP_ID, 'stt_language', 'detect_language');
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultCompletionModelId(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_COMPLETION_MODEL_ID, lazy: true) ?: Application::DEFAULT_COMPLETION_MODEL_ID;
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultSttModelId(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_stt_model_id', lazy: true) ?: Application::DEFAULT_MODEL_ID;
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultImageModelId(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_image_model_id', lazy: true) ?: Application::DEFAULT_MODEL_ID;
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultImageSize(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_image_size', lazy: true) ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE;
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultTtsModelId(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', lazy: true) ?: Application::DEFAULT_MODEL_ID;
- }
-
- /**
- * @return string
- */
- public function getAdminDefaultTtsVoice(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- }
-
- /**
- * @return array
- */
- public function getAdminTtsVoices(): array {
- $voices = json_decode(
- $this->appConfig->getValueString(
- Application::APP_ID, 'tts_voices',
- json_encode(Application::DEFAULT_SPEECH_VOICES),
- lazy: true,
- ) ?: json_encode(Application::DEFAULT_SPEECH_VOICES),
- true,
- );
- if (!is_array($voices)) {
- $voices = Application::DEFAULT_SPEECH_VOICES;
- }
- return $voices;
- }
-
- /**
- * @return string
- */
- public function getServiceUrl(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'url');
- }
-
- /**
- * @return string
- */
- public function getServiceName(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'service_name', lazy: true);
- }
-
- /**
- * @return int
- */
- public function getRequestTimeout(): int {
- return intval($this->appConfig->getValueString(Application::APP_ID, 'request_timeout', strval(Application::OPENAI_DEFAULT_REQUEST_TIMEOUT), lazy: true)) ?: Application::OPENAI_DEFAULT_REQUEST_TIMEOUT;
- }
-
- /**
- * @return int
- */
- public function getChunkSize(): int {
- return $this->appConfig->getValueInt(Application::APP_ID, 'chunk_size', Application::DEFAULT_CHUNK_SIZE, lazy: true) ?: Application::DEFAULT_CHUNK_SIZE;
- }
-
- /**
- * @return int
- */
- public function getMaxTokens(): int {
- return intval($this->appConfig->getValueString(Application::APP_ID, 'max_tokens', strval(Application::DEFAULT_MAX_NUM_OF_TOKENS), lazy: true)) ?: Application::DEFAULT_MAX_NUM_OF_TOKENS;
- }
-
- /**
- * @return string
- */
- public function getLlmExtraParams(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'llm_extra_params', lazy: true);
- }
-
/**
* @return array
*/
@@ -324,1219 +131,145 @@ public function getQuotaPeriod(): array {
return $value;
}
- /**
- * @return int[]
- */
- public function getQuotas(): array {
- $quotas = json_decode(
- $this->appConfig->getValueString(
- Application::APP_ID, 'quotas',
- json_encode(Application::DEFAULT_QUOTAS),
- lazy: true,
- ) ?: json_encode(Application::DEFAULT_QUOTAS),
- true,
- );
- if ($quotas === null) {
- $quotas = Application::DEFAULT_QUOTAS;
- }
- // Make sure all quota types are set in the json encoded app value (in case new quota types are added in the future)
- if (count($quotas) !== count(Application::DEFAULT_QUOTAS)) {
- foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) {
- if (!isset($quotas[$quotaType]) || !is_int($quotas[$quotaType]) || $quotas[$quotaType] < 0) {
- $quotas[$quotaType] = Application::DEFAULT_QUOTAS[$quotaType];
- }
- }
- $this->appConfig->setValueString(Application::APP_ID, 'quotas', json_encode($quotas), lazy: true);
- }
-
- return $quotas;
- }
-
- public function getUsageStorageTime() : int {
+ public function getUsageStorageTime(): int {
return $this->appConfig->getValueInt(Application::APP_ID, 'usage_storage_time', Application::DEFAULT_QUOTA_PERIOD, lazy: true);
}
- /**
- * @return boolean
- */
- public function getChatEndpointEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'chat_endpoint_enabled', '1', lazy: true) === '1';
- }
-
- /**
- * @param string|null $userId
- * @param bool $fallBackOnAdminValue
- * @return string
- */
- public function getUserBasicUser(?string $userId, bool $fallBackOnAdminValue = true): string {
- $fallBackBasicUser = $fallBackOnAdminValue ? $this->getAdminBasicUser() : '';
- $basicUser = $userId === null
- ? $fallBackBasicUser
- : ($this->config->getUserValue($userId, Application::APP_ID, 'basic_user', $fallBackBasicUser) ?: $fallBackBasicUser);
- return $basicUser;
- }
-
/**
* @param string|null $userId
- * @param bool $fallBackOnAdminValue
- * @return string
- * @throws Exception
- */
- public function getUserBasicPassword(?string $userId, bool $fallBackOnAdminValue = true): string {
- $fallBackBasicPassword = $fallBackOnAdminValue ? $this->getAdminBasicPassword() : '';
- if ($userId === null) {
- return $fallBackBasicPassword;
- }
- $encryptedUserBasicPassword = $this->config->getUserValue($userId, Application::APP_ID, 'basic_password');
- $userBasicPassword = $encryptedUserBasicPassword === '' ? '' : $this->crypto->decrypt($encryptedUserBasicPassword);
- return $userBasicPassword ?: $fallBackBasicPassword;
- }
-
- /**
- * Get admin basic user
- * @return string
- */
- public function getAdminBasicUser(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'basic_user', lazy: true);
- }
-
- /**
- * Get admin basic password
- * @return string
- * @throws Exception
- */
- public function getAdminBasicPassword(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'basic_password', lazy: true);
- }
-
- /**
- * @return boolean
- */
- public function getUseBasicAuth(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'use_basic_auth', '0', lazy: true) === '1';
- }
-
- /**
- * @return string
- */
- public function getImageServiceUrl(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_url', '', lazy: true);
- }
-
- /**
- * @return string
- */
- public function getImageServiceName(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_service_name', '', lazy: true);
- }
-
- /**
- * @return string
- */
- public function getAdminImageApiKey(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_api_key', '', true);
- }
-
- /**
- * @return string
- */
- public function getAdminImageBasicUser(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_basic_user', '', lazy: true);
- }
-
- /**
* @return string
*/
- public function getAdminImageBasicPassword(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_basic_password', '', true);
- }
-
- /**
- * @return bool
- */
- public function getAdminImageUseBasicAuth(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'image_use_basic_auth', '0', lazy: true) === '1';
+ public function getUserSTTLanguage(?string $userId): string {
+ return $this->config->getUserValue($userId, Application::APP_ID, 'stt_language', 'detect_language');
}
/**
- * @return int
+ * Get the instance-wide admin config for the settings page
+ *
+ * @return array{quota_period: array, usage_storage_time: int}
*/
- public function getImageRequestTimeout(): int {
- return intval($this->appConfig->getValueString(Application::APP_ID, 'image_request_timeout', strval(Application::OPENAI_DEFAULT_REQUEST_TIMEOUT), lazy: true)) ?: Application::OPENAI_DEFAULT_REQUEST_TIMEOUT;
+ public function getAdminConfig(): array {
+ return [
+ 'quota_period' => $this->getQuotaPeriod(),
+ 'usage_storage_time' => $this->getUsageStorageTime(),
+ ];
}
/**
- * @return string
+ * Get the user config for the settings page
+ *
+ * @return array{stt_language: string}
*/
- public function getSttServiceUrl(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_url', '', lazy: true);
+ public function getUserConfig(string $userId): array {
+ return [
+ 'stt_language' => $this->getUserSTTLanguage($userId),
+ ];
}
- /**
- * @return string
- */
- public function getSttServiceName(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_service_name', '', lazy: true);
- }
+ ////////////////////////////////////////////
+ //////////// Setters for settings //////////
/**
- * @return string
+ * Setter for quotaPeriod; minimum is 1 day.
+ * Days are floating, and months are set dates
+ * @param array $quotaPeriod
+ * @return void
+ * @throws Exception
*/
- public function getAdminSttApiKey(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_api_key', '', true);
+ public function setQuotaPeriod(array $quotaPeriod): void {
+ if (!isset($quotaPeriod['length']) || !is_int($quotaPeriod['length'])) {
+ throw new Exception('Invalid quota period length');
+ }
+ if ($quotaPeriod['length'] < 1) {
+ throw new Exception('Invalid quota period 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');
+ }
+ if ($quotaPeriod['day'] < 1) {
+ throw new Exception('Invalid quota period day');
+ }
+ if ($quotaPeriod['day'] > 28) {
+ throw new Exception('Invalid quota period day');
+ }
+ } elseif ($quotaPeriod['unit'] !== 'day') {
+ throw new Exception('Invalid quota period unit');
+ }
+ $maxLength = $quotaPeriod['unit'] === 'month'
+ ? Application::MAX_QUOTA_PERIOD_MONTHS
+ : Application::MAX_QUOTA_PERIOD_DAYS;
+ if ($quotaPeriod['length'] > $maxLength) {
+ throw new Exception('Invalid quota period length');
+ }
+ $this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod), lazy: true);
}
/**
- * @return string
+ * @param int $usageStorageTime
+ * @return void
*/
- public function getAdminSttBasicUser(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_basic_user', '', lazy: true);
+ public function setUsageStorageTime(int $usageStorageTime): void {
+ $usageStorageTime = max(1, $usageStorageTime);
+ $this->appConfig->setValueInt(Application::APP_ID, 'usage_storage_time', $usageStorageTime, lazy: true);
}
/**
- * @return string
+ * @param string $userId
+ * @param string $language
+ * @throws PreConditionNotMetException
*/
- public function getAdminSttBasicPassword(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_basic_password', '', true);
+ public function setUserSTTLanguage(string $userId, string $language): void {
+ $this->config->setUserValue($userId, Application::APP_ID, 'stt_language', $language);
}
/**
- * @return bool
+ * Set the instance-wide admin config
+ *
+ * @param array $adminConfig
+ * @throws Exception
*/
- public function getAdminSttUseBasicAuth(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_use_basic_auth', '0', lazy: true) === '1';
- }
+ public function setAdminConfig(array $adminConfig): void {
+ foreach ($adminConfig as $key => $value) {
+ if (!isset(self::ADMIN_CONFIG_TYPES[$key])) {
+ throw new Exception('Invalid config key: ' . $key);
+ }
+ if (gettype($value) !== self::ADMIN_CONFIG_TYPES[$key]) {
+ throw new Exception('Invalid type for key: ' . $key . '. Expected ' . self::ADMIN_CONFIG_TYPES[$key] . ', got ' . gettype($value));
+ }
+ }
- /**
- * @return int
- */
- public function getSttRequestTimeout(): int {
- return intval($this->appConfig->getValueString(Application::APP_ID, 'stt_request_timeout', strval(Application::OPENAI_DEFAULT_REQUEST_TIMEOUT), lazy: true)) ?: Application::OPENAI_DEFAULT_REQUEST_TIMEOUT;
+ // Validation of the input values is done in the individual setters
+ if (isset($adminConfig['quota_period'])) {
+ $this->setQuotaPeriod($adminConfig['quota_period']);
+ }
+ if (isset($adminConfig['usage_storage_time'])) {
+ $this->setUsageStorageTime($adminConfig['usage_storage_time']);
+ }
}
/**
- * @return string
+ * Set the user config for the settings page
+ *
+ * @param array $userConfig
+ * @throws Exception
*/
- public function getTtsServiceUrl(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_url', '', lazy: true);
- }
+ public function setUserConfig(string $userId, array $userConfig): void {
+ foreach ($userConfig as $key => $value) {
+ if (!isset(self::USER_CONFIG_TYPES[$key])) {
+ throw new Exception('Invalid config key: ' . $key);
+ }
+ if (gettype($value) !== self::USER_CONFIG_TYPES[$key]) {
+ throw new Exception('Invalid type for key: ' . $key . '. Expected ' . self::USER_CONFIG_TYPES[$key] . ', got ' . gettype($value));
+ }
+ }
- /**
- * @return string
- */
- public function getTtsServiceName(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_service_name', '', lazy: true);
- }
-
- /**
- * @return string
- */
- public function getAdminTtsApiKey(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_api_key', '', true);
- }
-
- /**
- * @return string
- */
- public function getAdminTtsBasicUser(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_basic_user', '', lazy: true);
- }
-
- /**
- * @return string
- */
- public function getAdminTtsBasicPassword(): string {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_basic_password', '', true);
- }
-
- /**
- * @return bool
- */
- public function getAdminTtsUseBasicAuth(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_use_basic_auth', '0', lazy: true) === '1';
- }
-
- /**
- * @return int
- */
- public function getTtsRequestTimeout(): int {
- return intval($this->appConfig->getValueString(Application::APP_ID, 'tts_request_timeout', strval(Application::OPENAI_DEFAULT_REQUEST_TIMEOUT), lazy: true)) ?: Application::OPENAI_DEFAULT_REQUEST_TIMEOUT;
- }
-
- /**
- * Get the admin config for the settings page
- * @return mixed[]
- */
- public function getAdminConfig(): array {
- return [
- 'request_timeout' => $this->getRequestTimeout(),
- 'url' => $this->getServiceUrl(),
- 'service_name' => $this->getServiceName(),
- 'api_key' => $this->getAdminApiKey(),
- 'default_completion_model_id' => $this->getAdminDefaultCompletionModelId(),
- 'default_stt_model_id' => $this->getAdminDefaultSttModelId(),
- 'default_tts_model_id' => $this->getAdminDefaultTtsModelId(),
- 'default_tts_voice' => $this->getAdminDefaultTtsVoice(),
- 'tts_voices' => $this->getAdminTtsVoices(),
- 'default_image_model_id' => $this->getAdminDefaultImageModelId(),
- 'default_image_size' => $this->getAdminDefaultImageSize(),
- 'image_request_auth' => $this->getIsImageRetrievalAuthenticated(),
- 'chunk_size' => strval($this->getChunkSize()),
- 'max_tokens' => $this->getMaxTokens(),
- 'use_max_completion_tokens_param' => $this->getUseMaxCompletionTokensParam(),
- 'llm_extra_params' => $this->getLlmExtraParams(),
- // Updated to get max tokens
- 'quota_period' => $this->getQuotaPeriod(),
- // 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(),
- 'stt_provider_enabled' => $this->getSttProviderEnabled(),
- 'tts_provider_enabled' => $this->getTtsProviderEnabled(),
- 'multimodal_image_enabled' => $this->getMultimodalImageEnabled(),
- 'multimodal_audio_enabled' => $this->getMultimodalAudioEnabled(),
- 'multimodal_video_enabled' => $this->getMultimodalVideoEnabled(),
- 'multimodal_document_enabled' => $this->getMultimodalDocumentEnabled(),
- 'chat_endpoint_enabled' => $this->getChatEndpointEnabled(),
- 'basic_user' => $this->getAdminBasicUser(),
- 'basic_password' => $this->getAdminBasicPassword(),
- 'use_basic_auth' => $this->getUseBasicAuth(),
- // Get the service details for image, stt and tts
- 'image_url' => $this->getImageServiceUrl(),
- 'image_service_name' => $this->getImageServiceName(),
- 'image_api_key' => $this->getAdminImageApiKey(),
- 'image_basic_user' => $this->getAdminImageBasicUser(),
- 'image_basic_password' => $this->getAdminImageBasicPassword(),
- 'image_use_basic_auth' => $this->getAdminImageUseBasicAuth(),
- 'image_request_timeout' => $this->getImageRequestTimeout(),
-
- 'stt_url' => $this->getSttServiceUrl(),
- 'stt_service_name' => $this->getSttServiceName(),
- 'stt_api_key' => $this->getAdminSttApiKey(),
- 'stt_basic_user' => $this->getAdminSttBasicUser(),
- 'stt_basic_password' => $this->getAdminSttBasicPassword(),
- 'stt_use_basic_auth' => $this->getAdminSttUseBasicAuth(),
- 'stt_request_timeout' => $this->getSttRequestTimeout(),
-
- 'tts_url' => $this->getTtsServiceUrl(),
- 'tts_service_name' => $this->getTtsServiceName(),
- 'tts_api_key' => $this->getAdminTtsApiKey(),
- 'tts_basic_user' => $this->getAdminTtsBasicUser(),
- 'tts_basic_password' => $this->getAdminTtsBasicPassword(),
- 'tts_use_basic_auth' => $this->getAdminTtsUseBasicAuth(),
- 'tts_request_timeout' => $this->getTtsRequestTimeout(),
- ];
- }
-
- /**
- * Get the user config for the settings page
- * @return array{api_key: string, basic_password: string, basic_user: string, is_custom_service: bool, use_basic_auth: bool, stt_language: string}
- */
- public function getUserConfig(string $userId): array {
- $isCustomService = $this->getServiceUrl() !== '' && $this->getServiceUrl() !== Application::OPENAI_API_BASE_URL;
- return [
- 'api_key' => $this->getUserApiKey($userId),
- 'basic_user' => $this->getUserBasicUser($userId, false),
- 'basic_password' => $this->getUserBasicPassword($userId, false),
- 'use_basic_auth' => $this->getUseBasicAuth(),
- 'is_custom_service' => $isCustomService,
- 'stt_language' => $this->getUserSTTLanguage($userId)
- ];
- }
-
- /**
- * @return bool
- */
- public function getUseMaxCompletionTokensParam(): bool {
- $serviceUrl = $this->getServiceUrl();
- $isUsingOpenAI = $serviceUrl === '' || $serviceUrl === Application::OPENAI_API_BASE_URL;
- // we know OpenAI expects "use_max_completion_tokens_param", let's assume the other services don't
- $default = $isUsingOpenAI ? '1' : '0';
- return $this->appConfig->getValueString(Application::APP_ID, 'use_max_completion_tokens_param', $default, lazy: true) === '1';
- }
-
- /**
- * @return bool
- */
- public function getTranslationProviderEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'translation_provider_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getIsImageRetrievalAuthenticated(): bool {
- $serviceUrl = $this->getServiceUrl();
- // the image_request_auth default depends on the service used for image generation
- // if we override it, we check the one we are really gonna use
- if ($this->imageOverrideEnabled()) {
- $serviceUrl = $this->getImageServiceUrl();
- }
- $isUsingOpenAI = $serviceUrl === '' || $serviceUrl === Application::OPENAI_API_BASE_URL;
- $default = $isUsingOpenAI ? '0' : '1';
- return $this->appConfig->getValueString(Application::APP_ID, 'image_request_auth', $default, lazy: true) === '1';
- }
-
- /**
- * @return bool
- */
- public function getLlmProviderEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'llm_provider_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getT2iProviderEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 't2i_provider_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getSttProviderEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'stt_provider_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getTtsProviderEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'tts_provider_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getMultimodalImageEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'multimodal_image_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getMultimodalAudioEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'multimodal_audio_enabled', '1') === '1';
- }
-
- /**
- * @return bool
- */
- public function getMultimodalVideoEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'multimodal_video_enabled', '0') === '1';
- }
-
- /**
- * @return bool
- */
- public function getMultimodalDocumentEnabled(): bool {
- return $this->appConfig->getValueString(Application::APP_ID, 'multimodal_document_enabled', '1') === '1';
- }
-
- ////////////////////////////////////////////
- //////////// Setters for settings //////////
-
- /**
- * @param int[] $quotas
- * @return void
- * @throws Exception
- */
- public function setQuotas(array $quotas): void {
- // Validate input
- if (count($quotas) !== count(Application::DEFAULT_QUOTAS)) {
- throw new Exception('Invalid number of quotas');
- }
-
- foreach ($quotas as $quotaType => $quota) {
- if (!isset(Application::DEFAULT_QUOTAS[$quotaType])) {
- throw new Exception('Invalid quota type(s)');
- }
-
- if (!is_int($quota) || $quota < 0) {
- throw new Exception('Invalid quota value');
- }
- }
-
- $this->appConfig->setValueString(Application::APP_ID, 'quotas', json_encode($quotas, JSON_THROW_ON_ERROR), lazy: true);
- $cache = $this->cacheFactory->createDistributed(Application::APP_ID);
- $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
- */
- public function setAdminApiKey(string $apiKey): void {
- // No need to validate. As long as it's a string, we're happy campers
- $this->appConfig->setValueString(Application::APP_ID, 'api_key', $apiKey, true, true);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $userId
- * @param string $apiKey
- * @throws PreConditionNotMetException
- */
- public function setUserApiKey(string $userId, string $apiKey): void {
- // No need to validate. As long as it's a string, we're happy campers
- if ($apiKey === '') {
- $this->config->setUserValue($userId, Application::APP_ID, 'api_key', '');
- } else {
- $encryptedApiKey = $this->crypto->encrypt($apiKey);
- $this->config->setUserValue($userId, Application::APP_ID, 'api_key', $encryptedApiKey);
- }
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $userId
- * @param string $language
- * @throws PreConditionNotMetException
- */
- public function setUserSTTLanguage(string $userId, string $language): void {
- $this->config->setUserValue($userId, Application::APP_ID, 'stt_language', $language);
- }
-
- /**
- * @param string $defaultCompletionModelId
- * @return void
- */
- public function setAdminDefaultCompletionModelId(string $defaultCompletionModelId): void {
- // No need to validate. As long as it's a string, we're happy campers
- $this->appConfig->setValueString(Application::APP_ID, 'default_completion_model_id', $defaultCompletionModelId, lazy: true);
- }
-
- /**
- * @param string $defaultSttModelId
- * @return void
- */
- public function setAdminDefaultSttModelId(string $defaultSttModelId): void {
- // No need to validate. As long as it's a string, we're happy campers
- $this->appConfig->setValueString(Application::APP_ID, 'default_stt_model_id', $defaultSttModelId, lazy: true);
- }
-
- /**
- * @param string $defaultTtsModelId
- * @return void
- */
- public function setAdminDefaultTtsModelId(string $defaultTtsModelId): void {
- // No need to validate. As long as it's a string, we're happy campers
- $this->appConfig->setValueString(Application::APP_ID, 'default_speech_model_id', $defaultTtsModelId, lazy: true);
- }
-
- /**
- * @param string $defaultImageModelId
- * @return void
- */
- public function setAdminDefaultImageModelId(string $defaultImageModelId): void {
- // No need to validate. As long as it's a string, we're happy campers
- $this->appConfig->setValueString(Application::APP_ID, 'default_image_model_id', $defaultImageModelId, lazy: true);
- }
-
- /**
- * @param string $voice
- * @return void
- */
- public function setAdminDefaultTtsVoice(string $voice): void {
- $this->appConfig->setValueString(Application::APP_ID, 'default_speech_voice', $voice, lazy: true);
- }
-
- /**
- * @param string $defaultImageSize
- * @return void
- * @throws Exception
- */
- public function setAdminDefaultImageSize(string $defaultImageSize): void {
- if ($defaultImageSize !== '' && preg_match('/^\d+x\d+$/', $defaultImageSize) !== 1) {
- throw new Exception('Invalid image size value');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'default_image_size', $defaultImageSize, lazy: true);
- }
-
- /**
- * @param string $serviceUrl
- * @return void
- * @throws Exception
- */
- public function setServiceUrl(string $serviceUrl): void {
- // Validate input:
- if (!filter_var($serviceUrl, FILTER_VALIDATE_URL) && $serviceUrl !== '') {
- throw new Exception('Invalid service URL');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'url', $serviceUrl);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $serviceName
- * @return void
- * @throws Exception
- */
- public function setServiceName(string $serviceName): void {
- $this->appConfig->setValueString(Application::APP_ID, 'service_name', $serviceName, lazy: true);
- }
-
- /**
- * @param int $requestTimeout
- * @return void
- */
- public function setRequestTimeout(int $requestTimeout): void {
- // Validate input:
- $requestTimeout = max(1, $requestTimeout);
- $this->appConfig->setValueString(Application::APP_ID, 'request_timeout', strval($requestTimeout), lazy: true);
- }
-
- /**
- * Setter for chunkSize; default/minimum is 0 (no chunking)
- * @param int $chunkSize
- * @return void
- */
- public function setChunkSize(int $chunkSize): void {
- // Validate input:
- $chunkSize = max(0, $chunkSize);
- if ($chunkSize) {
- $chunkSize = max(Application::MIN_CHUNK_SIZE, $chunkSize);
- }
- $this->appConfig->setValueInt(Application::APP_ID, 'chunk_size', $chunkSize, lazy: true);
- }
-
- /**
- * Setter for maxTokens; minimum is 100
- * @param int $maxTokens
- * @return void
- */
- public function setMaxTokens(int $maxTokens): void {
- // Validate input:
- $maxTokens = max(100, $maxTokens);
- $this->appConfig->setValueString(Application::APP_ID, 'max_tokens', strval($maxTokens), lazy: true);
- }
-
- public function setLlmExtraParams(string $llmExtraParams): void {
- if ($llmExtraParams !== '') {
- $paramsArray = json_decode($llmExtraParams, true);
- if (!is_array($paramsArray)) {
- throw new Exception('Invalid model extra parameters, must be a valid JSON object string or an empty string');
- }
- }
- $this->appConfig->setValueString(Application::APP_ID, 'llm_extra_params', $llmExtraParams, lazy: true);
- }
-
- /**
- * 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(array $quotaPeriod): void {
- if (!isset($quotaPeriod['length']) || !is_int($quotaPeriod['length'])) {
- throw new Exception('Invalid quota period length');
- }
- if ($quotaPeriod['length'] < 1) {
- throw new Exception('Invalid quota period 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');
- }
- if ($quotaPeriod['day'] < 1) {
- throw new Exception('Invalid quota period day');
- }
- if ($quotaPeriod['day'] > 28) {
- throw new Exception('Invalid quota period day');
- }
- } elseif ($quotaPeriod['unit'] !== 'day') {
- throw new Exception('Invalid quota period unit');
- }
- $maxLength = $quotaPeriod['unit'] === 'month'
- ? Application::MAX_QUOTA_PERIOD_MONTHS
- : Application::MAX_QUOTA_PERIOD_DAYS;
- if ($quotaPeriod['length'] > $maxLength) {
- throw new Exception('Invalid quota period length');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod), lazy: true);
- }
-
- /**
- * @param string $basicUser
- * @return void
- */
- public function setAdminBasicUser(string $basicUser): void {
- $this->appConfig->setValueString(Application::APP_ID, 'basic_user', $basicUser, lazy: true);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $basicPassword
- * @return void
- */
- public function setAdminBasicPassword(string $basicPassword): void {
- $this->appConfig->setValueString(Application::APP_ID, 'basic_password', $basicPassword, true, true);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $userId
- * @param string $basicUser
- * @return void
- * @throws PreConditionNotMetException
- */
- public function setUserBasicUser(string $userId, string $basicUser): void {
- $this->config->setUserValue($userId, Application::APP_ID, 'basic_user', $basicUser);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $userId
- * @param string $basicPassword
- * @return void
- * @throws PreConditionNotMetException
- */
- public function setUserBasicPassword(string $userId, string $basicPassword): void {
- $encryptedBasicPassword = $basicPassword === '' ? '' : $this->crypto->encrypt($basicPassword);
- $this->config->setUserValue($userId, Application::APP_ID, 'basic_password', $encryptedBasicPassword);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param bool $useBasicAuth
- * @return void
- */
- public function setUseBasicAuth(bool $useBasicAuth): void {
- $this->appConfig->setValueString(Application::APP_ID, 'use_basic_auth', $useBasicAuth ? '1' : '0', lazy: true);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param array $voices
- * @return void
- */
- public function setAdminTtsVoices(array $voices): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_voices', json_encode($voices), lazy: true);
- $this->invalidateModelsCache();
- }
-
- /**
- * @param string $url
- * @return void
- * @throws Exception
- */
- public function setImageServiceUrl(string $url): void {
- if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) {
- throw new Exception('Invalid image service URL');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'image_url', $url, lazy: true);
- }
-
- /**
- * @param string $name
- * @return void
- */
- public function setImageServiceName(string $name): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_service_name', $name, lazy: true);
- }
-
- /**
- * @param string $apiKey
- * @return void
- */
- public function setAdminImageApiKey(string $apiKey): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_api_key', $apiKey, true, true);
- }
-
- /**
- * @param string $user
- * @return void
- */
- public function setAdminImageBasicUser(string $user): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_basic_user', $user, lazy: true);
- }
-
- /**
- * @param string $password
- * @return void
- */
- public function setAdminImageBasicPassword(string $password): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_basic_password', $password, true, true);
- }
-
- /**
- * @param bool $use
- * @return void
- */
- public function setAdminImageUseBasicAuth(bool $use): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_use_basic_auth', $use ? '1' : '0', lazy: true);
- }
-
- /**
- * @param int $requestTimeout
- * @return void
- */
- public function setImageRequestTimeout(int $requestTimeout): void {
- // Validate input:
- $requestTimeout = max(1, $requestTimeout);
- $this->appConfig->setValueString(Application::APP_ID, 'image_request_timeout', strval($requestTimeout), lazy: true);
- }
- /**
- * @param string $url
- * @return void
- * @throws Exception
- */
- public function setSttServiceUrl(string $url): void {
- if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) {
- throw new Exception('Invalid STT service URL');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'stt_url', $url, lazy: true);
- }
-
- /**
- * @param string $name
- * @return void
- */
- public function setSttServiceName(string $name): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_service_name', $name, lazy: true);
- }
-
- /**
- * @param string $apiKey
- * @return void
- */
- public function setAdminSttApiKey(string $apiKey): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_api_key', $apiKey, true, true);
- }
-
- /**
- * @param string $user
- * @return void
- */
- public function setAdminSttBasicUser(string $user): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_basic_user', $user, lazy: true);
- }
-
- /**
- * @param string $password
- * @return void
- */
- public function setAdminSttBasicPassword(string $password): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_basic_password', $password, true, true);
- }
-
- /**
- * @param bool $use
- * @return void
- */
- public function setAdminSttUseBasicAuth(bool $use): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_use_basic_auth', $use ? '1' : '0', lazy: true);
- }
-
- /**
- * @param int $requestTimeout
- * @return void
- */
- public function setSttRequestTimeout(int $requestTimeout): void {
- // Validate input:
- $requestTimeout = max(1, $requestTimeout);
- $this->appConfig->setValueString(Application::APP_ID, 'stt_request_timeout', strval($requestTimeout), lazy: true);
- }
-
- /**
- * @param string $url
- * @return void
- * @throws Exception
- */
- public function setTtsServiceUrl(string $url): void {
- if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) {
- throw new Exception('Invalid TTS service URL');
- }
- $this->appConfig->setValueString(Application::APP_ID, 'tts_url', $url, lazy: true);
- }
-
- /**
- * @param string $name
- * @return void
- */
- public function setTtsServiceName(string $name): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_service_name', $name, lazy: true);
- }
-
- /**
- * @param string $apiKey
- * @return void
- */
- public function setAdminTtsApiKey(string $apiKey): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_api_key', $apiKey, true, true);
- }
-
- /**
- * @param string $user
- * @return void
- */
- public function setAdminTtsBasicUser(string $user): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_basic_user', $user, lazy: true);
- }
-
- /**
- * @param string $password
- * @return void
- */
- public function setAdminTtsBasicPassword(string $password): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_basic_password', $password, true, true);
- }
-
- /**
- * @param bool $use
- * @return void
- */
- public function setAdminTtsUseBasicAuth(bool $use): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_use_basic_auth', $use ? '1' : '0', lazy: true);
- }
-
- /**
- * @param int $requestTimeout
- * @return void
- */
- public function setTtsRequestTimeout(int $requestTimeout): void {
- // Validate input:
- $requestTimeout = max(1, $requestTimeout);
- $this->appConfig->setValueString(Application::APP_ID, 'tts_request_timeout', strval($requestTimeout), lazy: true);
- }
-
- /**
- * Set the admin config for the settings page
- * @param mixed[] $adminConfig
- * @return void
- * @throws Exception
- */
- public function setAdminConfig(array $adminConfig): void {
- // That the variable types are correct
- foreach (array_keys($adminConfig) as $key) {
- $value = $adminConfig[$key];
- if ($value === null) {
- $this->config->deleteAppValue(Application::APP_ID, $key);
- } elseif (gettype($value) !== self::ADMIN_CONFIG_TYPES[$key]) {
- throw new Exception('Invalid type for key: ' . $key . '. Expected ' . self::ADMIN_CONFIG_TYPES[$key] . ', got ' . gettype($value));
- }
- }
-
- // Validation of the input values is done in the individual setters
- if (isset($adminConfig['request_timeout'])) {
- $this->setRequestTimeout($adminConfig['request_timeout']);
- }
- if (isset($adminConfig['url'])) {
- $this->setServiceUrl(rtrim($adminConfig['url'], ' /'));
- }
- if (isset($adminConfig['service_name'])) {
- $this->setServiceName($adminConfig['service_name']);
- }
- if (isset($adminConfig['api_key'])) {
- $this->setAdminApiKey($adminConfig['api_key']);
- }
- if (isset($adminConfig['default_completion_model_id'])) {
- $this->setAdminDefaultCompletionModelId($adminConfig['default_completion_model_id']);
- }
- if (isset($adminConfig['default_stt_model_id'])) {
- $this->setAdminDefaultSttModelId($adminConfig['default_stt_model_id']);
- }
- if (isset($adminConfig['default_tts_model_id'])) {
- $this->setAdminDefaultTtsModelId($adminConfig['default_tts_model_id']);
- }
- if (isset($adminConfig['default_image_model_id'])) {
- $this->setAdminDefaultImageModelId($adminConfig['default_image_model_id']);
- }
- if (isset($adminConfig['default_image_size'])) {
- $this->setAdminDefaultImageSize($adminConfig['default_image_size']);
- }
- if (isset($adminConfig['image_request_auth'])) {
- $this->setIsImageRetrievalAuthenticated($adminConfig['image_request_auth']);
- }
- if (isset($adminConfig['chunk_size'])) {
- $this->setChunkSize(intval($adminConfig['chunk_size']));
- }
- if (isset($adminConfig['max_tokens'])) {
- $this->setMaxTokens($adminConfig['max_tokens']);
- }
- if (isset($adminConfig['llm_extra_params'])) {
- $this->setLlmExtraParams($adminConfig['llm_extra_params']);
- }
- if (isset($adminConfig['quota_period'])) {
- $this->setQuotaPeriod($adminConfig['quota_period']);
- }
- 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']);
- }
- if (isset($adminConfig['translation_provider_enabled'])) {
- $this->setTranslationProviderEnabled($adminConfig['translation_provider_enabled']);
- }
- if (isset($adminConfig['llm_provider_enabled'])) {
- $this->setLlmProviderEnabled($adminConfig['llm_provider_enabled']);
- }
- if (isset($adminConfig['t2i_provider_enabled'])) {
- $this->setT2iProviderEnabled($adminConfig['t2i_provider_enabled']);
- }
- if (isset($adminConfig['stt_provider_enabled'])) {
- $this->setSttProviderEnabled($adminConfig['stt_provider_enabled']);
- }
- if (isset($adminConfig['tts_provider_enabled'])) {
- $this->setTtsProviderEnabled($adminConfig['tts_provider_enabled']);
- }
- if (isset($adminConfig['default_tts_voice'])) {
- $this->setAdminDefaultTtsVoice($adminConfig['default_tts_voice']);
- }
- if (isset($adminConfig['multimodal_image_enabled'])) {
- $this->setMultimodalImageEnabled($adminConfig['multimodal_image_enabled']);
- }
- if (isset($adminConfig['multimodal_audio_enabled'])) {
- $this->setMultimodalAudioEnabled($adminConfig['multimodal_audio_enabled']);
- }
- if (isset($adminConfig['multimodal_video_enabled'])) {
- $this->setMultimodalVideoEnabled($adminConfig['multimodal_video_enabled']);
- }
- if (isset($adminConfig['multimodal_document_enabled'])) {
- $this->setMultimodalDocumentEnabled($adminConfig['multimodal_document_enabled']);
- }
- if (isset($adminConfig['chat_endpoint_enabled'])) {
- $this->setChatEndpointEnabled($adminConfig['chat_endpoint_enabled']);
- }
- if (isset($adminConfig['basic_user'])) {
- $this->setAdminBasicUser($adminConfig['basic_user']);
- }
- if (isset($adminConfig['basic_password'])) {
- $this->setAdminBasicPassword($adminConfig['basic_password']);
- }
- if (isset($adminConfig['use_basic_auth'])) {
- $this->setUseBasicAuth($adminConfig['use_basic_auth']);
- }
- if (isset($adminConfig['tts_voices'])) {
- $this->setAdminTtsVoices($adminConfig['tts_voices']);
- }
-
- if (isset($adminConfig['image_url'])) {
- $this->setImageServiceUrl(rtrim($adminConfig['image_url'], ' /'));
- }
- if (isset($adminConfig['image_service_name'])) {
- $this->setImageServiceName($adminConfig['image_service_name']);
- }
- if (isset($adminConfig['image_api_key'])) {
- $this->setAdminImageApiKey($adminConfig['image_api_key']);
- }
- if (isset($adminConfig['image_basic_user'])) {
- $this->setAdminImageBasicUser($adminConfig['image_basic_user']);
- }
- if (isset($adminConfig['image_basic_password'])) {
- $this->setAdminImageBasicPassword($adminConfig['image_basic_password']);
- }
- if (isset($adminConfig['image_use_basic_auth'])) {
- $this->setAdminImageUseBasicAuth($adminConfig['image_use_basic_auth']);
- }
- if (isset($adminConfig['image_request_timeout'])) {
- $this->setImageRequestTimeout($adminConfig['image_request_timeout']);
- }
-
- if (isset($adminConfig['stt_url'])) {
- $this->setSttServiceUrl(rtrim($adminConfig['stt_url'], ' /'));
- }
- if (isset($adminConfig['stt_service_name'])) {
- $this->setSttServiceName($adminConfig['stt_service_name']);
- }
- if (isset($adminConfig['stt_api_key'])) {
- $this->setAdminSttApiKey($adminConfig['stt_api_key']);
- }
- if (isset($adminConfig['stt_basic_user'])) {
- $this->setAdminSttBasicUser($adminConfig['stt_basic_user']);
- }
- if (isset($adminConfig['stt_basic_password'])) {
- $this->setAdminSttBasicPassword($adminConfig['stt_basic_password']);
- }
- if (isset($adminConfig['stt_use_basic_auth'])) {
- $this->setAdminSttUseBasicAuth($adminConfig['stt_use_basic_auth']);
- }
- if (isset($adminConfig['stt_request_timeout'])) {
- $this->setSttRequestTimeout($adminConfig['stt_request_timeout']);
- }
-
- if (isset($adminConfig['tts_url'])) {
- $this->setTtsServiceUrl(rtrim($adminConfig['tts_url'], ' /'));
- }
- if (isset($adminConfig['tts_service_name'])) {
- $this->setTtsServiceName($adminConfig['tts_service_name']);
- }
- if (isset($adminConfig['tts_api_key'])) {
- $this->setAdminTtsApiKey($adminConfig['tts_api_key']);
- }
- if (isset($adminConfig['tts_basic_user'])) {
- $this->setAdminTtsBasicUser($adminConfig['tts_basic_user']);
- }
- if (isset($adminConfig['tts_basic_password'])) {
- $this->setAdminTtsBasicPassword($adminConfig['tts_basic_password']);
- }
- if (isset($adminConfig['tts_use_basic_auth'])) {
- $this->setAdminTtsUseBasicAuth($adminConfig['tts_use_basic_auth']);
- }
- if (isset($adminConfig['tts_request_timeout'])) {
- $this->setTtsRequestTimeout($adminConfig['tts_request_timeout']);
- }
- }
-
- /**
- * Set the user config for the settings page
- * @param string $userId
- * @param string[] $userConfig
- * @throws Exception
- */
- public function setUserConfig(string $userId, array $userConfig): void {
- // That the variable types are correct
- foreach (array_keys($userConfig) as $key) {
- if (gettype($userConfig[$key]) !== self::USER_CONFIG_TYPES[$key]) {
- throw new Exception('Invalid type for key: ' . $key . '. Expected ' . self::USER_CONFIG_TYPES[$key] . ', got ' . gettype($userConfig[$key]));
- }
- }
-
- // Validation of the input values is done in the individual setters
- if (isset($userConfig['api_key'])) {
- $this->setUserApiKey($userId, $userConfig['api_key']);
- }
- if (isset($userConfig['basic_user'])) {
- $this->setUserBasicUser($userId, $userConfig['basic_user']);
- }
- if (isset($userConfig['basic_password'])) {
- $this->setUserBasicPassword($userId, $userConfig['basic_password']);
- }
if (isset($userConfig['stt_language'])) {
- $this->setUserSttLanguage($userId, $userConfig['stt_language']);
+ $this->setUserSTTLanguage($userId, $userConfig['stt_language']);
}
}
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setUseMaxCompletionParam(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'use_max_completion_tokens_param', $enabled ? '1' : '0', lazy: true);
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setTranslationProviderEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'translation_provider_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setIsImageRetrievalAuthenticated(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'image_request_auth', $enabled ? '1' : '0', lazy: true);
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setLlmProviderEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'llm_provider_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setT2iProviderEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 't2i_provider_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setSttProviderEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'stt_provider_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- * @return void
- */
- public function setTtsProviderEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'tts_provider_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- */
- public function setMultimodalImageEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'multimodal_image_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- */
- public function setMultimodalAudioEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'multimodal_audio_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- */
- public function setMultimodalVideoEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'multimodal_video_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- */
- public function setMultimodalDocumentEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'multimodal_document_enabled', $enabled ? '1' : '0');
- }
-
- /**
- * @param bool $enabled
- */
- public function setChatEndpointEnabled(bool $enabled): void {
- $this->appConfig->setValueString(Application::APP_ID, 'chat_endpoint_enabled', $enabled ? '1' : '0', lazy: true);
- }
-
- /**
- * @return bool
- */
- public function imageOverrideEnabled(): bool {
- return !empty($this->getImageServiceUrl());
- }
-
- /**
- * @return bool
- */
- public function sttOverrideEnabled(): bool {
- return !empty($this->getSttServiceUrl());
- }
-
- /**
- * @return bool
- */
- public function ttsOverrideEnabled(): bool {
- return !empty($this->getTtsServiceUrl());
- }
}
diff --git a/lib/Service/QuotaRuleService.php b/lib/Service/QuotaRuleService.php
index ddf813a1..258e5a38 100644
--- a/lib/Service/QuotaRuleService.php
+++ b/lib/Service/QuotaRuleService.php
@@ -25,26 +25,32 @@ class QuotaRuleService {
public function __construct(
private QuotaRuleMapper $quotaRuleMapper,
private QuotaUserMapper $quotaUserMapper,
- private OpenAiSettingsService $openAiSettingsService,
private IGroupManager $groupManager,
private ICacheFactory $cacheFactory,
private IUserManager $userManager,
private QuotaUsageMapper $quotaUsageMapper,
private IL10N $l10n,
private LoggerInterface $logger,
+ private ServicesService $servicesService,
) {
}
/**
- * Returns the quota rule for the given user
+ * Returns the quota rule of a service that applies to the given user.
+ *
+ * Quota rules belong to one service: the budget a matching rule grants is
+ * spent on that service alone. When no rule of the service matches, the
+ * quota configured on the service itself applies, which is signalled by a
+ * null 'id'.
*
* @param int $quotaType
* @param string $userId It can be an empty string
- * @return array
+ * @param ServiceConfig $service the service the request is made to
+ * @return array{amount: int, pool: bool|int, id: int|null}
*/
- public function getRule(int $quotaType, string $userId) {
+ public function getRule(int $quotaType, string $userId, ServiceConfig $service) {
$cache = $this->cacheFactory->createDistributed(Application::APP_ID);
- $cacheKey = Application::QUOTA_RULES_CACHE_PREFIX . $quotaType . '-' . $userId;
+ $cacheKey = Application::QUOTA_RULES_CACHE_PREFIX . $quotaType . '-' . $userId . '-' . $service->getId();
$rule = $cache->get($cacheKey);
if ($rule === null) {
try {
@@ -54,10 +60,10 @@ public function getRule(int $quotaType, string $userId) {
throw new DoesNotExistException('User not found: ' . $userId);
}
$groups = $this->groupManager->getUserGroupIds($user);
- $rule = $this->quotaRuleMapper->getRule($quotaType, $userId, $groups)->jsonSerialize();
+ $rule = $this->quotaRuleMapper->getRule($quotaType, $userId, $groups, $service->getId())->jsonSerialize();
} catch (DoesNotExistException|MultipleObjectsReturnedException) {
$rule = [
- 'amount' => $this->openAiSettingsService->getQuotas()[$quotaType],
+ 'amount' => $service->getQuota($quotaType),
'pool' => false,
'id' => null,
];
@@ -104,11 +110,22 @@ public function getRules(): array {
}
/**
+ * @param string|null $serviceId the service the rule applies to, the first
+ * configured one when none is given
* @return array created rule with entities
* @throws Exception
*/
- public function addRule(): array {
- $id = $this->quotaRuleMapper->addRule(0, 0, 0, 0);
+ public function addRule(?string $serviceId = null): array {
+ if ($serviceId === null) {
+ $services = $this->servicesService->getServices();
+ if ($services === []) {
+ throw new Exception('No service to add a quota rule for');
+ }
+ $serviceId = $services[0]->getId();
+ } else {
+ $this->validateService($serviceId);
+ }
+ $id = $this->quotaRuleMapper->addRule(0, 0, 0, 0, $serviceId);
$this->clearCache();
return [
'id' => $id,
@@ -116,6 +133,7 @@ public function addRule(): array {
'amount' => 0,
'priority' => 0,
'pool' => false,
+ 'service_id' => $serviceId,
'entities' => [],
];
}
@@ -128,8 +146,9 @@ public function addRule(): array {
*/
public function updateRule(int $id, array $rule): array {
$this->validateRuleBasics($rule);
+ $this->validateService($rule['service_id']);
$this->validateEntities($rule['entities']);
- $this->quotaRuleMapper->updateRule($id, $rule['type'], $rule['amount'], $rule['priority'], $rule['pool']);
+ $this->quotaRuleMapper->updateRule($id, $rule['type'], $rule['amount'], $rule['priority'], $rule['pool'], $rule['service_id']);
$this->quotaUserMapper->setUsers($id, $rule['entities']);
$rule['id'] = $id;
$this->clearCache();
@@ -168,6 +187,17 @@ private function validateRuleBasics(array $rule): void {
}
}
+ /**
+ * Validate the service a quota rule applies to
+ *
+ * @throws Exception if no such service is configured
+ */
+ private function validateService(string $serviceId): void {
+ if ($this->servicesService->getService($serviceId) === null) {
+ throw new Exception('Unknown service: ' . $serviceId);
+ }
+ }
+
/**
* Validate the entities of a quota rule
*
@@ -187,10 +217,10 @@ private function validateEntities(array $entities) {
}
}
}
- public function getQuotaUsage(int $startDate, int $endDate, int $type): array {
+ public function getQuotaUsage(int $startDate, int $endDate, int $type, ?string $serviceId = null): array {
$data = [[$this->l10n->t('Name'), $this->l10n->t('Usage')]];
- $users = $this->quotaUsageMapper->getUsersQuotaUsage($startDate, $endDate, $type);
- $pools = $this->quotaUsageMapper->getPoolsQuotaUsage($startDate, $endDate, $type);
+ $users = $this->quotaUsageMapper->getUsersQuotaUsage($startDate, $endDate, $type, $serviceId);
+ $pools = $this->quotaUsageMapper->getPoolsQuotaUsage($startDate, $endDate, $type, $serviceId);
$usersIdx = 0;
$poolsIdx = 0;
while ($usersIdx < count($users) && $poolsIdx < count($pools)) {
diff --git a/lib/Service/ServiceConfig.php b/lib/Service/ServiceConfig.php
new file mode 100644
index 00000000..7cff54e6
--- /dev/null
+++ b/lib/Service/ServiceConfig.php
@@ -0,0 +1,517 @@
+ 'string',
+ 'url' => 'string',
+ 'api_key' => 'string',
+ 'basic_user' => 'string',
+ 'basic_password' => 'string',
+ 'use_basic_auth' => 'boolean',
+ 'request_timeout' => 'integer',
+ 'chat_endpoint_enabled' => 'boolean',
+ 'use_max_completion_tokens_param' => 'boolean',
+ 'llm_extra_params' => 'string',
+ 'max_tokens' => 'integer',
+ 'chunk_size' => 'integer',
+ 'multimodal_image_enabled' => 'boolean',
+ 'multimodal_audio_enabled' => 'boolean',
+ 'multimodal_video_enabled' => 'boolean',
+ 'multimodal_document_enabled' => 'boolean',
+ 'tts_voices' => 'array',
+ 'default_tts_voice' => 'string',
+ 'default_image_size' => 'string',
+ 'image_request_auth' => 'boolean',
+ 'text_enabled' => 'boolean',
+ 'image_enabled' => 'boolean',
+ 'stt_enabled' => 'boolean',
+ 'tts_enabled' => 'boolean',
+ 'translation_enabled' => 'boolean',
+ 'text_models' => 'array',
+ 'image_models' => 'array',
+ 'stt_models' => 'array',
+ 'tts_models' => 'array',
+ 'quotas' => 'array',
+ ];
+
+ /** Properties that are stored encrypted and never sent to the frontend */
+ public const SECRET_PROPERTIES = ['api_key', 'basic_password'];
+
+ /**
+ * @param array $quotas
+ * @param list $ttsVoices
+ * @param list $textModels
+ * @param list $imageModels
+ * @param list $sttModels
+ * @param list $ttsModels
+ */
+ public function __construct(
+ private string $id,
+ private string $name = '',
+ private string $url = '',
+ private string $apiKey = '',
+ private string $basicUser = '',
+ private string $basicPassword = '',
+ private bool $useBasicAuth = false,
+ private int $requestTimeout = Application::OPENAI_DEFAULT_REQUEST_TIMEOUT,
+ private bool $chatEndpointEnabled = true,
+ private ?bool $useMaxCompletionTokensParam = null,
+ private string $llmExtraParams = '',
+ private int $maxTokens = Application::DEFAULT_MAX_NUM_OF_TOKENS,
+ private int $chunkSize = Application::DEFAULT_CHUNK_SIZE,
+ // these mirror the defaults of the single-service configuration, so
+ // that a migrated service and a newly connected one accept the same
+ // kinds of attachment
+ private bool $multimodalImageEnabled = true,
+ private bool $multimodalAudioEnabled = true,
+ private bool $multimodalVideoEnabled = false,
+ private bool $multimodalDocumentEnabled = true,
+ private array $ttsVoices = Application::DEFAULT_SPEECH_VOICES,
+ private string $defaultTtsVoice = Application::DEFAULT_SPEECH_VOICE,
+ private string $defaultImageSize = Application::DEFAULT_DEFAULT_IMAGE_SIZE,
+ private ?bool $imageRequestAuth = null,
+ private bool $textEnabled = true,
+ private bool $imageEnabled = true,
+ private bool $sttEnabled = true,
+ private bool $ttsEnabled = true,
+ private bool $translationEnabled = true,
+ private array $textModels = [],
+ private array $imageModels = [],
+ private array $sttModels = [],
+ private array $ttsModels = [],
+ private array $quotas = Application::DEFAULT_QUOTAS,
+ ) {
+ }
+
+ /**
+ * Build a service from its stored (already decrypted) representation
+ *
+ * @param array $values
+ */
+ public static function fromArray(string $id, array $values): self {
+ $service = new self($id);
+ return $service->with($values);
+ }
+
+ /**
+ * Return a copy of this service with the given properties replaced
+ *
+ * @param array $values
+ */
+ public function with(array $values): self {
+ $new = clone $this;
+ foreach ($values as $key => $value) {
+ switch ($key) {
+ case 'name': $new->name = (string)$value;
+ break;
+ case 'url': $new->url = rtrim((string)$value, '/');
+ break;
+ case 'api_key': $new->apiKey = (string)$value;
+ break;
+ case 'basic_user': $new->basicUser = (string)$value;
+ break;
+ case 'basic_password': $new->basicPassword = (string)$value;
+ break;
+ case 'use_basic_auth': $new->useBasicAuth = (bool)$value;
+ break;
+ case 'request_timeout': $new->requestTimeout = max(1, (int)$value);
+ break;
+ case 'chat_endpoint_enabled': $new->chatEndpointEnabled = (bool)$value;
+ break;
+ case 'use_max_completion_tokens_param': $new->useMaxCompletionTokensParam = $value === null ? null : (bool)$value;
+ break;
+ case 'llm_extra_params': $new->llmExtraParams = (string)$value;
+ break;
+ case 'max_tokens': $new->maxTokens = max(1, (int)$value);
+ break;
+ case 'chunk_size': $new->chunkSize = (int)$value === 0 ? 0 : max(Application::MIN_CHUNK_SIZE, (int)$value);
+ break;
+ case 'multimodal_image_enabled': $new->multimodalImageEnabled = (bool)$value;
+ break;
+ case 'multimodal_audio_enabled': $new->multimodalAudioEnabled = (bool)$value;
+ break;
+ case 'multimodal_video_enabled': $new->multimodalVideoEnabled = (bool)$value;
+ break;
+ case 'multimodal_document_enabled': $new->multimodalDocumentEnabled = (bool)$value;
+ break;
+ case 'tts_voices': $new->ttsVoices = array_values(array_map('strval', (array)$value));
+ break;
+ case 'default_tts_voice': $new->defaultTtsVoice = (string)$value;
+ break;
+ case 'default_image_size': $new->defaultImageSize = (string)$value;
+ break;
+ case 'image_request_auth': $new->imageRequestAuth = $value === null ? null : (bool)$value;
+ break;
+ case 'text_enabled': $new->textEnabled = (bool)$value;
+ break;
+ case 'image_enabled': $new->imageEnabled = (bool)$value;
+ break;
+ case 'stt_enabled': $new->sttEnabled = (bool)$value;
+ break;
+ case 'tts_enabled': $new->ttsEnabled = (bool)$value;
+ break;
+ case 'translation_enabled': $new->translationEnabled = (bool)$value;
+ break;
+ case 'text_models': $new->textModels = self::normalizeModels($value);
+ break;
+ case 'image_models': $new->imageModels = self::normalizeModels($value);
+ break;
+ case 'stt_models': $new->sttModels = self::normalizeModels($value);
+ break;
+ case 'tts_models': $new->ttsModels = self::normalizeModels($value);
+ break;
+ case 'quotas': $new->quotas = self::normalizeQuotas($value, $new->quotas);
+ break;
+ }
+ }
+ return $new;
+ }
+
+ /**
+ * @param mixed $models
+ * @return list
+ */
+ private static function normalizeModels(mixed $models): array {
+ if (!is_array($models)) {
+ return [];
+ }
+ $models = array_map('strval', $models);
+ $models = array_filter($models, static fn (string $model) => $model !== '');
+ return array_values(array_unique($models));
+ }
+
+ /**
+ * Merge the given quotas onto the ones already set, so that a partial
+ * update does not reset the quota types it does not mention. A new service
+ * passes the defaults as the base.
+ *
+ * @param mixed $quotas
+ * @param array $base
+ * @return array
+ */
+ private static function normalizeQuotas(mixed $quotas, array $base): array {
+ $normalized = $base;
+ if (!is_array($quotas)) {
+ return $normalized;
+ }
+ foreach (array_keys(Application::DEFAULT_QUOTAS) as $type) {
+ if (isset($quotas[$type])) {
+ $normalized[$type] = max(0, (int)$quotas[$type]);
+ }
+ }
+ return $normalized;
+ }
+
+ public function getId(): string {
+ return $this->id;
+ }
+
+ /**
+ * The raw configured name, which may be empty
+ */
+ public function getName(): string {
+ return $this->name;
+ }
+
+ /**
+ * The name to show to users, falling back to something recognizable
+ */
+ public function getDisplayName(): string {
+ if ($this->name !== '') {
+ return $this->name;
+ }
+ if ($this->isUsingOpenAi()) {
+ return 'OpenAI';
+ }
+ $host = parse_url($this->url, PHP_URL_HOST);
+ return is_string($host) && $host !== '' ? $host : 'LocalAI';
+ }
+
+ /**
+ * The raw configured URL, which may be empty (meaning: the OpenAI API)
+ */
+ public function getUrl(): string {
+ return $this->url;
+ }
+
+ /**
+ * The URL to actually send requests to
+ */
+ public function getRequestUrl(): string {
+ return $this->url === '' ? Application::OPENAI_API_BASE_URL : $this->url;
+ }
+
+ public function isUsingOpenAi(): bool {
+ return $this->url === '' || $this->url === Application::OPENAI_API_BASE_URL;
+ }
+
+ public function isUsingOpenRouter(): bool {
+ return str_starts_with(strtolower($this->url), 'https://openrouter.ai');
+ }
+
+ public function getApiKey(): string {
+ return $this->apiKey;
+ }
+
+ public function getBasicUser(): string {
+ return $this->basicUser;
+ }
+
+ public function getBasicPassword(): string {
+ return $this->basicPassword;
+ }
+
+ /**
+ * The raw configured switch. To decide how a request authenticates, use
+ * {@see self::usesBasicAuth()} instead.
+ */
+ public function getUseBasicAuth(): bool {
+ return $this->useBasicAuth;
+ }
+
+ /**
+ * Whether requests to this service authenticate with basic auth.
+ *
+ * The OpenAI API only accepts a bearer token, so the basic auth switch is
+ * ignored for it. This is the branch {@see OpenAiAPIService::request()}
+ * takes, and therefore what decides which credentials of a user are
+ * actually used.
+ */
+ public function usesBasicAuth(): bool {
+ return !$this->isUsingOpenAi() && $this->useBasicAuth;
+ }
+
+ public function getRequestTimeout(): int {
+ return $this->requestTimeout;
+ }
+
+ public function getChatEndpointEnabled(): bool {
+ return $this->chatEndpointEnabled;
+ }
+
+ public function getUseMaxCompletionTokensParam(): bool {
+ // we know OpenAI expects "max_completion_tokens", let's assume the other services don't
+ return $this->useMaxCompletionTokensParam ?? $this->isUsingOpenAi();
+ }
+
+ public function getLlmExtraParams(): string {
+ return $this->llmExtraParams;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getLlmExtraParamsArray(): ?array {
+ if ($this->llmExtraParams === '') {
+ return null;
+ }
+ $decoded = json_decode($this->llmExtraParams, true);
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ public function getMaxTokens(): int {
+ return $this->maxTokens;
+ }
+
+ public function getChunkSize(): int {
+ return $this->chunkSize;
+ }
+
+ public function getMultimodalImageEnabled(): bool {
+ return $this->multimodalImageEnabled;
+ }
+
+ public function getMultimodalAudioEnabled(): bool {
+ return $this->multimodalAudioEnabled;
+ }
+
+ public function getMultimodalVideoEnabled(): bool {
+ return $this->multimodalVideoEnabled;
+ }
+
+ public function getMultimodalDocumentEnabled(): bool {
+ return $this->multimodalDocumentEnabled;
+ }
+
+ /**
+ * @return list
+ */
+ public function getTtsVoices(): array {
+ return $this->ttsVoices;
+ }
+
+ public function getDefaultTtsVoice(): string {
+ return $this->defaultTtsVoice;
+ }
+
+ public function getDefaultImageSize(): string {
+ return $this->defaultImageSize;
+ }
+
+ public function getImageRequestAuth(): bool {
+ // OpenAI serves the generated images from an unauthenticated URL, other
+ // services more often than not expect the credentials
+ return $this->imageRequestAuth ?? !$this->isUsingOpenAi();
+ }
+
+ /**
+ * Whether this service offers the translation task types. Translation is
+ * part of the text modality, but has its own switch because it is often
+ * served by a dedicated instance.
+ */
+ public function getTranslationEnabled(): bool {
+ return $this->translationEnabled;
+ }
+
+ public function isModalityEnabled(string $modality): bool {
+ return match ($modality) {
+ Application::MODALITY_TEXT => $this->textEnabled,
+ Application::MODALITY_IMAGE => $this->imageEnabled,
+ Application::MODALITY_STT => $this->sttEnabled,
+ Application::MODALITY_TTS => $this->ttsEnabled,
+ default => false,
+ };
+ }
+
+ /**
+ * The models the admin selected for a modality, or an empty list when the
+ * modality is switched off for this service
+ *
+ * @return list
+ */
+ public function getModels(string $modality): array {
+ if (!$this->isModalityEnabled($modality)) {
+ return [];
+ }
+ return match ($modality) {
+ Application::MODALITY_TEXT => $this->textModels,
+ Application::MODALITY_IMAGE => $this->imageModels,
+ Application::MODALITY_STT => $this->sttModels,
+ Application::MODALITY_TTS => $this->ttsModels,
+ default => [],
+ };
+ }
+
+ /**
+ * The first model selected for a modality.
+ *
+ * Used by the providers that chain two modalities (enhanced transcription,
+ * audio translation, LLM-improved image prompts) to pick the model for the
+ * step outside their own modality.
+ */
+ public function getFirstModel(string $modality): ?string {
+ return $this->getModels($modality)[0] ?? null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getQuotas(): array {
+ return $this->quotas;
+ }
+
+ public function getQuota(int $quotaType): int {
+ return $this->quotas[$quotaType] ?? 0;
+ }
+
+ /**
+ * Full representation, including secrets, as stored in app config
+ *
+ * @return array
+ */
+ public function jsonSerialize(): array {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ 'url' => $this->url,
+ 'api_key' => $this->apiKey,
+ 'basic_user' => $this->basicUser,
+ 'basic_password' => $this->basicPassword,
+ 'use_basic_auth' => $this->useBasicAuth,
+ 'request_timeout' => $this->requestTimeout,
+ 'chat_endpoint_enabled' => $this->chatEndpointEnabled,
+ 'use_max_completion_tokens_param' => $this->useMaxCompletionTokensParam,
+ 'llm_extra_params' => $this->llmExtraParams,
+ 'max_tokens' => $this->maxTokens,
+ 'chunk_size' => $this->chunkSize,
+ 'multimodal_image_enabled' => $this->multimodalImageEnabled,
+ 'multimodal_audio_enabled' => $this->multimodalAudioEnabled,
+ 'multimodal_video_enabled' => $this->multimodalVideoEnabled,
+ 'multimodal_document_enabled' => $this->multimodalDocumentEnabled,
+ 'tts_voices' => $this->ttsVoices,
+ 'default_tts_voice' => $this->defaultTtsVoice,
+ 'default_image_size' => $this->defaultImageSize,
+ 'image_request_auth' => $this->imageRequestAuth,
+ 'text_enabled' => $this->textEnabled,
+ 'image_enabled' => $this->imageEnabled,
+ 'stt_enabled' => $this->sttEnabled,
+ 'tts_enabled' => $this->ttsEnabled,
+ 'translation_enabled' => $this->translationEnabled,
+ 'text_models' => $this->textModels,
+ 'image_models' => $this->imageModels,
+ 'stt_models' => $this->sttModels,
+ 'tts_models' => $this->ttsModels,
+ 'quotas' => $this->quotas,
+ ];
+ }
+
+ /**
+ * Representation for the personal settings: only what a user needs to know
+ * to provide their own credentials for this service
+ *
+ * @return array
+ */
+ public function jsonSerializeForUser(): array {
+ // deliberately without the URL: users only need to tell the services
+ // apart, and the full endpoint of an internal service is not theirs
+ // to know
+ return [
+ 'id' => $this->id,
+ 'display_name' => $this->getDisplayName(),
+ // the effective scheme, so the user is asked for the credential
+ // that requests to this service actually use
+ 'use_basic_auth' => $this->usesBasicAuth(),
+ 'is_using_openai' => $this->isUsingOpenAi(),
+ ];
+ }
+
+ /**
+ * Representation for the admin frontend: secrets are replaced by
+ * placeholders so they are never sent to the browser
+ *
+ * @return array
+ */
+ public function jsonSerializeRedacted(): array {
+ $values = $this->jsonSerialize();
+ $values['api_key'] = $this->apiKey === '' ? '' : Application::SECRET_PLACEHOLDER;
+ $values['basic_password'] = $this->basicPassword === '' ? '' : Application::SECRET_PLACEHOLDER;
+ $values['display_name'] = $this->getDisplayName();
+ $values['is_using_openai'] = $this->isUsingOpenAi();
+ $values['model_endpoint_url'] = $this->getRequestUrl() . '/models';
+ return $values;
+ }
+}
diff --git a/lib/Service/ServicesService.php b/lib/Service/ServicesService.php
new file mode 100644
index 00000000..57959b82
--- /dev/null
+++ b/lib/Service/ServicesService.php
@@ -0,0 +1,476 @@
+servicesCache !== null) {
+ return $this->servicesCache;
+ }
+ $services = [];
+ foreach ($this->getStoredServices() as $values) {
+ foreach (ServiceConfig::SECRET_PROPERTIES as $secret) {
+ $values[$secret] = $this->decrypt($values[$secret] ?? '');
+ }
+ $services[] = ServiceConfig::fromArray($values['id'], $values);
+ }
+ $this->servicesCache = $services;
+ return $services;
+ }
+
+ /**
+ * The stored rows of the service list, with the secrets still encrypted.
+ *
+ * @return list> rows that have a string 'id'
+ */
+ private function getStoredServices(): array {
+ $storedString = $this->appConfig->getValueString(Application::APP_ID, Application::SERVICES_CONFIG_KEY, '[]', lazy: true);
+ try {
+ $stored = json_decode($storedString, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ $this->logger->error('Could not decode the stored service list', ['exception' => $e]);
+ $stored = [];
+ }
+ if (!is_array($stored)) {
+ $stored = [];
+ }
+
+ $rows = [];
+ foreach ($stored as $values) {
+ if (!is_array($values) || !isset($values['id']) || !is_string($values['id'])) {
+ continue;
+ }
+ $rows[] = $values;
+ }
+ return $rows;
+ }
+
+ public function getService(string $id): ?ServiceConfig {
+ foreach ($this->getServices() as $service) {
+ if ($service->getId() === $id) {
+ return $service;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @throws Exception if the service does not exist
+ */
+ public function getServiceOrFail(string $id): ServiceConfig {
+ $service = $this->getService($id);
+ if ($service === null) {
+ throw new Exception('Unknown service: ' . $id);
+ }
+ return $service;
+ }
+
+ public function hasOpenAiService(): bool {
+ // This answers the public capabilities endpoint, so it reads the stored
+ // rows directly: going through getServices() would decrypt every
+ // secret of every service just to look at the URLs.
+ foreach ($this->getStoredServices() as $values) {
+ $withoutSecrets = array_diff_key($values, array_flip(ServiceConfig::SECRET_PROPERTIES));
+ if (ServiceConfig::fromArray($values['id'], $withoutSecrets)->isUsingOpenAi()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Create a new service
+ *
+ * @param array $values
+ * @throws Exception if a value has an invalid type
+ */
+ public function addService(array $values = []): ServiceConfig {
+ $this->validate($values);
+ $services = $this->getServices();
+ $services[] = ServiceConfig::fromArray($this->generateId(), $values);
+ $this->storeServices($services);
+ return $this->getServiceOrFail(end($services)->getId());
+ }
+
+ /**
+ * Update the given properties of an existing service. Properties that are
+ * not present in $values are left untouched. A secret set to the
+ * placeholder value is left untouched as well, so the frontend can send
+ * back the redacted representation it received.
+ *
+ * @param array $values
+ * @throws Exception if the service does not exist or a value has an invalid type
+ */
+ public function updateService(string $id, array $values): ServiceConfig {
+ $this->validate($values);
+ foreach (ServiceConfig::SECRET_PROPERTIES as $secret) {
+ if (($values[$secret] ?? null) === Application::SECRET_PLACEHOLDER) {
+ unset($values[$secret]);
+ }
+ }
+ $services = $this->getServices();
+ $found = false;
+ foreach ($services as $index => $service) {
+ if ($service->getId() === $id) {
+ $services[$index] = $service->with($values);
+ $found = true;
+ break;
+ }
+ }
+ if (!$found) {
+ throw new Exception('Unknown service: ' . $id);
+ }
+ $this->storeServices($services);
+ return $this->getServiceOrFail($id);
+ }
+
+ /**
+ * @throws Exception if the service does not exist
+ */
+ public function deleteService(string $id): void {
+ $services = $this->getServices();
+ $remaining = array_values(array_filter(
+ $services,
+ static fn (ServiceConfig $service) => $service->getId() !== $id,
+ ));
+ if (count($remaining) === count($services)) {
+ throw new Exception('Unknown service: ' . $id);
+ }
+ $this->storeServices($remaining);
+ foreach (Application::PROCESSING_TIME_KEYS as $key) {
+ $this->appConfig->deleteKey(Application::APP_ID, $key . '_' . $id);
+ }
+ $this->deleteAllUserCredentials($id);
+ $this->deleteQuotaRules($id);
+ }
+
+ /**
+ * Drop the quota rules of a service that is gone: they govern that service
+ * alone, so there is nothing left for them to limit.
+ */
+ private function deleteQuotaRules(string $id): void {
+ try {
+ foreach ($this->quotaRuleMapper->getRulesOfService($id) as $rule) {
+ $this->quotaUserMapper->deleteByRuleId($rule->getId());
+ }
+ $this->quotaRuleMapper->deleteRulesOfService($id);
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ 'Could not delete the quota rules of the removed service ' . $id,
+ ['exception' => $e],
+ );
+ }
+ }
+
+ /**
+ * Drop the credentials every user provided for a service that is gone, so
+ * that no secret material is left behind.
+ *
+ * The recorded quota usage of the service is deliberately kept: it is real
+ * usage, it still shows up in the usage export of the period it happened
+ * in, and the cleanup job prunes it with the rest of the usage history.
+ */
+ private function deleteAllUserCredentials(string $id): void {
+ $keys = array_map(
+ fn (string $key) => $this->userKey($id, $key),
+ ['api_key', 'basic_user', 'basic_password'],
+ );
+ try {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('userid', 'configkey')
+ ->from('preferences')
+ ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->in('configkey', $qb->createNamedParameter($keys, IQueryBuilder::PARAM_STR_ARRAY)));
+ $result = $qb->executeQuery();
+ $rows = $result->fetchAll();
+ $result->closeCursor();
+ foreach ($rows as $row) {
+ // through IConfig, so its user value cache stays coherent
+ $this->config->deleteUserValue((string)$row['userid'], Application::APP_ID, (string)$row['configkey']);
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ 'Could not delete the user credentials of the removed service ' . $id,
+ ['exception' => $e],
+ );
+ }
+ }
+
+ /**
+ * Replace the whole service list. Used by the migration from the
+ * single-service configuration.
+ *
+ * @param ServiceConfig[] $services
+ */
+ public function setServices(array $services): void {
+ $this->storeServices(array_values($services));
+ }
+
+ /**
+ * Representation of all services for the admin frontend, without secrets
+ *
+ * @return list>
+ */
+ public function getServicesForFrontend(): array {
+ return array_values(array_map(
+ static fn (ServiceConfig $service) => $service->jsonSerializeRedacted(),
+ $this->getServices(),
+ ));
+ }
+
+ /**
+ * Return the service as it should be used for requests on behalf of
+ * $userId: with the user's own credentials if they configured any.
+ */
+ public function applyUserCredentials(ServiceConfig $service, ?string $userId): ServiceConfig {
+ if ($userId === null) {
+ return $service;
+ }
+ $values = [];
+ $apiKey = $this->getUserApiKey($userId, $service->getId());
+ if ($apiKey !== '') {
+ $values['api_key'] = $apiKey;
+ }
+ $basicUser = $this->getUserBasicUser($userId, $service->getId());
+ $basicPassword = $this->getUserBasicPassword($userId, $service->getId());
+ if ($basicUser !== '' && $basicPassword !== '') {
+ $values['basic_user'] = $basicUser;
+ $values['basic_password'] = $basicPassword;
+ }
+ return $values === [] ? $service : $service->with($values);
+ }
+
+ /**
+ * Whether the user provided their own credentials for this service, which
+ * exempts them from the quotas.
+ *
+ * Only the credentials that requests to this service actually use count:
+ * an API key stored for a service that authenticates with basic auth is
+ * never sent, so it must not lift the quotas while the admin's
+ * credentials pay for the requests.
+ */
+ public function userHasOwnCredentials(?string $userId, ServiceConfig $service): bool {
+ if ($userId === null) {
+ return false;
+ }
+ if ($service->usesBasicAuth()) {
+ return $this->getUserBasicUser($userId, $service->getId()) !== ''
+ && $this->getUserBasicPassword($userId, $service->getId()) !== '';
+ }
+ return $this->getUserApiKey($userId, $service->getId()) !== '';
+ }
+
+ public function getUserApiKey(string $userId, string $serviceId): string {
+ return $this->getUserSecret($userId, $serviceId, 'api_key');
+ }
+
+ public function getUserBasicUser(string $userId, string $serviceId): string {
+ return $this->config->getUserValue($userId, Application::APP_ID, $this->userKey($serviceId, 'basic_user'));
+ }
+
+ public function getUserBasicPassword(string $userId, string $serviceId): string {
+ return $this->getUserSecret($userId, $serviceId, 'basic_password');
+ }
+
+ /**
+ * The per-service credentials of a user, for the personal settings page.
+ * Secrets are redacted.
+ *
+ * @return array
+ */
+ public function getUserCredentialsForFrontend(string $userId): array {
+ $credentials = [];
+ foreach ($this->getServices() as $service) {
+ $apiKey = $this->getUserApiKey($userId, $service->getId());
+ $basicPassword = $this->getUserBasicPassword($userId, $service->getId());
+ $credentials[$service->getId()] = [
+ 'api_key' => $apiKey === '' ? '' : Application::SECRET_PLACEHOLDER,
+ 'basic_user' => $this->getUserBasicUser($userId, $service->getId()),
+ 'basic_password' => $basicPassword === '' ? '' : Application::SECRET_PLACEHOLDER,
+ ];
+ }
+ return $credentials;
+ }
+
+ /**
+ * Store the credentials a user provided for one service
+ *
+ * @param array $values with any of the keys api_key, basic_user, basic_password
+ * @throws Exception if the service does not exist
+ */
+ public function setUserCredentials(string $userId, string $serviceId, array $values): void {
+ $this->getServiceOrFail($serviceId);
+ foreach (['api_key', 'basic_user', 'basic_password'] as $key) {
+ if (!isset($values[$key]) || !is_string($values[$key])) {
+ continue;
+ }
+ $value = $values[$key];
+ if ($value === Application::SECRET_PLACEHOLDER) {
+ continue;
+ }
+ $configKey = $this->userKey($serviceId, $key);
+ if ($value === '') {
+ $this->config->deleteUserValue($userId, Application::APP_ID, $configKey);
+ continue;
+ }
+ if ($key === 'basic_user') {
+ $this->config->setUserValue($userId, Application::APP_ID, $configKey, $value);
+ } else {
+ $this->config->setUserValue($userId, Application::APP_ID, $configKey, $this->crypto->encrypt($value));
+ }
+ }
+ }
+
+ /**
+ * @param ServiceConfig[] $services
+ */
+ private function storeServices(array $services): void {
+ $stored = array_map(function (ServiceConfig $service) {
+ $values = $service->jsonSerialize();
+ foreach (ServiceConfig::SECRET_PROPERTIES as $secret) {
+ $values[$secret] = $values[$secret] === '' ? '' : $this->crypto->encrypt($values[$secret]);
+ }
+ return $values;
+ }, $services);
+ $this->appConfig->setValueString(
+ Application::APP_ID,
+ Application::SERVICES_CONFIG_KEY,
+ json_encode($stored, JSON_THROW_ON_ERROR),
+ lazy: true,
+ sensitive: true,
+ );
+ $this->servicesCache = null;
+ // the quota amounts of a service are part of the cached fallback quota
+ // rule, so a changed quota would otherwise not be enforced
+ $this->cacheFactory->createDistributed(Application::APP_ID)
+ ->clear(Application::QUOTA_RULES_CACHE_PREFIX);
+ }
+
+ /**
+ * @param array $values
+ * @throws Exception if a value has an invalid type
+ */
+ private function validate(array $values): void {
+ foreach ($values as $key => $value) {
+ if ($key === 'id') {
+ // the ID is not writable
+ continue;
+ }
+ if (!isset(ServiceConfig::PROPERTY_TYPES[$key])) {
+ throw new Exception('Invalid service property: ' . $key);
+ }
+ $expected = ServiceConfig::PROPERTY_TYPES[$key];
+ if (in_array($key, ['use_max_completion_tokens_param', 'image_request_auth'], true) && $value === null) {
+ // null means "decide based on the service URL"
+ continue;
+ }
+ if (gettype($value) !== $expected) {
+ throw new Exception('Invalid type for ' . $key . '. Expected ' . $expected . ', got ' . gettype($value));
+ }
+ }
+ if (isset($values['llm_extra_params']) && $values['llm_extra_params'] !== '') {
+ if (!is_array(json_decode((string)$values['llm_extra_params'], true))) {
+ throw new Exception('llm_extra_params must be a JSON object');
+ }
+ }
+ if (isset($values['url']) && $values['url'] !== '' && !filter_var($values['url'], FILTER_VALIDATE_URL)) {
+ throw new Exception('Invalid service URL');
+ }
+ if (isset($values['default_image_size']) && $values['default_image_size'] !== ''
+ && preg_match('/^\d+x\d+$/', (string)$values['default_image_size']) !== 1) {
+ throw new Exception('Invalid image size value. Expected the format x');
+ }
+ }
+
+ /**
+ * The ID of the next service.
+ *
+ * IDs are never reused, so the counter is the source of truth. It is
+ * still checked against the IDs that are actually taken: an upgrade that
+ * was aborted between writing the service list and the counter would
+ * otherwise hand out an ID that is already in use.
+ */
+ private function generateId(): string {
+ $taken = [];
+ foreach ($this->getStoredServices() as $values) {
+ $taken[$values['id']] = true;
+ }
+ $counter = $this->appConfig->getValueInt(Application::APP_ID, 'service_id_counter', 0);
+ do {
+ $counter++;
+ $id = 's' . $counter;
+ } while (isset($taken[$id]));
+ $this->appConfig->setValueInt(Application::APP_ID, 'service_id_counter', $counter);
+ return $id;
+ }
+
+ private function userKey(string $serviceId, string $key): string {
+ return 'service_' . $serviceId . '_' . $key;
+ }
+
+ private function getUserSecret(string $userId, string $serviceId, string $key): string {
+ $stored = $this->config->getUserValue($userId, Application::APP_ID, $this->userKey($serviceId, $key));
+ return $this->decrypt($stored);
+ }
+
+ private function decrypt(mixed $value): string {
+ if (!is_string($value) || $value === '') {
+ return '';
+ }
+ try {
+ return $this->crypto->decrypt($value);
+ } catch (Throwable $e) {
+ $this->logger->warning('Could not decrypt a stored secret', ['exception' => $e]);
+ return '';
+ }
+ }
+}
diff --git a/lib/Service/TranslateService.php b/lib/Service/TranslateService.php
index ecdb2d6a..c3190688 100644
--- a/lib/Service/TranslateService.php
+++ b/lib/Service/TranslateService.php
@@ -38,7 +38,6 @@ class TranslateService {
];
public function __construct(
- private OpenAiSettingsService $openAiSettingsService,
private LoggerInterface $logger,
private OpenAiAPIService $openAiAPIService,
private ChunkService $chunkService,
@@ -66,10 +65,10 @@ public static function getCoreLanguagesByCode(): array {
}
public function translate(
- string $inputText, string $sourceLanguageCode, string $targetLanguageCode, string $model, ?int $maxTokens,
+ ServiceConfig $service, string $inputText, string $sourceLanguageCode, string $targetLanguageCode, string $model, ?int $maxTokens,
?string $userId, ?callable $reportProgress = null, bool $preferStreaming = false, ?callable $reportOutput = null,
): string {
- $chunks = $this->chunkService->chunkSplitPrompt($inputText, true, $maxTokens);
+ $chunks = $this->chunkService->chunkSplitPrompt($service, $inputText, true, $maxTokens);
$translation = '';
$increase = 1.0 / (float)count($chunks);
$progress = 0.0;
@@ -104,14 +103,14 @@ public function translate(
}
$prompt = $promptStart . PHP_EOL . PHP_EOL . $chunk;
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($service->isUsingOpenAi() || $service->getChatEndpointEnabled()) {
$completionsObj = $this->openAiAPIService->createChatCompletion(
- $userId, $model, $prompt, TranslateService::SYSTEM_PROMPT, null, 1, $maxTokens, TranslateService::JSON_RESPONSE_FORMAT
+ $userId, $service, $model, $prompt, TranslateService::SYSTEM_PROMPT, null, 1, $maxTokens, TranslateService::JSON_RESPONSE_FORMAT
);
$completions = $completionsObj['messages'];
} else {
$completions = $this->openAiAPIService->createCompletion(
- $userId, $prompt . PHP_EOL . TranslateService::SYSTEM_PROMPT . PHP_EOL . PHP_EOL, 1, $model, $maxTokens
+ $userId, $service, $prompt . PHP_EOL . TranslateService::SYSTEM_PROMPT . PHP_EOL . PHP_EOL, 1, $model, $maxTokens
);
}
diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php
index af3b8b2d..7aefcdec 100644
--- a/lib/Settings/Admin.php
+++ b/lib/Settings/Admin.php
@@ -10,6 +10,7 @@
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiSettingsService;
use OCA\OpenAi\Service\QuotaRuleService;
+use OCA\OpenAi\Service\ServicesService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@@ -19,6 +20,7 @@ class Admin implements ISettings {
public function __construct(
private IInitialState $initialStateService,
private OpenAiSettingsService $openAiSettingsService,
+ private ServicesService $servicesService,
private QuotaRuleService $quotaRuleService,
private IAppManager $appManager,
) {
@@ -29,21 +31,12 @@ public function __construct(
*/
public function getForm(): TemplateResponse {
$adminConfig = $this->openAiSettingsService->getAdminConfig();
- $adminConfig['api_key'] = $adminConfig['api_key'] === '' ? '' : 'dummyApiKey';
- $adminConfig['basic_password'] = $adminConfig['basic_password'] === '' ? '' : 'dummyPassword';
- $adminConfig['image_api_key'] = $adminConfig['image_api_key'] === '' ? '' : 'dummyApiKey';
- $adminConfig['image_basic_password'] = $adminConfig['image_basic_password'] === '' ? '' : 'dummyPassword';
- $adminConfig['stt_api_key'] = $adminConfig['stt_api_key'] === '' ? '' : 'dummyApiKey';
- $adminConfig['stt_basic_password'] = $adminConfig['stt_basic_password'] === '' ? '' : 'dummyPassword';
- $adminConfig['tts_api_key'] = $adminConfig['tts_api_key'] === '' ? '' : 'dummyApiKey';
- $adminConfig['tts_basic_password'] = $adminConfig['tts_basic_password'] === '' ? '' : 'dummyPassword';
- $isAssistantEnabled = $this->appManager->isEnabledForUser('assistant');
- $adminConfig['assistant_enabled'] = $isAssistantEnabled;
+ $adminConfig['assistant_enabled'] = $this->appManager->isEnabledForUser('assistant');
$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);
+ $this->initialStateService->provideInitialState('services', $this->servicesService->getServicesForFrontend());
+ $this->initialStateService->provideInitialState('rules', $this->quotaRuleService->getRules());
return new TemplateResponse(Application::APP_ID, 'adminSettings');
}
diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php
index 230d2433..0a83e7b8 100644
--- a/lib/Settings/Personal.php
+++ b/lib/Settings/Personal.php
@@ -9,6 +9,8 @@
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
+use OCA\OpenAi\Service\ServicesService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IL10N;
@@ -18,6 +20,7 @@ class Personal implements ISettings {
public function __construct(
private IInitialState $initialStateService,
private OpenAiSettingsService $openAiSettingsService,
+ private ServicesService $servicesService,
private IL10N $l,
private ?string $userId,
) {
@@ -31,11 +34,9 @@ public function getForm(): TemplateResponse {
return new TemplateResponse(Application::APP_ID, 'personalSettings');
}
$userConfig = $this->openAiSettingsService->getUserConfig($this->userId);
- $userConfig['api_key'] = $userConfig['api_key'] === '' ? '' : 'dummyApiKey';
- $userConfig['basic_password'] = $userConfig['basic_password'] === '' ? '' : 'dummyPassword';
$languages = Application::LANGUAGE_CODES_AND_ENDONYMS;
array_unshift($languages, ['detect_language', $this->l->t('Detect language')]);
- $languages = array_map(static function (array $language) use ($userConfig) {
+ $languages = array_map(static function (array $language) {
return [
'value' => $language[0],
'label' => $language[1],
@@ -53,6 +54,15 @@ public function getForm(): TemplateResponse {
}
}
$this->initialStateService->provideInitialState('config', $userConfig);
+ // users can provide their own credentials for any connected service
+ $this->initialStateService->provideInitialState('services', array_map(
+ static fn (ServiceConfig $service) => $service->jsonSerializeForUser(),
+ $this->servicesService->getServices(),
+ ));
+ $this->initialStateService->provideInitialState(
+ 'user-credentials',
+ $this->servicesService->getUserCredentialsForFrontend($this->userId),
+ );
return new TemplateResponse(Application::APP_ID, 'personalSettings');
}
diff --git a/lib/TaskProcessing/AnalyzeImagesProvider.php b/lib/TaskProcessing/AnalyzeImagesProvider.php
index 1ba992e3..8af1653a 100644
--- a/lib/TaskProcessing/AnalyzeImagesProvider.php
+++ b/lib/TaskProcessing/AnalyzeImagesProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,22 +22,23 @@
use Psr\Log\LoggerInterface;
class AnalyzeImagesProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private LoggerInterface $logger,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-analyze-images';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -46,7 +46,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -64,25 +64,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the output.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the output'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -113,7 +104,7 @@ public function process(
$reportOutput = $options->getReportIntermediateOutput();
$preferStreaming = $options->getPreferStreaming();
- if (!$this->openAiAPIService->isUsingOpenAi() && !$this->openAiSettingsService->getChatEndpointEnabled()) {
+ if (!$this->service->isUsingOpenAi() && !$this->service->getChatEndpointEnabled()) {
throw new ProcessingException('Must support chat completion endpoint');
}
@@ -129,11 +120,7 @@ public function process(
}
$prompt = $input['input'];
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
$maxTokens = null;
if (isset($input['max_tokens']) && is_int($input['max_tokens'])) {
@@ -158,7 +145,7 @@ function ($carry, $file) {
try {
$systemPrompt = 'Take the user\'s question and answer it based on the provided images. Ensure that the answer matches the language of the user\'s question.';
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
$time = microtime(true);
$streamedOutput = '';
$streamedReasoning = '';
@@ -196,7 +183,7 @@ function ($carry, $file) {
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
diff --git a/lib/TaskProcessing/AudioToAudioChatProvider.php b/lib/TaskProcessing/AudioToAudioChatProvider.php
index c7f36194..d051057c 100644
--- a/lib/TaskProcessing/AudioToAudioChatProvider.php
+++ b/lib/TaskProcessing/AudioToAudioChatProvider.php
@@ -9,11 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\Files\File;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,33 +22,36 @@
use OCP\TaskProcessing\TaskTypes\AudioToAudioChat;
use Psr\Log\LoggerInterface;
+/**
+ * Audio chat through the integrated audio-in/audio-out chat completion
+ * endpoint, which OpenAI-compatible services expose as one request.
+ *
+ * This is only registered for models of services that have audio attachments
+ * enabled. Chaining separate speech-to-text, chat and text-to-speech providers
+ * is not done here: the Assistant app registers a fallback provider for that.
+ */
class AudioToAudioChatProvider implements ISynchronousProvider {
-
- // OpenAI supports wav and mp3
- // https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
- private const SUPPORTED_INPUT_AUDIO_FORMATS = [
- 'audio/mp3' => 'mp3',
- 'audio/mpeg' => 'mp3',
- 'audio/wav' => 'wav',
- 'audio/x-wav' => 'wav',
- ];
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private IL10N $l,
private LoggerInterface $logger,
- private IAppConfig $appConfig,
- private OpenAiSettingsService $openAiSettingsService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
+ /** Used to transcribe the input, which is part of the task output */
+ private string $sttModel,
+ /** Only needed when the model answers with text instead of audio */
+ private ?string $ttsModel,
) {
}
public function getId(): string {
- return Application::APP_ID . '-audio2audio:chat';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -58,7 +59,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -70,13 +71,7 @@ public function getInputShapeDefaults(): array {
}
public function getOptionalInputShape(): array {
- $isUsingOpenAi = $this->openAiAPIService->isUsingOpenAi();
- $ois = [
- 'llm_model' => new ShapeDescriptor(
- $this->l->t('Completion model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
+ return [
'voice' => new ShapeDescriptor(
$this->l->t('Output voice'),
$this->l->t('The voice used to generate speech'),
@@ -87,56 +82,30 @@ public function getOptionalInputShape(): array {
$this->l->t('The memories to be injected into the chat session.'),
EShapeType::ListOfTexts
),
- ];
- if (!$isUsingOpenAi) {
- $ois['tts_model'] = new ShapeDescriptor(
- $this->l->t('Text-to-speech model'),
- $this->l->t('The model used to generate the speech'),
- EShapeType::Enum
- );
- $ois['speed'] = new ShapeDescriptor(
+ 'speed' => new ShapeDescriptor(
$this->l->t('Speed'),
- $this->openAiAPIService->isUsingOpenAi()
+ $this->service->isUsingOpenAi()
? $this->l->t('Speech speed modifier (Valid values: 0.25-4)')
: $this->l->t('Speech speed modifier'),
EShapeType::Number
- );
- }
- return $ois;
+ ),
+ ];
}
public function getOptionalInputShapeEnumValues(): array {
- $isUsingOpenAi = $this->openAiAPIService->isUsingOpenAi();
- $voices = json_decode($this->appConfig->getValueString(Application::APP_ID, 'tts_voices', lazy: true)) ?: Application::DEFAULT_SPEECH_VOICES;
- $models = $this->openAiAPIService->getModelEnumValues($this->userId);
- $enumValues = [
- 'voice' => array_map(function ($v) {
- return new ShapeEnumValue($v, $v);
- }, $voices),
- 'llm_model' => $models,
+ return [
+ 'voice' => array_map(
+ static fn (string $voice) => new ShapeEnumValue($voice, $voice),
+ $this->service->getTtsVoices(),
+ ),
];
- if (!$isUsingOpenAi) {
- $enumValues['tts_model'] = $models;
- }
- return $enumValues;
}
public function getOptionalInputShapeDefaults(): array {
- $isUsingOpenAi = $this->openAiAPIService->isUsingOpenAi();
- $adminVoice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- $adminLlmModel = $isUsingOpenAi
- ? 'gpt-audio'
- : $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- $defaults = [
- 'voice' => $adminVoice,
- 'llm_model' => $adminLlmModel,
+ return [
+ 'voice' => $this->service->getDefaultTtsVoice(),
+ 'speed' => 1,
];
- if (!$isUsingOpenAi) {
- $adminTtsModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
- $defaults['tts_model'] = $adminTtsModel;
- $defaults['speed'] = 1;
- }
- return $defaults;
}
public function getOutputShapeEnumValues(): array {
@@ -183,31 +152,14 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
$history = $input['history'];
- if (isset($input['tts_model']) && is_string($input['tts_model'])) {
- $ttsModel = $input['tts_model'];
- } else {
- $ttsModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', Application::DEFAULT_SPEECH_MODEL_ID, lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
- }
-
- if (isset($input['llm_model']) && is_string($input['llm_model'])) {
- $llmModel = $input['llm_model'];
- } else {
- $isUsingOpenAi = $this->openAiAPIService->isUsingOpenAi();
- $llmModel = $isUsingOpenAi
- ? 'gpt-4o-audio-preview'
- : $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
-
- if (isset($input['voice']) && is_string($input['voice'])) {
- $outputVoice = $input['voice'];
- } else {
- $outputVoice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', Application::DEFAULT_SPEECH_VOICE, lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- }
+ $outputVoice = isset($input['voice']) && is_string($input['voice'])
+ ? $input['voice']
+ : $this->service->getDefaultTtsVoice();
$speed = 1;
if (isset($input['speed']) && is_numeric($input['speed'])) {
$speed = $input['speed'];
- if ($this->openAiAPIService->isUsingOpenAi()) {
+ if ($this->service->isUsingOpenAi()) {
if ($speed > 4) {
$speed = 4;
} elseif ($speed < 0.25) {
@@ -216,23 +168,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
}
- $sttModel = $this->appConfig->getValueString(Application::APP_ID, 'default_stt_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID;
- $serviceName = $this->appConfig->getValueString(Application::APP_ID, 'service_name', lazy: true) ?: Application::APP_ID;
-
- // Using the chat API if connected to OpenAI
- // there is an issue if the history mostly contains text, the model will answer text even if we add the audio modality
- if ($this->openAiAPIService->isUsingOpenAi()) {
- return $this->oneStep($userId, $systemPrompt, $inputFile, $history, $outputVoice, $sttModel, $llmModel, $ttsModel, $speed, $serviceName);
- }
-
- // 3 steps: STT -> LLM -> TTS
- return $this->threeSteps($userId, $systemPrompt, $inputFile, $history, $outputVoice, $sttModel, $llmModel, $ttsModel, $speed, $serviceName);
- }
-
- private function oneStep(
- ?string $userId, string $systemPrompt, File $inputFile, array $history, string $outputVoice,
- string $sttModel, string $llmModel, string $ttsModel, float $speed, string $serviceName,
- ): array {
+ $serviceName = $this->service->getDisplayName();
$result = [];
$extraParams = [
'modalities' => ['text', 'audio'],
@@ -240,7 +176,7 @@ private function oneStep(
];
$systemPrompt .= ' Producing text responses will break the user interface. Important: You have multimodal voice capability, and you use voice exclusively to respond.';
$completion = $this->openAiAPIService->createChatCompletion(
- $userId, $llmModel, null, $systemPrompt, $history, 1, 1000,
+ $userId, $this->service, $this->model, null, $systemPrompt, $history, 1, 1000,
$extraParams, null, null, [$inputFile]
);
$message = array_pop($completion['audio_messages']);
@@ -248,9 +184,12 @@ private function oneStep(
// https://community.openai.com/t/gpt-4o-audio-preview-responds-in-text-not-audio/1006486/5
if ($message === null) {
// no audio, TTS the text message
+ if ($this->ttsModel === null) {
+ throw new ProcessingException($serviceName . ' answered with text and no text-to-speech model is configured for it');
+ }
try {
$textResponse = array_pop($completion['messages']);
- $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $textResponse, $ttsModel, $outputVoice, $speed);
+ $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $this->service, $textResponse, $this->ttsModel, $outputVoice, $speed);
if (!isset($apiResponse['body'])) {
$this->logger->warning($serviceName . ' text to speech generation failed: no speech returned');
throw new ProcessingException($serviceName . ' text to speech generation failed: no speech returned');
@@ -275,10 +214,9 @@ private function oneStep(
$result['output'] = $output;
$result['output_transcript'] = $textResponse;
- // we still want the input transcription
+ // the transcript of the input is part of the task output
try {
- $inputTranscription = $this->openAiAPIService->transcribeFile($userId, $inputFile, false, $sttModel);
- $result['input_transcript'] = $inputTranscription;
+ $result['input_transcript'] = $this->openAiAPIService->transcribeFile($userId, $this->service, $inputFile, false, $this->sttModel);
} catch (UserFacingProcessingException $e) {
throw $e;
} catch (\Throwable $e) {
@@ -288,53 +226,4 @@ private function oneStep(
return $result;
}
-
- private function threeSteps(
- ?string $userId, string $systemPrompt, File $inputFile, array $history, string $outputVoice,
- string $sttModel, string $llmModel, string $ttsModel, float $speed, string $serviceName,
- ): array {
- // speech to text
- try {
- $inputTranscription = $this->openAiAPIService->transcribeFile($userId, $inputFile, false, $sttModel);
- } catch (UserFacingProcessingException $e) {
- throw $e;
- } catch (\Throwable $e) {
- $this->logger->warning($serviceName . ' transcription failed with: ' . $e->getMessage(), ['exception' => $e]);
- throw new ProcessingException($serviceName . ' transcription failed with: ' . $e->getMessage());
- }
-
- // free prompt
- try {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $llmModel, $inputTranscription, $systemPrompt, $history, 1, 1000);
- $completion = $completion['messages'];
- } catch (UserFacingProcessingException $e) {
- throw $e;
- } catch (\Throwable $e) {
- throw new ProcessingException($serviceName . ' chat completion request failed: ' . $e->getMessage());
- }
- if (count($completion) === 0) {
- throw new ProcessingException('No completion in ' . $serviceName . ' response.');
- }
- $llmResult = array_pop($completion);
-
- // text to speech
- try {
- $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $llmResult, $ttsModel, $outputVoice, $speed);
-
- if (!isset($apiResponse['body'])) {
- $this->logger->warning($serviceName . ' text to speech generation failed: no speech returned');
- throw new ProcessingException($serviceName . ' text to speech generation failed: no speech returned');
- }
- return [
- 'output' => $apiResponse['body'],
- 'output_transcript' => $llmResult,
- 'input_transcript' => $inputTranscription,
- ];
- } catch (UserFacingProcessingException $e) {
- throw $e;
- } catch (\Throwable $e) {
- $this->logger->warning($serviceName . ' text to speech generation failed with: ' . $e->getMessage(), ['exception' => $e]);
- throw new ProcessingException($serviceName . ' text to speech generation failed with: ' . $e->getMessage());
- }
- }
}
diff --git a/lib/TaskProcessing/AudioToAudioTranslateProvider.php b/lib/TaskProcessing/AudioToAudioTranslateProvider.php
index 0c5236cf..c2724663 100644
--- a/lib/TaskProcessing/AudioToAudioTranslateProvider.php
+++ b/lib/TaskProcessing/AudioToAudioTranslateProvider.php
@@ -12,11 +12,10 @@
use Exception;
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCA\OpenAi\Service\TranslateService;
use OCA\OpenAi\Service\WatermarkingService;
use OCP\Files\File;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\L10N\IFactory;
@@ -31,28 +30,41 @@
use OCP\TaskProcessing\TaskTypes\AudioToAudioTranslate;
use Psr\Log\LoggerInterface;
+/**
+ * Translates spoken audio into spoken audio of another language by chaining
+ * transcription, translation and speech generation on one service.
+ *
+ * Registered once per selected speech-to-text model; the models for the other
+ * two steps are the first text and text-to-speech models selected for the same
+ * service.
+ */
class AudioToAudioTranslateProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private TranslateService $translateService,
- private OpenAiSettingsService $openAiSettingsService,
private WatermarkingService $watermarkingService,
private LoggerInterface $logger,
private IFactory $l10nFactory,
private IL10N $l,
- private IAppConfig $appConfig,
private IUserManager $userManager,
- private ?string $userId,
+ private ServiceConfig $service,
+ /** The speech-to-text model this provider is registered for */
+ private string $model,
+ /** The text model used to translate the transcription */
+ private string $textModel,
+ /** The text-to-speech model used to read out the translation */
+ private string $ttsModel,
) {
}
public function getId(): string {
- return Application::APP_ID . '-audio2audio:translate';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_STT);
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -88,14 +100,9 @@ public function getOptionalInputShape(): array {
$this->l->t('The voice to use'),
EShapeType::Enum
),
- 'tts_model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the speech'),
- EShapeType::Enum
- ),
'tts_speed' => new ShapeDescriptor(
$this->l->t('Speed'),
- $this->openAiAPIService->isUsingOpenAi(Application::SERVICE_TYPE_TTS)
+ $this->service->isUsingOpenAi()
? $this->l->t('Speech speed modifier (Valid values: 0.25-4)')
: $this->l->t('Speech speed modifier'),
EShapeType::Number
@@ -104,21 +111,17 @@ public function getOptionalInputShape(): array {
}
public function getOptionalInputShapeEnumValues(): array {
- $voices = json_decode($this->appConfig->getValueString(Application::APP_ID, 'tts_voices', lazy: true)) ?: Application::DEFAULT_SPEECH_VOICES;
return [
- 'tts_voice' => array_map(function ($v) {
- return new ShapeEnumValue($v, $v);
- }, $voices),
- 'tts_model' => $this->openAiAPIService->getModelEnumValues($this->userId, Application::SERVICE_TYPE_TTS),
+ 'tts_voice' => array_map(
+ static fn (string $voice) => new ShapeEnumValue($voice, $voice),
+ $this->service->getTtsVoices(),
+ ),
];
}
public function getOptionalInputShapeDefaults(): array {
- $adminVoice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- $adminModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
return [
- 'tts_voice' => $adminVoice,
- 'tts_model' => $adminModel,
+ 'tts_voice' => $this->service->getDefaultTtsVoice(),
'tts_speed' => 1,
];
}
@@ -166,9 +169,8 @@ public function process(
}
// STT
- $sttModel = $this->appConfig->getValueString(Application::APP_ID, 'default_stt_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID;
try {
- $transcription = $this->openAiAPIService->transcribeFile($userId, $inputFile, false, $sttModel, $input['origin_language']);
+ $transcription = $this->openAiAPIService->transcribeFile($userId, $this->service, $inputFile, false, $this->model, $input['origin_language']);
} catch (UserFacingProcessingException $e) {
throw $e;
} catch (Exception $e) {
@@ -206,10 +208,7 @@ public function process(
}
// translate
- $completionModel = $this->openAiAPIService->isUsingOpenAi()
- ? ($this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID)
- : $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', lazy: true);
- $maxTokens = $this->openAiSettingsService->getMaxTokens();
+ $maxTokens = $this->service->getMaxTokens();
try {
$reportTranslationOutput = function (string $translationOutput) use ($reportOutput, $transcription, $watermarkSuffix) {
@@ -222,8 +221,9 @@ public function process(
}
};
$translatedText = $this->translateService->translate(
+ $this->service,
$transcription, $input['origin_language'], $input['target_language'],
- $completionModel, $maxTokens, $userId, null,
+ $this->textModel, $maxTokens, $userId, null,
$preferStreaming, $reportTranslationOutput,
);
@@ -254,21 +254,14 @@ public function process(
// TTS
$ttsPrompt = $translatedText . $watermarkSuffix;
- if (isset($input['tts_model']) && is_string($input['tts_model'])) {
- $ttsModel = $input['tts_model'];
- } else {
- $ttsModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', Application::DEFAULT_SPEECH_MODEL_ID, lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
- }
- if (isset($input['tts_voice']) && is_string($input['tts_voice'])) {
- $voice = $input['tts_voice'];
- } else {
- $voice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', Application::DEFAULT_SPEECH_VOICE, lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- }
+ $voice = isset($input['tts_voice']) && is_string($input['tts_voice'])
+ ? $input['tts_voice']
+ : $this->service->getDefaultTtsVoice();
$speed = 1;
if (isset($input['tts_speed']) && is_numeric($input['tts_speed'])) {
$speed = $input['tts_speed'];
- if ($this->openAiAPIService->isUsingOpenAi(Application::SERVICE_TYPE_TTS)) {
+ if ($this->service->isUsingOpenAi()) {
if ($speed > 4) {
$speed = 4;
} elseif ($speed < 0.25) {
@@ -279,7 +272,7 @@ public function process(
try {
$apiResponse = $this->openAiAPIService->requestSpeechCreation(
- $userId, $ttsPrompt, $ttsModel, $voice, $speed,
+ $userId, $this->service, $ttsPrompt, $this->ttsModel, $voice, $speed,
);
if (!isset($apiResponse['body'])) {
diff --git a/lib/TaskProcessing/AudioToTextEnhancedProvider.php b/lib/TaskProcessing/AudioToTextEnhancedProvider.php
index a2dcf079..d5f31d93 100644
--- a/lib/TaskProcessing/AudioToTextEnhancedProvider.php
+++ b/lib/TaskProcessing/AudioToTextEnhancedProvider.php
@@ -9,22 +9,32 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCP\TaskProcessing\IManager;
+use OCA\OpenAi\Service\ServiceConfig;
+use OCP\IL10N;
use OCP\TaskProcessing\ISynchronousProvider;
-use OCP\TaskProcessing\Task;
use OCP\TaskProcessing\TaskTypes\AudioToText;
use Psr\Log\LoggerInterface;
use Throwable;
+/**
+ * Transcribes audio and reformats the transcription into paragraphs.
+ *
+ * Both steps run on the same service: the transcription with the
+ * speech-to-text model this provider is registered for, the reformatting with
+ * the first text model selected for that service.
+ */
class AudioToTextEnhancedProvider implements ISynchronousProvider {
+ // No ProviderIdentity: this provider derives its ID and name from the
+ // transcription provider it wraps rather than from a model of its own.
public function __construct(
private AudioToTextProvider $audioToTextProvider,
+ private ReformatParagraphsProvider $reformatParagraphsProvider,
private OpenAiAPIService $openAiAPIService,
- private IManager $taskProcessingManager,
private LoggerInterface $logger,
+ private IL10N $l,
+ private ServiceConfig $service,
) {
}
@@ -33,7 +43,7 @@ public function getId(): string {
}
public function getName(): string {
- return $this->audioToTextProvider->getName() . ' (with paragraph reformatting)';
+ return $this->l->t('%s (with paragraph reformatting)', [$this->audioToTextProvider->getName()]);
}
public function getTaskTypeId(): string {
@@ -42,7 +52,7 @@ public function getTaskTypeId(): string {
public function getExpectedRuntime(): int {
// The audio to text provider may not be openai and this assumes it is
- return $this->audioToTextProvider->getExpectedRuntime() + $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->audioToTextProvider->getExpectedRuntime() + $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -85,23 +95,14 @@ public function process(?string $userId, array $input, callable $reportProgress)
return ['output' => $transcription];
}
- $reformatTask = new Task(
- \OCP\TaskProcessing\TaskTypes\TextToTextReformatParagraphs::ID,
- ['input' => $transcription],
- Application::APP_ID,
- $userId,
- 'audio2text_enhanced',
- );
-
try {
- $finished = $this->taskProcessingManager->runTask($reformatTask);
- $output = $finished->getOutput();
- if (is_array($output) && isset($output['output']) && is_string($output['output']) && $output['output'] !== '') {
+ $output = $this->reformatParagraphsProvider->process($userId, ['input' => $transcription], $reportProgress);
+ if (isset($output['output']) && is_string($output['output']) && $output['output'] !== '') {
return ['output' => $output['output']];
}
- $this->logger->warning('ReformatParagraphs follow-up task returned no usable output, falling back to raw transcription');
+ $this->logger->warning('Paragraph reformatting returned no usable output, falling back to raw transcription');
} catch (Throwable $e) {
- $this->logger->warning('ReformatParagraphs follow-up task failed, falling back to raw transcription: ' . $e->getMessage(), ['exception' => $e]);
+ $this->logger->warning('Paragraph reformatting failed, falling back to raw transcription: ' . $e->getMessage(), ['exception' => $e]);
}
return ['output' => $transcription];
diff --git a/lib/TaskProcessing/AudioToTextProvider.php b/lib/TaskProcessing/AudioToTextProvider.php
index f5e961af..e7b3c50f 100644
--- a/lib/TaskProcessing/AudioToTextProvider.php
+++ b/lib/TaskProcessing/AudioToTextProvider.php
@@ -11,8 +11,8 @@
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\Files\File;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,21 +24,23 @@
use Psr\Log\LoggerInterface;
class AudioToTextProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private LoggerInterface $logger,
- private IAppConfig $appConfig,
private IL10N $l,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-audio2text';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_STT);
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -46,7 +48,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -100,10 +102,10 @@ public function process(?string $userId, array $input, callable $reportProgress)
throw new ProcessingException('Invalid language');
}
- $model = $this->appConfig->getValueString(Application::APP_ID, 'default_stt_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID;
+ $model = $this->model;
try {
- $transcription = $this->openAiAPIService->transcribeFile($userId, $inputFile, false, $model, $language);
+ $transcription = $this->openAiAPIService->transcribeFile($userId, $this->service, $inputFile, false, $model, $language);
return ['output' => $transcription];
} catch (UserFacingProcessingException $e) {
throw $e;
diff --git a/lib/TaskProcessing/AudioToTextSubtitlesProvider.php b/lib/TaskProcessing/AudioToTextSubtitlesProvider.php
index 5cd3be49..7a90e72e 100644
--- a/lib/TaskProcessing/AudioToTextSubtitlesProvider.php
+++ b/lib/TaskProcessing/AudioToTextSubtitlesProvider.php
@@ -11,8 +11,8 @@
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\Files\File;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,21 +24,23 @@
use Psr\Log\LoggerInterface;
class AudioToTextSubtitlesProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private LoggerInterface $logger,
- private IAppConfig $appConfig,
private IL10N $l,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-audio2text-subtitles';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_STT);
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -46,7 +48,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -131,7 +133,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
$this->l->t('The input file type is invalid. Only audio or video files are allowed.'),
);
}
- if ($this->openAiAPIService->isUsingOpenAi()) {
+ if ($this->service->isUsingOpenAi()) {
$validFileTypes = [
'audio/mp3',
'audio/mp4',
@@ -156,10 +158,10 @@ public function process(?string $userId, array $input, callable $reportProgress)
throw new ProcessingException('Invalid language');
}
- $model = $this->appConfig->getValueString(Application::APP_ID, 'default_stt_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID;
+ $model = $this->model;
try {
- $transcription = $this->openAiAPIService->transcribeFile($userId, $inputFile, false, $model, $language, $format);
+ $transcription = $this->openAiAPIService->transcribeFile($userId, $this->service, $inputFile, false, $model, $language, $format);
return ['output' => $transcription];
} catch (UserFacingProcessingException $e) {
throw $e;
diff --git a/lib/TaskProcessing/ChangeToneProvider.php b/lib/TaskProcessing/ChangeToneProvider.php
index a0320fff..d473fef0 100644
--- a/lib/TaskProcessing/ChangeToneProvider.php
+++ b/lib/TaskProcessing/ChangeToneProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -25,22 +24,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextChangeTone;
class ChangeToneProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-changetone';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -48,7 +48,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -82,25 +82,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -140,13 +131,9 @@ public function process(
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
- $chunks = $this->chunkService->chunkSplitPrompt($textInput, true, $maxTokens);
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $textInput, true, $maxTokens);
$streamedOutput = '';
$streamedReasoning = '';
$fullOutput = '';
@@ -156,9 +143,9 @@ public function process(
foreach ($chunks as $textInput) {
$prompt = "Reformulate the following text in a $toneInput tone in its original language. Output only the reformulation. Here is the text:" . "\n\n" . $textInput . "\n\n" . 'Do not mention the used language in your reformulation. Here is your reformulation in the same language:';
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$time = microtime(true);
foreach ($chunks as $chunk) {
if (!in_array($chunk['kind'] ?? null, ['content', 'reasoning_content'], true)) {
@@ -194,12 +181,12 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
$reasoning = [];
}
} catch (UserFacingProcessingException $e) {
@@ -223,7 +210,7 @@ public function process(
throw new ProcessingException('No result in OpenAI/LocalAI response.');
}
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => $fullOutput,
'reasoning' => $fullReasoning,
diff --git a/lib/TaskProcessing/ContextWriteProvider.php b/lib/TaskProcessing/ContextWriteProvider.php
index 0df2982e..a0833628 100644
--- a/lib/TaskProcessing/ContextWriteProvider.php
+++ b/lib/TaskProcessing/ContextWriteProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,22 +23,23 @@
use OCP\TaskProcessing\TaskTypes\ContextWrite;
class ContextWriteProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private ChunkService $chunkService,
private IL10N $l,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-contextwrite';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,25 +65,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -127,13 +118,9 @@ public function process(
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
- $chunks = $this->chunkService->chunkSplitPrompt($sourceMaterial, true, $maxTokens);
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $sourceMaterial, true, $maxTokens);
$fullOutput = '';
$fullReasoning = '';
@@ -152,9 +139,9 @@ public function process(
. ' Also, use the *WRITING STYLE* as a guide for how to write the text ONLY and not as a source of facts or events.'
. ' Detect the language used in the *SOURCE_MATERIAL*. Make sure to use the same language in your response. Do not mention the language explicitly.';
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$time = microtime(true);
foreach ($chunks as $chunk) {
if (!in_array($chunk['kind'] ?? null, ['content', 'reasoning_content'], true)) {
@@ -190,12 +177,12 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
$reasoning = [];
}
} catch (UserFacingProcessingException $e) {
@@ -219,7 +206,7 @@ public function process(
throw new ProcessingException('No result in OpenAI/LocalAI response.');
}
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => $fullOutput,
'reasoning' => $fullReasoning,
diff --git a/lib/TaskProcessing/EmojiProvider.php b/lib/TaskProcessing/EmojiProvider.php
index 0d5225fe..8c777189 100644
--- a/lib/TaskProcessing/EmojiProvider.php
+++ b/lib/TaskProcessing/EmojiProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -21,21 +20,22 @@
use OCP\TaskProcessing\TaskTypes\GenerateEmoji;
class EmojiProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:emoji';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -43,7 +43,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -61,25 +61,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -109,18 +100,14 @@ public function process(?string $userId, array $input, callable $reportProgress)
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $completion['messages'];
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
}
} catch (UserFacingProcessingException $e) {
throw $e;
@@ -129,7 +116,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
if (count($completion) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => array_pop($completion)];
}
diff --git a/lib/TaskProcessing/HeadlineProvider.php b/lib/TaskProcessing/HeadlineProvider.php
index 8c1a7197..47efbee6 100644
--- a/lib/TaskProcessing/HeadlineProvider.php
+++ b/lib/TaskProcessing/HeadlineProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -21,21 +20,22 @@
use OCP\TaskProcessing\TaskTypes\TextToTextHeadline;
class HeadlineProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:headline';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -43,7 +43,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -61,25 +61,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -109,18 +100,14 @@ public function process(?string $userId, array $input, callable $reportProgress)
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $completion['messages'];
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
}
} catch (UserFacingProcessingException $e) {
throw $e;
@@ -129,7 +116,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
if (count($completion) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => array_pop($completion)];
}
diff --git a/lib/TaskProcessing/ImageToTextOcrProvider.php b/lib/TaskProcessing/ImageToTextOcrProvider.php
index d7113640..7d91ed8f 100644
--- a/lib/TaskProcessing/ImageToTextOcrProvider.php
+++ b/lib/TaskProcessing/ImageToTextOcrProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,22 +23,23 @@
use Psr\Log\LoggerInterface;
class ImageToTextOcrProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private LoggerInterface $logger,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-image2text-ocr';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,25 +65,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the output.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the output'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -105,7 +96,7 @@ public function process(
$reportOutput = $options->getReportIntermediateOutput();
$preferStreaming = $options->getPreferStreaming();
- if (!$this->openAiAPIService->isUsingOpenAi() && !$this->openAiSettingsService->getChatEndpointEnabled()) {
+ if (!$this->service->isUsingOpenAi() && !$this->service->getChatEndpointEnabled()) {
throw new ProcessingException('Must support chat completion endpoint');
}
@@ -118,11 +109,7 @@ public function process(
$files = $input['input'];
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
$maxTokens = null;
if (isset($input['max_tokens']) && is_int($input['max_tokens'])) {
@@ -157,7 +144,7 @@ function ($carry, $file) {
try {
if ($preferStreaming) {
$chunks = $this->openAiAPIService->createStreamedChatCompletion(
- $userId, $model, $userPrompt, $systemPrompt, null, 1, $maxTokens, null, null, null, [$file],
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, null, 1, $maxTokens, null, null, null, [$file],
);
$time = microtime(true);
foreach ($chunks as $chunk) {
@@ -188,7 +175,7 @@ function ($carry, $file) {
$messages = $returnValue['messages'];
} else {
$completion = $this->openAiAPIService->createChatCompletion(
- $userId, $model, $userPrompt, $systemPrompt, null, 1, $maxTokens, null, null, null, [$file],
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, null, 1, $maxTokens, null, null, null, [$file],
);
$messages = $completion['messages'];
}
diff --git a/lib/TaskProcessing/MultimodalChatWithToolsProvider.php b/lib/TaskProcessing/MultimodalChatWithToolsProvider.php
index 36201610..f460bdd2 100644
--- a/lib/TaskProcessing/MultimodalChatWithToolsProvider.php
+++ b/lib/TaskProcessing/MultimodalChatWithToolsProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCA\OpenAi\Service\WatermarkingService;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
@@ -25,24 +24,26 @@
use Psr\Log\LoggerInterface;
class MultimodalChatWithToolsProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
private const MAX_INPUT_ATTACHMENTS = 10;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private LoggerInterface $logger,
private WatermarkingService $watermarkingService,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:multimodal-chatwithtools';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -50,7 +51,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -103,7 +104,6 @@ public function process(
$reportOutput = $options->getReportIntermediateOutput();
$preferStreaming = $options->getPreferStreaming();
$startTime = time();
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
if (!isset($input['input']) || !is_string($input['input'])) {
throw new ProcessingException('Invalid input');
@@ -159,7 +159,7 @@ public function process(
try {
if ($preferStreaming) {
$chunks = $this->openAiAPIService->createStreamedChatCompletion(
- $userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
);
$time = microtime(true);
$streamedOutput = '';
@@ -197,7 +197,7 @@ public function process(
$returnValue = $chunks->getReturn();
} else {
$returnValue = $this->openAiAPIService->createChatCompletion(
- $userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
);
}
} catch (UserFacingProcessingException $e) {
@@ -207,7 +207,7 @@ public function process(
}
if (count($returnValue['messages']) > 0 || count($returnValue['tool_calls']) > 0 || count($returnValue['images']) > 0 || count($returnValue['audio_messages']) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
$attachments = [];
// Handle image output
diff --git a/lib/TaskProcessing/ProofreadProvider.php b/lib/TaskProcessing/ProofreadProvider.php
index 6e02d061..9040da2c 100644
--- a/lib/TaskProcessing/ProofreadProvider.php
+++ b/lib/TaskProcessing/ProofreadProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,22 +22,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextProofread;
class ProofreadProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:proofread';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -46,7 +46,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -69,11 +69,6 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
@@ -84,16 +79,13 @@ public function getOptionalInputShapeEnumValues(): array {
new ShapeEnumValue($this->l->t('Standard'), 'standard'),
new ShapeEnumValue($this->l->t('Strict'), 'strict'),
],
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
'strictness' => 'standard',
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -127,25 +119,21 @@ public function process(?string $userId, array $input, callable $reportProgress)
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
- $chunks = $this->chunkService->chunkSplitPrompt($textInput, true, $maxTokens);
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $textInput, true, $maxTokens);
$result = '';
$increase = 1.0 / ((float)count($chunks) + 1.0);
$progress = 0.0;
foreach ($chunks as $textInput) {
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $textInput, $systemPrompt, null, 1, $maxTokens);
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $textInput, $systemPrompt, null, 1, $maxTokens);
$completion = $completion['messages'];
} else {
$prompt = $systemPrompt . ' Here is the text:' . "\n\n" . $textInput;
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
}
} catch (UserFacingProcessingException $e) {
throw $e;
@@ -167,12 +155,12 @@ public function process(?string $userId, array $input, callable $reportProgress)
if (count($chunks) > 1) {
$systemPrompt = 'Repeat the proofread feedback list. Ensure that no information is lost, but also not duplicated. ';
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $result, $systemPrompt, null, 1, $maxTokens);
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $result, $systemPrompt, null, 1, $maxTokens);
$completion = $completion['messages'];
} else {
$prompt = $systemPrompt . ' Here is the text:' . "\n\n" . $result;
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
}
} catch (UserFacingProcessingException $e) {
throw $e;
@@ -186,7 +174,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
$progress += $increase;
$reportProgress($progress);
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => $result];
}
diff --git a/lib/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php
new file mode 100644
index 00000000..762df420
--- /dev/null
+++ b/lib/TaskProcessing/ProviderFactory.php
@@ -0,0 +1,201 @@
+servicesService->getServices() as $service) {
+ foreach ($service->getModels(Application::MODALITY_TEXT) as $model) {
+ array_push($providers, ...$this->getTextProviders($service, $model));
+ }
+ foreach ($service->getModels(Application::MODALITY_IMAGE) as $model) {
+ array_push($providers, ...$this->getImageProviders($service, $model));
+ }
+ foreach ($service->getModels(Application::MODALITY_STT) as $model) {
+ array_push($providers, ...$this->getSttProviders($service, $model));
+ }
+ foreach ($service->getModels(Application::MODALITY_TTS) as $model) {
+ array_push($providers, ...$this->getTtsProviders($service, $model));
+ }
+ }
+ return $providers;
+ }
+
+ /**
+ * Every text task type, for one text model
+ *
+ * @return IProvider[]
+ */
+ private function getTextProviders(ServiceConfig $service, string $model): array {
+ $providers = [
+ new TextToTextProvider($this->openAiAPIService, $this->l, $service, $model),
+ new TextToTextChatProvider($this->openAiAPIService, $this->l, $service, $model),
+ new TextToTextChatWithToolsProvider($this->openAiAPIService, $this->l, $service, $model),
+ new SummaryProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model),
+ new HeadlineProvider($this->openAiAPIService, $this->l, $service, $model),
+ new TopicsProvider($this->openAiAPIService, $this->l, $this->chunkService, $this->logger, $service, $model),
+ new ContextWriteProvider($this->openAiAPIService, $this->chunkService, $this->l, $service, $model),
+ new ReformulateProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model),
+ new TextToTextImproveProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model),
+ new EmojiProvider($this->openAiAPIService, $this->l, $service, $model),
+ new ChangeToneProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model),
+ new ProofreadProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model),
+ new MultimodalChatWithToolsProvider(
+ $this->openAiAPIService, $this->l, $this->logger, $this->watermarkingService, $service, $model,
+ ),
+ ];
+ if ($service->getTranslationEnabled()) {
+ $providers[] = new TranslateProvider(
+ $this->openAiAPIService, $this->l, $this->translateService, $service, $model,
+ );
+ }
+ if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextReformatParagraphs')) {
+ $providers[] = new ReformatParagraphsProvider(
+ $this->openAiAPIService, $this->l, $this->chunkService, $service, $model,
+ );
+ }
+ if ($service->getMultimodalImageEnabled()) {
+ $providers[] = new ImageToTextOcrProvider($this->openAiAPIService, $this->l, $this->logger, $service, $model);
+ $providers[] = new AnalyzeImagesProvider($this->openAiAPIService, $this->l, $this->logger, $service, $model);
+ }
+ // The integrated audio-in/audio-out chat endpoint needs the input
+ // transcript, which is a separate speech-to-text request
+ $sttModel = $service->getFirstModel(Application::MODALITY_STT);
+ if (
+ $service->getMultimodalAudioEnabled()
+ && $sttModel !== null
+ && class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')
+ ) {
+ $providers[] = new AudioToAudioChatProvider(
+ $this->openAiAPIService, $this->l, $this->logger, $service, $model,
+ $sttModel, $service->getFirstModel(Application::MODALITY_TTS),
+ );
+ }
+ return $providers;
+ }
+
+ /**
+ * Every image task type, for one image model
+ *
+ * @return IProvider[]
+ */
+ private function getImageProviders(ServiceConfig $service, string $model): array {
+ $textToImage = new TextToImageProvider(
+ $this->openAiAPIService, $this->l, $this->logger, $this->clientService,
+ $this->watermarkingService, $service, $model,
+ );
+ $providers = [$textToImage];
+ // The prompt improvement needs a text model of the same service
+ $textModel = $service->getFirstModel(Application::MODALITY_TEXT);
+ if ($textModel !== null) {
+ $providers[] = new TextToImageImprovedPromptProvider(
+ $textToImage,
+ new TextToTextProvider($this->openAiAPIService, $this->l, $service, $textModel),
+ $this->logger, $this->l, $this->openAiAPIService, $service,
+ );
+ }
+ return $providers;
+ }
+
+ /**
+ * Every speech-to-text task type, for one transcription model
+ *
+ * @return IProvider[]
+ */
+ private function getSttProviders(ServiceConfig $service, string $model): array {
+ $audioToText = new AudioToTextProvider($this->openAiAPIService, $this->logger, $this->l, $service, $model);
+ $providers = [$audioToText];
+ if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToTextSubtitles')) {
+ $providers[] = new AudioToTextSubtitlesProvider(
+ $this->openAiAPIService, $this->logger, $this->l, $service, $model,
+ );
+ }
+
+ // The following ones need a text model of the same service
+ $textModel = $service->getFirstModel(Application::MODALITY_TEXT);
+ if ($textModel === null) {
+ return $providers;
+ }
+ if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextReformatParagraphs')) {
+ $providers[] = new AudioToTextEnhancedProvider(
+ $audioToText,
+ new ReformatParagraphsProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $textModel),
+ $this->openAiAPIService, $this->logger, $this->l, $service,
+ );
+ }
+ // ... and speech generation on top of that
+ $ttsModel = $service->getFirstModel(Application::MODALITY_TTS);
+ if ($ttsModel !== null && $service->getTranslationEnabled()) {
+ $providers[] = new AudioToAudioTranslateProvider(
+ $this->openAiAPIService, $this->translateService, $this->watermarkingService,
+ $this->logger, $this->l10nFactory, $this->l, $this->userManager,
+ $service, $model, $textModel, $ttsModel,
+ );
+ }
+ return $providers;
+ }
+
+ /**
+ * Every text-to-speech task type, for one speech model
+ *
+ * @return IProvider[]
+ */
+ private function getTtsProviders(ServiceConfig $service, string $model): array {
+ return [
+ new TextToSpeechProvider(
+ $this->openAiAPIService, $this->l, $this->logger,
+ $this->watermarkingService, $service, $model,
+ ),
+ ];
+ }
+
+}
diff --git a/lib/TaskProcessing/ProviderIdentity.php b/lib/TaskProcessing/ProviderIdentity.php
new file mode 100644
index 00000000..20fc54a4
--- /dev/null
+++ b/lib/TaskProcessing/ProviderIdentity.php
@@ -0,0 +1,65 @@
+service->getId()
+ . '-' . self::slugifyModel($this->model)
+ . '-' . self::slugifyTaskType($this->getTaskTypeId());
+ }
+
+ /**
+ * Providers are named after the model they use, so the admin can tell them
+ * apart in the AI admin settings
+ */
+ protected function buildProviderName(): string {
+ return $this->model . ' (' . $this->service->getDisplayName() . ')';
+ }
+
+ /**
+ * Model names can contain characters that don't belong in an ID (OpenRouter
+ * model names contain slashes, for example)
+ */
+ public static function slugifyModel(string $model): string {
+ return preg_replace('/[^A-Za-z0-9._-]/', '_', $model) ?? $model;
+ }
+
+ /**
+ * The task type ID as it appears in a provider ID: the `core:` prefix of
+ * the task types the server ships is dropped, because every provider ID
+ * already says which app it belongs to. The prefix of a task type defined
+ * by another app is kept, so that two of them cannot collide.
+ */
+ public static function slugifyTaskType(string $taskTypeId): string {
+ return str_starts_with($taskTypeId, 'core:') ? substr($taskTypeId, 5) : $taskTypeId;
+ }
+}
diff --git a/lib/TaskProcessing/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php
index 8b5d02ae..acdd2cb1 100644
--- a/lib/TaskProcessing/ReformatParagraphsProvider.php
+++ b/lib/TaskProcessing/ReformatParagraphsProvider.php
@@ -9,11 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
-use OCP\IAppConfig;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -22,6 +20,8 @@
use OCP\TaskProcessing\ShapeDescriptor;
class ReformatParagraphsProvider implements ISynchronousProvider {
+ use ProviderIdentity;
+
private const TASK_TYPE_ID = 'core:text2text:reformatparagraphs';
private function parseAnchorsFromModelOutput(string $raw): array {
@@ -72,20 +72,19 @@ private function insertParagraphBreaksByAnchors(string $text, array $anchors): s
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private IAppConfig $appConfig,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:reformatparagraphs';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -93,7 +92,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -111,25 +110,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -158,12 +148,8 @@ public function process(?string $userId, array $input, callable $reportProgress)
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
- $chunks = $this->chunkService->chunkSplitPrompt($prompt, false);
+ $model = $this->model;
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $prompt, false);
$result = '';
$increase = 1.0 / (float)count($chunks);
$progress = 0.0;
@@ -183,12 +169,12 @@ public function process(?string $userId, array $input, callable $reportProgress)
Aside from transportation, the price
TEXT;
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $chunk, $systemPrompt, null, 1, $maxTokens);
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $chunk, $systemPrompt, null, 1, $maxTokens);
$completion = $completion['messages'];
} else {
$instruction = $systemPrompt . ' Here is the text:' . "\n\n" . $chunk;
- $completion = $this->openAiAPIService->createCompletion($userId, $instruction, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $instruction, 1, $model, $maxTokens);
}
} catch (UserFacingProcessingException $e) {
throw $e;
@@ -212,7 +198,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => $result];
}
}
diff --git a/lib/TaskProcessing/ReformulateProvider.php b/lib/TaskProcessing/ReformulateProvider.php
index f1056ec0..3f486597 100644
--- a/lib/TaskProcessing/ReformulateProvider.php
+++ b/lib/TaskProcessing/ReformulateProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,22 +23,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextReformulation;
class ReformulateProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-reformulate';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,25 +65,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -122,12 +113,8 @@ public function process(
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
- $chunks = $this->chunkService->chunkSplitPrompt($prompt, true, $maxTokens);
+ $model = $this->model;
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $prompt, true, $maxTokens);
$fullOutput = '';
$fullReasoning = '';
$increase = 1.0 / (float)count($chunks);
@@ -138,9 +125,9 @@ public function process(
foreach ($chunks as $chunk) {
$prompt = 'Reformulate the following text. Use the same language as the original text. Output only the reformulation. Here is the text:' . "\n\n" . $chunk . "\n\n" . 'Do not mention the used language in your reformulation. Here is your reformulation in the same language:';
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$time = microtime(true);
foreach ($chunks as $chunk) {
if (!in_array($chunk['kind'] ?? null, ['content', 'reasoning_content'], true)) {
@@ -176,12 +163,12 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
$reasoning = [];
}
} catch (UserFacingProcessingException $e) {
@@ -206,7 +193,7 @@ public function process(
}
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => $fullOutput,
'reasoning' => $fullReasoning,
diff --git a/lib/TaskProcessing/SummaryProvider.php b/lib/TaskProcessing/SummaryProvider.php
index ab9dce24..f84709f5 100644
--- a/lib/TaskProcessing/SummaryProvider.php
+++ b/lib/TaskProcessing/SummaryProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,22 +22,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
class SummaryProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:summary';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -46,7 +46,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -74,17 +74,11 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
'format' => [
new ShapeEnumValue($this->l->t('Auto'), 'auto'),
new ShapeEnumValue($this->l->t('One Sentence'), 'sentence'),
@@ -100,10 +94,8 @@ public function getOptionalInputShapeEnumValues(): array {
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
'format' => 'auto',
'complexity' => 'medium',
];
@@ -129,17 +121,14 @@ public function process(?string $userId, array $input, callable $reportProgress)
}
$prompt = $input['input'];
- $maxTokens = $this->openAiSettingsService->getMaxTokens();
+ $maxTokens = $this->service->getMaxTokens();
if (isset($input['max_tokens']) && is_int($input['max_tokens'])) {
$maxTokens = $input['max_tokens'];
}
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- }
+ $model = $this->model;
- $prompts = $this->chunkService->chunkSplitPrompt($prompt);
+ $prompts = $this->chunkService->chunkSplitPrompt($this->service, $prompt);
$newNumChunks = count($prompts);
$progress = 0.0;
do {
@@ -171,10 +160,10 @@ public function process(?string $userId, array $input, callable $reportProgress)
$summarySystemPrompt .= 'Use simple language and vocabulary appropriate for a 5 year old. ';
}
}
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
foreach ($prompts as $p) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $p, $summarySystemPrompt, null, 1, $maxTokens);
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $p, $summarySystemPrompt, null, 1, $maxTokens);
$completions[] = $completion['messages'];
$progress += $increase;
$running = $reportProgress($progress);
@@ -187,7 +176,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
. 'Here is the text to summarize:\n\n' . $p . '\n';
foreach (array_map($wrapSummaryPrompt, $prompts) as $p) {
- $completions[] = $this->openAiAPIService->createCompletion($userId, $p, 1, $model, $maxTokens);
+ $completions[] = $this->openAiAPIService->createCompletion($userId, $this->service, $p, 1, $model, $maxTokens);
$progress += $increase;
$running = $reportProgress($progress);
if (!$running) {
@@ -214,12 +203,12 @@ public function process(?string $userId, array $input, callable $reportProgress)
));
$summary = implode(' ', $completionStrings);
- $prompts = $this->chunkService->chunkSplitPrompt($summary);
+ $prompts = $this->chunkService->chunkSplitPrompt($this->service, $summary);
$newNumChunks = count($prompts);
} while ($oldNumChunks > $newNumChunks);
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => $summary];
}
diff --git a/lib/TaskProcessing/TextToImageImprovedPromptProvider.php b/lib/TaskProcessing/TextToImageImprovedPromptProvider.php
index 8b795026..ad2a3d50 100644
--- a/lib/TaskProcessing/TextToImageImprovedPromptProvider.php
+++ b/lib/TaskProcessing/TextToImageImprovedPromptProvider.php
@@ -9,26 +9,34 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
-use OCP\TaskProcessing\IManager;
use OCP\TaskProcessing\ISynchronousWatermarkingProvider;
use OCP\TaskProcessing\ShapeDescriptor;
-use OCP\TaskProcessing\Task;
use OCP\TaskProcessing\TaskTypes\TextToImage;
-use OCP\TaskProcessing\TaskTypes\TextToText;
use Psr\Log\LoggerInterface;
use Throwable;
+/**
+ * Generates an image from a prompt that an LLM improved first.
+ *
+ * Both steps run on the same service: the image with the model this provider
+ * is registered for, the prompt improvement with the first text model selected
+ * for that service.
+ */
class TextToImageImprovedPromptProvider implements ISynchronousWatermarkingProvider {
+ // No ProviderIdentity: this provider derives its ID and name from the
+ // image provider it wraps rather than from a model of its own.
+
public function __construct(
private TextToImageProvider $textToImageProvider,
- private IManager $taskProcessingManager,
+ private TextToTextProvider $textToTextProvider,
private LoggerInterface $logger,
private IL10N $l10n,
private OpenAiAPIService $openAiAPIService,
+ private ServiceConfig $service,
) {
}
@@ -46,7 +54,7 @@ public function getTaskTypeId(): string {
public function getExpectedRuntime(): int {
// The text to image provider may not be openai and this assumes it is
- return $this->textToImageProvider->getExpectedRuntime() + $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->textToImageProvider->getExpectedRuntime() + $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -98,25 +106,16 @@ public function process(?string $userId, array $input, callable $reportProgress,
. 'Add concrete visual details (subject, composition, lighting, style) only when they are reasonable. '
. 'Keep the original intent. Return ONLY the improved prompt as a single line, no preface, no quotes, no explanations.' . "\n\n"
. 'Original prompt:' . "\n" . $originalPrompt;
- $improveTask = new Task(
- TextToText::ID,
- ['input' => $instruction],
- Application::APP_ID,
- $userId,
- 'text2image_improved_prompt',
- );
-
$improvedPrompt = $originalPrompt;
try {
- $finished = $this->taskProcessingManager->runTask($improveTask);
- $output = $finished->getOutput();
- if (is_array($output) && isset($output['output']) && is_string($output['output']) && trim($output['output']) !== '') {
+ $output = $this->textToTextProvider->process($userId, ['input' => $instruction], $reportProgress);
+ if (isset($output['output']) && is_string($output['output']) && trim($output['output']) !== '') {
$improvedPrompt = trim($output['output']);
} else {
- $this->logger->warning('Prompt improvement task returned no usable output, falling back to original prompt');
+ $this->logger->warning('Prompt improvement returned no usable output, falling back to original prompt');
}
} catch (Throwable $e) {
- $this->logger->warning('Prompt improvement task failed, falling back to original prompt: ' . $e->getMessage(), ['exception' => $e]);
+ $this->logger->warning('Prompt improvement failed, falling back to original prompt: ' . $e->getMessage(), ['exception' => $e]);
}
$reportProgress(0.5);
diff --git a/lib/TaskProcessing/TextToImageProvider.php b/lib/TaskProcessing/TextToImageProvider.php
index c7e52d28..33396af3 100644
--- a/lib/TaskProcessing/TextToImageProvider.php
+++ b/lib/TaskProcessing/TextToImageProvider.php
@@ -11,9 +11,9 @@
use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCA\OpenAi\Service\WatermarkingService;
use OCP\Http\Client\IClientService;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,24 +24,25 @@
use Psr\Log\LoggerInterface;
class TextToImageProvider implements ISynchronousWatermarkingProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private IL10N $l,
private LoggerInterface $logger,
private IClientService $clientService,
- private IAppConfig $appConfig,
- private ?string $userId,
private WatermarkingService $watermarkingService,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2image';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_IMAGE);
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -49,7 +50,9 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ // this provider feeds updateExpImgProcessingTime() below, so the
+ // estimate has to be read back from the same place
+ return $this->openAiAPIService->getExpImgProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -63,34 +66,22 @@ public function getInputShapeDefaults(): array {
}
public function getOptionalInputShape(): array {
- $defaultImageSize = $this->appConfig->getValueString(Application::APP_ID, 'default_image_size', lazy: true) ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE;
+ $defaultImageSize = $this->service->getDefaultImageSize();
return [
'size' => new ShapeDescriptor(
$this->l->t('Size'),
$this->l->t('Optional. The size of the generated images. Must be in 256x256 format. Default is %s', [$defaultImageSize]),
EShapeType::Text
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the images'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId, serviceType: Application::SERVICE_TYPE_IMAGE),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiAPIService->isUsingOpenAi(Application::SERVICE_TYPE_IMAGE)
- ? ($this->appConfig->getValueString(Application::APP_ID, 'default_image_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID)
- : $this->appConfig->getValueString(Application::APP_ID, 'default_image_model_id', lazy: true);
- return [
- 'model' => $adminModel,
- ];
+ return [];
}
public function getOutputShapeEnumValues(): array {
@@ -124,23 +115,23 @@ public function process(?string $userId, array $input, callable $reportProgress,
throw new UserFacingProcessingException('numberOfImages is out of bounds', userFacingMessage: $this->l->t('Cannot generate less than 1 image'));
}
- $size = $this->appConfig->getValueString(Application::APP_ID, 'default_image_size', lazy: true) ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE;
+ $size = $this->service->getDefaultImageSize();
if (isset($input['size']) && is_string($input['size']) && preg_match('/^\d+x\d+$/', $input['size'])) {
$size = trim($input['size']);
}
+ if (preg_match('/^\d+x\d+$/', $size) !== 1) {
+ // the service is misconfigured, fall back to the default rather
+ // than sending a size the API cannot parse
+ $size = Application::DEFAULT_DEFAULT_IMAGE_SIZE;
+ }
[$x, $y] = explode('x', $size, 2);
if ((int)$x > 4096 || (int)$y > 4096) {
throw new UserFacingProcessingException('size is out of bounds', userFacingMessage: $this->l->t('Cannot generate images larger than 4096x4096'));
}
-
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->appConfig->getValueString(Application::APP_ID, 'default_image_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID;
- }
+ $model = $this->model;
try {
- $apiResponse = $this->openAiAPIService->requestImageCreation($userId, $prompt, $model, $nbImages, $size);
+ $apiResponse = $this->openAiAPIService->requestImageCreation($userId, $this->service, $prompt, $model, $nbImages, $size);
$b64s = array_map(static function (array $result) {
return $result['b64_json'] ?? null;
}, $apiResponse['data']);
@@ -162,7 +153,7 @@ public function process(?string $userId, array $input, callable $reportProgress,
throw new ProcessingException('OpenAI/LocalAI\'s text to image generation failed: no image returned');
}
$client = $this->clientService->newClient();
- $requestOptions = $this->openAiAPIService->getImageRequestOptions($userId);
+ $requestOptions = $this->openAiAPIService->getImageRequestOptions($userId, $this->service);
$output = ['images' => []];
foreach ($urls as $url) {
$imageResponse = $client->get($url, $requestOptions);
@@ -176,7 +167,7 @@ public function process(?string $userId, array $input, callable $reportProgress,
$output['images'][] = $image;
}
$endTime = time();
- $this->openAiAPIService->updateExpImgProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpImgProcessingTime($endTime - $startTime, $this->service);
/** @var array|numeric|string> $output */
return $output;
} catch (UserFacingProcessingException $e) {
diff --git a/lib/TaskProcessing/TextToSpeechProvider.php b/lib/TaskProcessing/TextToSpeechProvider.php
index 687fb4f7..6ed8df6f 100644
--- a/lib/TaskProcessing/TextToSpeechProvider.php
+++ b/lib/TaskProcessing/TextToSpeechProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCA\OpenAi\Service\WatermarkingService;
-use OCP\IAppConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,23 +22,24 @@
use Psr\Log\LoggerInterface;
class TextToSpeechProvider implements ISynchronousWatermarkingProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
private IL10N $l,
private LoggerInterface $logger,
- private IAppConfig $appConfig,
- private ?string $userId,
private WatermarkingService $watermarkingService,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2speech';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_TTS);
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,14 +65,9 @@ public function getOptionalInputShape(): array {
$this->l->t('The voice to use'),
EShapeType::Enum
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the speech'),
- EShapeType::Enum
- ),
'speed' => new ShapeDescriptor(
$this->l->t('Speed'),
- $this->openAiAPIService->isUsingOpenAi(Application::SERVICE_TYPE_TTS)
+ $this->service->isUsingOpenAi()
? $this->l->t('Speech speed modifier (Valid values: 0.25-4)')
: $this->l->t('Speech speed modifier'),
EShapeType::Number
@@ -81,21 +76,17 @@ public function getOptionalInputShape(): array {
}
public function getOptionalInputShapeEnumValues(): array {
- $voices = json_decode($this->appConfig->getValueString(Application::APP_ID, 'tts_voices', lazy: true)) ?: Application::DEFAULT_SPEECH_VOICES;
return [
- 'voice' => array_map(function ($v) {
- return new ShapeEnumValue($v, $v);
- }, $voices),
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId, Application::SERVICE_TYPE_TTS),
+ 'voice' => array_map(
+ static fn (string $voice) => new ShapeEnumValue($voice, $voice),
+ $this->service->getTtsVoices(),
+ ),
];
}
public function getOptionalInputShapeDefaults(): array {
- $adminVoice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- $adminModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
return [
- 'voice' => $adminVoice,
- 'model' => $adminModel,
+ 'voice' => $this->service->getDefaultTtsVoice(),
'speed' => 1,
];
}
@@ -123,23 +114,16 @@ public function process(?string $userId, array $input, callable $reportProgress,
if ($includeWatermark) {
$prompt .= "\n\n" . $this->l->t('This was generated using Artificial Intelligence.');
}
+ $model = $this->model;
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id', Application::DEFAULT_SPEECH_MODEL_ID, lazy: true) ?: Application::DEFAULT_SPEECH_MODEL_ID;
- }
-
- if (isset($input['voice']) && is_string($input['voice'])) {
- $voice = $input['voice'];
- } else {
- $voice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice', Application::DEFAULT_SPEECH_VOICE, lazy: true) ?: Application::DEFAULT_SPEECH_VOICE;
- }
+ $voice = isset($input['voice']) && is_string($input['voice'])
+ ? $input['voice']
+ : $this->service->getDefaultTtsVoice();
$speed = 1;
if (isset($input['speed']) && is_numeric($input['speed'])) {
$speed = $input['speed'];
- if ($this->openAiAPIService->isUsingOpenAi(Application::SERVICE_TYPE_TTS)) {
+ if ($this->service->isUsingOpenAi()) {
if ($speed > 4) {
$speed = 4;
} elseif ($speed < 0.25) {
@@ -149,7 +133,7 @@ public function process(?string $userId, array $input, callable $reportProgress,
}
try {
- $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $prompt, $model, $voice, $speed);
+ $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $this->service, $prompt, $model, $voice, $speed);
if (!isset($apiResponse['body'])) {
$this->logger->warning('OpenAI/LocalAI\'s text to speech generation failed: no speech returned');
diff --git a/lib/TaskProcessing/TextToTextChatProvider.php b/lib/TaskProcessing/TextToTextChatProvider.php
index 7222af40..ad64b833 100644
--- a/lib/TaskProcessing/TextToTextChatProvider.php
+++ b/lib/TaskProcessing/TextToTextChatProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,20 +22,22 @@
use OCP\TaskProcessing\TaskTypes\TextToTextChat;
class TextToTextChatProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:chat';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -44,7 +45,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -102,7 +103,6 @@ public function process(
$reportOutput = $options->getReportIntermediateOutput();
$preferStreaming = $options->getPreferStreaming();
$startTime = time();
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
if (!isset($input['input']) || !is_string($input['input'])) {
throw new ProcessingException('Invalid input');
@@ -131,7 +131,7 @@ public function process(
try {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens);
$time = microtime(true);
$streamedOutput = '';
$streamedReasoning = '';
@@ -169,7 +169,7 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
@@ -180,7 +180,7 @@ public function process(
}
if (count($completion) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => array_pop($completion),
'reasoning' => count($reasoning) > 0 ? array_pop($reasoning) : '',
diff --git a/lib/TaskProcessing/TextToTextChatWithToolsProvider.php b/lib/TaskProcessing/TextToTextChatWithToolsProvider.php
index a02e6dca..d90cd00a 100644
--- a/lib/TaskProcessing/TextToTextChatWithToolsProvider.php
+++ b/lib/TaskProcessing/TextToTextChatWithToolsProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,20 +22,22 @@
use OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools;
class TextToTextChatWithToolsProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:chatwithtools';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -44,7 +45,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -97,7 +98,6 @@ public function process(
$reportOutput = $options->getReportIntermediateOutput();
$preferStreaming = $options->getPreferStreaming();
$startTime = time();
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
if (!isset($input['input']) || !is_string($input['input'])) {
throw new ProcessingException('Invalid input');
@@ -141,7 +141,7 @@ public function process(
try {
if ($preferStreaming) {
$chunks = $this->openAiAPIService->createStreamedChatCompletion(
- $userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools
);
$time = microtime(true);
$streamedOutput = '';
@@ -179,7 +179,7 @@ public function process(
$returnValue = $chunks->getReturn();
} else {
$returnValue = $this->openAiAPIService->createChatCompletion(
- $userId, $adminModel, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools
+ $userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools
);
}
} catch (UserFacingProcessingException $e) {
@@ -189,7 +189,7 @@ public function process(
}
if (count($returnValue['messages']) > 0 || count($returnValue['tool_calls']) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => array_pop($returnValue['messages']) ?? '',
'reasoning' => count($returnValue['reasoning_messages']) > 0 ? array_pop($returnValue['reasoning_messages']) : '',
diff --git a/lib/TaskProcessing/TextToTextImproveProvider.php b/lib/TaskProcessing/TextToTextImproveProvider.php
index 86c4e12c..e9313a0e 100644
--- a/lib/TaskProcessing/TextToTextImproveProvider.php
+++ b/lib/TaskProcessing/TextToTextImproveProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -24,22 +23,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextImprove;
class TextToTextImproveProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-improve';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,25 +65,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -130,12 +121,8 @@ public function process(
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
- $chunks = $this->chunkService->chunkSplitPrompt($textInput, true, $maxTokens);
+ $model = $this->model;
+ $chunks = $this->chunkService->chunkSplitPrompt($this->service, $textInput, true, $maxTokens);
$fullOutput = '';
$fullReasoning = '';
$increase = 1.0 / (float)count($chunks);
@@ -148,9 +135,9 @@ public function process(
. "\n\n*INSTRUCTIONS*:\n$instructions\n\n*TEXT*:\n\n$chunk\n\n"
. 'Do not mention the used language in your output. Here is your improved text in the same language:';
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$time = microtime(true);
foreach ($chunks as $chunk) {
if (!in_array($chunk['kind'] ?? null, ['content', 'reasoning_content'], true)) {
@@ -186,12 +173,12 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
$reasoning = [];
}
} catch (UserFacingProcessingException $e) {
@@ -216,7 +203,7 @@ public function process(
}
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => $fullOutput,
'reasoning' => $fullReasoning,
diff --git a/lib/TaskProcessing/TextToTextProvider.php b/lib/TaskProcessing/TextToTextProvider.php
index c80d44cb..aa1b08d7 100644
--- a/lib/TaskProcessing/TextToTextProvider.php
+++ b/lib/TaskProcessing/TextToTextProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,21 +22,22 @@
use OCP\TaskProcessing\TaskTypes\TextToText;
class TextToTextProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -45,7 +45,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -63,25 +63,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number,
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum,
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -120,16 +111,12 @@ public function process(
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
try {
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
if ($preferStreaming) {
- $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$time = microtime(true);
$streamedOutput = '';
$streamedReasoning = '';
@@ -167,12 +154,12 @@ public function process(
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
- $returnValue = $this->openAiAPIService->createChatCompletion($userId, $model, $prompt, null, null, 1, $maxTokens);
+ $returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, null, null, 1, $maxTokens);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
} else {
- $completion = $this->openAiAPIService->createCompletion($userId, $prompt, 1, $model, $maxTokens);
+ $completion = $this->openAiAPIService->createCompletion($userId, $this->service, $prompt, 1, $model, $maxTokens);
$reasoning = [];
}
} catch (UserFacingProcessingException $e) {
@@ -182,7 +169,7 @@ public function process(
}
if (count($completion) > 0) {
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return [
'output' => array_pop($completion),
'reasoning' => count($reasoning) > 0 ? array_pop($reasoning) : '',
diff --git a/lib/TaskProcessing/TopicsProvider.php b/lib/TaskProcessing/TopicsProvider.php
index 21074b3b..456ab150 100644
--- a/lib/TaskProcessing/TopicsProvider.php
+++ b/lib/TaskProcessing/TopicsProvider.php
@@ -9,10 +9,9 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\ChunkService;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\Exception\ProcessingException;
@@ -23,23 +22,24 @@
use Psr\Log\LoggerInterface;
class TopicsProvider implements ISynchronousProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private ChunkService $chunkService,
private LoggerInterface $logger,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-text2text:topics';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -47,7 +47,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -65,25 +65,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -112,12 +103,8 @@ public function process(?string $userId, array $input, callable $reportProgress)
$maxTokens = $input['max_tokens'];
}
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
- $prompts = $this->chunkService->chunkSplitPrompt($prompt);
+ $model = $this->model;
+ $prompts = $this->chunkService->chunkSplitPrompt($this->service, $prompt);
$newNumChunks = count($prompts);
$progress = 0.0;
$firstRun = true;
@@ -136,11 +123,11 @@ public function process(?string $userId, array $input, callable $reportProgress)
try {
$completions = [];
- if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) {
+ if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) {
$topicsSystemPrompt = 'Extract topics from the following text. Detect the language of the text. Use the same language as the text. Output only the topics, comma separated.';
foreach ($prompts as $p) {
- $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $p, $topicsSystemPrompt, null, 1, $maxTokens);
+ $completion = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $p, $topicsSystemPrompt, null, 1, $maxTokens);
$completions[] = $completion['messages'];
$progress += $increase;
$running = $reportProgress($progress);
@@ -155,7 +142,7 @@ public function process(?string $userId, array $input, callable $reportProgress)
};
foreach (array_map($wrapTopicsPrompt, $prompts) as $p) {
- $completions[] = $this->openAiAPIService->createCompletion($userId, $p, 1, $model, $maxTokens);
+ $completions[] = $this->openAiAPIService->createCompletion($userId, $this->service, $p, 1, $model, $maxTokens);
$progress += $increase;
$running = $reportProgress($progress);
if (!$running) {
@@ -179,13 +166,13 @@ public function process(?string $userId, array $input, callable $reportProgress)
$completionStrings = array_map(fn (array $completions): string => trim(array_pop($completions)), $completions);
$topics = implode(', ', $completionStrings);
- $prompts = $this->chunkService->chunkSplitPrompt($topics);
+ $prompts = $this->chunkService->chunkSplitPrompt($this->service, $topics);
$this->logger->error('TopicsProvider(dsadsaads): ' . $topics);
$newNumChunks = count($prompts);
} while ($oldNumChunks > $newNumChunks || $runAgain);
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
return ['output' => $topics];
}
}
diff --git a/lib/TaskProcessing/TranslateProvider.php b/lib/TaskProcessing/TranslateProvider.php
index 500dee62..12647396 100644
--- a/lib/TaskProcessing/TranslateProvider.php
+++ b/lib/TaskProcessing/TranslateProvider.php
@@ -9,9 +9,8 @@
namespace OCA\OpenAi\TaskProcessing;
-use OCA\OpenAi\AppInfo\Application;
use OCA\OpenAi\Service\OpenAiAPIService;
-use OCA\OpenAi\Service\OpenAiSettingsService;
+use OCA\OpenAi\Service\ServiceConfig;
use OCA\OpenAi\Service\TranslateService;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
@@ -25,22 +24,23 @@
use OCP\TaskProcessing\TaskTypes\TextToTextTranslate;
class TranslateProvider implements IProvider, ISynchronousOptionsAwareProvider {
+ use ProviderIdentity;
public function __construct(
private OpenAiAPIService $openAiAPIService,
- private OpenAiSettingsService $openAiSettingsService,
private IL10N $l,
private TranslateService $translateService,
- private ?string $userId,
+ private ServiceConfig $service,
+ private string $model,
) {
}
public function getId(): string {
- return Application::APP_ID . '-translate';
+ return $this->buildProviderId();
}
public function getName(): string {
- return $this->openAiAPIService->getServiceName();
+ return $this->buildProviderName();
}
public function getTaskTypeId(): string {
@@ -48,7 +48,7 @@ public function getTaskTypeId(): string {
}
public function getExpectedRuntime(): int {
- return $this->openAiAPIService->getExpTextProcessingTime();
+ return $this->openAiAPIService->getExpTextProcessingTime($this->service);
}
public function getInputShapeEnumValues(): array {
@@ -76,25 +76,16 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
EShapeType::Number
),
- 'model' => new ShapeDescriptor(
- $this->l->t('Model'),
- $this->l->t('The model used to generate the completion'),
- EShapeType::Enum
- ),
];
}
public function getOptionalInputShapeEnumValues(): array {
- return [
- 'model' => $this->openAiAPIService->getModelEnumValues($this->userId),
- ];
+ return [];
}
public function getOptionalInputShapeDefaults(): array {
- $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
return [
- 'max_tokens' => $this->openAiSettingsService->getMaxTokens(),
- 'model' => $adminModel,
+ 'max_tokens' => $this->service->getMaxTokens(),
];
}
@@ -117,11 +108,7 @@ public function process(
$preferStreaming = $options->getPreferStreaming();
$startTime = time();
- if (isset($input['model']) && is_string($input['model'])) {
- $model = $input['model'];
- } else {
- $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId();
- }
+ $model = $this->model;
if (!isset($input['input']) || !is_string($input['input'])) {
throw new ProcessingException('Invalid input text');
@@ -150,13 +137,14 @@ public function process(
}
};
$translation = $this->translateService->translate(
+ $this->service,
$inputText, $input['origin_language'] ?? '', $input['target_language'] ?? '',
$model, $maxTokens, $userId, $reportProgress,
$preferStreaming, $reportTranslationOutput,
);
$endTime = time();
- $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime);
+ $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime, $this->service);
if (empty(trim($translation))) {
throw new ProcessingException("Empty translation result from {$fromLanguage} to {$toLanguage}");
diff --git a/package.json b/package.json
index 62266293..e1c2281b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "integration_openai",
- "version": "5.0.0",
+ "version": "6.0.0",
"description": "OpenAI integration",
"main": "index.js",
"directories": {
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index a018a391..4503922c 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -9,745 +9,152 @@
{{ t('integration_openai', 'OpenAI and LocalAI integration') }}
-
-
- {{ t('integration_openai', 'The Assistant app is not enabled. You need it to use the features provided by the OpenAI/LocalAI integration app.') }}
-
- {{ t('integration_openai', 'Assistant app') }}
-
-
-
- {{ t('integration_openai', 'Services with an OpenAI-compatible API:') }}
-
-
- {{ t('integration_openai', 'With the current configuration, the target URL used to get the models is:') }}
-
- {{ modelEndpointUrl }}
-
-
- {{ t('integration_openai', 'This should include the address of your LocalAI instance (or any service implementing an API similar to OpenAI) along with the root path of the API. More often than not "/v1" at the end is required even if the model list loads without it. This URL will be accessed by your Nextcloud server.') }}
-
- {{ t('integration_openai', 'This can be a local address with a port like {example}. In this case, make sure \'allow_local_remote_servers\' is set to true in config.php.', { example: 'http://localhost:8080/v1' }) }}
-
-
-
- {{ state.url === ''
- ? t('integration_openai', 'Selection of chat/completion endpoint is not available for OpenAI since it implicitly uses chat completions for "instruction following" fine-tuned models.')
- : t('integration_openai', 'Using the chat endpoint may improve text generation quality for "instruction following" fine-tuned models.') }}
-
-
+
+ {{ t('integration_openai', 'Connect as many OpenAI-compatible services as you need. For each of them, select the models you want to expose per modality: every selected model becomes a provider you can pick in the AI admin settings.') }}
+
-
- {{ t('integration_openai', 'No models to list') }}
+
+ {{ t('integration_openai', 'The quota amounts themselves are configured per service. Quota rules override them for the service they apply to.') }}
-
-
-
-
-
-
-
- {{ t('integration_openai', 'Usage limits') }}
-
-
-
-
-
-
- {{ t('integration_openai', 'Usage quotas per time period') }}
-
-
- {{ t('integration_openai', 'A per-user quota for each quota type can be set. If the user has not provided their own API key, and a rule is not specified for this user or any of their groups, this quota will be enforced.') }}
- {{ t('integration_openai', '"0" means unlimited usage for a particular quota type.') }}
-
-
-
-
-
- {{ t('integration_openai', 'Your administrator defined a custom service address') }}
-
-
-
- {{ t('integration_openai', 'Leave the API key empty to use the one defined by administrators') }}
-
-
-
-
-
-
-
-
-
-
- {{ t('integration_openai', 'You can create a free API key in your OpenAI account settings') }}:
+
+
+ {{ t('integration_openai', 'Your own credentials') }}
+
+
+ {{ t('integration_openai', 'Your administrator has not connected any service yet.') }}
+
+
+ {{ t('integration_openai', 'For each service, you can use your own credentials instead of the ones your administrator configured. Leave the fields empty to use theirs. Using your own credentials for a service lifts the usage quotas of that service.') }}
+
+
+
-
- {{ t('integration_openai', 'Leave the username and password empty to use the ones defined by your administrator') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
{{ t('integration_openai', 'Usage quota info') }}
{{ t('integration_openai', 'If you see a shared quota usage of 50% and a usage of 10% that means that you have used 10% of the total shared quota, and the sum of all other users affected by this quota is 40%.') }}
-
-
-
- {{ t('integration_openai', 'Service url overridden for {task} to {url}', { task:aiTask, url: localState.url }) }}
-
-
-
-
-
-
-
- {{ buttonLabel }}
-
-
-
-
-
-
-
-
-
-
-
- {{ t('integration_openai', 'With the current configuration, the target URL used to get the models for {aiTask} is:', { aiTask }) }}
-
- {{ modelEndpointUrl }}
-
-