From d1e36f22c1db6810e044b7210d1ea1e08d9ed774 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 10:27:01 +0200 Subject: [PATCH 01/11] refactor: Register one provider per model and allow multiple services at the same time Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- CHANGELOG.md | 19 + README.md | 27 +- appinfo/info.xml | 18 +- appinfo/routes.php | 14 +- lib/AppInfo/Application.php | 124 +- lib/Capabilities.php | 6 +- lib/Controller/ConfigController.php | 58 - lib/Controller/OpenAiAPIController.php | 14 - lib/Controller/QuotaRuleController.php | 4 +- lib/Cron/RefreshModels.php | 15 +- lib/Db/QuotaUsage.php | 8 +- lib/Db/QuotaUsageMapper.php | 30 +- .../Version030104Date20241011192830.php | 11 +- .../Version030900Date20251006152735.php | 15 +- .../Translation/TranslationProvider.php | 126 -- lib/Service/ChunkService.php | 13 +- lib/Service/OpenAiAPIService.php | 632 +++---- lib/Service/OpenAiFileService.php | 43 +- lib/Service/OpenAiSettingsService.php | 1471 ++--------------- lib/Service/QuotaRuleService.php | 22 +- lib/Service/TranslateService.php | 11 +- lib/Settings/Admin.php | 17 +- lib/Settings/Personal.php | 16 +- lib/TaskProcessing/AnalyzeImagesProvider.php | 39 +- .../AudioToAudioChatProvider.php | 199 +-- .../AudioToAudioTranslateProvider.php | 75 +- .../AudioToTextEnhancedProvider.php | 35 +- lib/TaskProcessing/AudioToTextProvider.php | 16 +- .../AudioToTextSubtitlesProvider.php | 18 +- lib/TaskProcessing/ChangeToneProvider.php | 45 +- lib/TaskProcessing/ContextWriteProvider.php | 45 +- lib/TaskProcessing/EmojiProvider.php | 39 +- lib/TaskProcessing/HeadlineProvider.php | 39 +- lib/TaskProcessing/ImageToTextOcrProvider.php | 39 +- .../MultimodalChatWithToolsProvider.php | 20 +- lib/TaskProcessing/ProofreadProvider.php | 46 +- .../ReformatParagraphsProvider.php | 46 +- lib/TaskProcessing/ReformulateProvider.php | 45 +- lib/TaskProcessing/SummaryProvider.php | 43 +- .../TextToImageImprovedPromptProvider.php | 37 +- lib/TaskProcessing/TextToImageProvider.php | 47 +- lib/TaskProcessing/TextToSpeechProvider.php | 54 +- lib/TaskProcessing/TextToTextChatProvider.php | 20 +- .../TextToTextChatWithToolsProvider.php | 20 +- .../TextToTextImproveProvider.php | 45 +- lib/TaskProcessing/TextToTextProvider.php | 43 +- lib/TaskProcessing/TopicsProvider.php | 45 +- lib/TaskProcessing/TranslateProvider.php | 36 +- src/components/AdminSettings.vue | 1316 ++++----------- src/components/PersonalSettings.vue | 330 ++-- src/components/ServiceOverridePanel.vue | 341 ---- tests/unit/Providers/OpenAiProviderTest.php | 91 +- tests/unit/Quota/QuotaTest.php | 56 +- tests/unit/Service/ServiceOverrideTest.php | 266 --- 54 files changed, 1499 insertions(+), 4751 deletions(-) delete mode 100644 lib/OldProcessing/Translation/TranslationProvider.php delete mode 100644 src/components/ServiceOverridePanel.vue delete mode 100644 tests/unit/Service/ServiceOverrideTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 230a7d97..319159db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ 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 and usage are tracked per service; quota rules stay instance-wide + ## [5.0.0] - 2026-07-27 ### Breaking changes diff --git a/README.md b/README.md index ecf9ac6c..2e6a7639 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 and the usage quotas of each service, and the instance-wide quota period and rules ### 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..6eaf2a68 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.0 agpl Julien Veyssier OpenAi 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 fdb50ed8..4a8423b5 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'; @@ -88,90 +69,37 @@ class Application extends App implements IBootstrap { 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'; + /** + * 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'; + public const MODALITIES = [ + self::MODALITY_TEXT, + self::MODALITY_IMAGE, + self::MODALITY_STT, + self::MODALITY_TTS, + ]; + + /** App config key holding the JSON list of connected services */ + public const SERVICES_CONFIG_KEY = 'services'; - private IAppConfig $appConfig; + /** 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..206eda4e 100644 --- a/lib/Controller/QuotaRuleController.php +++ b/lib/Controller/QuotaRuleController.php @@ -90,9 +90,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/Cron/RefreshModels.php b/lib/Cron/RefreshModels.php index a53e8380..684bb743 100644 --- a/lib/Cron/RefreshModels.php +++ b/lib/Cron/RefreshModels.php @@ -9,8 +9,8 @@ namespace OCA\OpenAi\Cron; -use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Service\OpenAiAPIService; +use OCA\OpenAi\Service\ServicesService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; @@ -19,6 +19,7 @@ class RefreshModels extends TimedJob { public function __construct( ITimeFactory $time, private OpenAiAPIService $openAIAPIService, + private ServicesService $servicesService, private LoggerInterface $logger, ) { parent::__construct($time); @@ -27,10 +28,12 @@ public function __construct( 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); - + foreach ($this->servicesService->getServices() as $service) { + try { + $this->openAIAPIService->getModels(null, $service, true); + } catch (\Throwable $e) { + $this->logger->info('Could not refresh the model list of service ' . $service->getId(), ['exception' => $e]); + } + } } } 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/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..bd32e530 100644 --- a/lib/Migration/Version030900Date20251006152735.php +++ b/lib/Migration/Version030900Date20251006152735.php @@ -10,28 +10,19 @@ 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. Model lists are + // stored per service now and refreshed by the migration to multiple + // services, so there is nothing left to do here. } } 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 ac853808..70768d7a 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -31,7 +31,6 @@ 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; @@ -55,6 +54,7 @@ public function __construct( private OpenAiFileService $openAiFileService, private INotificationManager $notificationManager, private QuotaRuleService $quotaRuleService, + private ServicesService $servicesService, IClientService $clientService, private bool $isCLI, ) { @@ -67,80 +67,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,31 +96,25 @@ private function isModelListValid($models): bool { } /** + * Get the model list of a service + * * @param ?string $userId - * @param bool $refresh - * @param ?string $serviceType - * @return array|string[] + * @param ServiceConfig $service + * @param bool $refresh whether to bypass the caches and make a network request + * @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; - } + public function getModels(?string $userId, ServiceConfig $service, bool $refresh = false): array { + $serviceId = $service->getId(); $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'; + $userCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_' . ($userId ?? ''); + $adminCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_main'; + $dbCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId; if (!$refresh) { - if (array_key_exists($memoryCacheKey, $this->modelsMemoryCache)) { + if (array_key_exists($serviceId, $this->modelsMemoryCache)) { $this->logger->debug('Getting OpenAI models from the memory cache'); - return $this->modelsMemoryCache[$memoryCacheKey]; + return $this->modelsMemoryCache[$serviceId]; } // try to get models from the user cache first @@ -196,31 +122,24 @@ public function getModels(?string $userId, bool $refresh = false, ?string $servi $userCachedModels = $cache->get($userCacheKey); if ($userCachedModels) { $this->logger->debug('Getting OpenAI models from user cache for user ' . $userId); - $this->modelsMemoryCache[$memoryCacheKey] = $userCachedModels; + $this->modelsMemoryCache[$serviceId] = $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) === '' - ) - )) { + // if the user has their own credentials for this service, skip the admin cache + if (!$this->servicesService->userHasOwnCredentials($userId, $service)) { // here we know there is either no user cache or userId is null - // so if there is no user-defined service credentials + // so if there are 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; + $this->modelsMemoryCache[$serviceId] = $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 + // if we don't need to refresh the 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' => [], @@ -233,7 +152,7 @@ public function getModels(?string $userId, bool $refresh = false, ?string $servi $newCache = $fallbackModels; } $cache->set($userId !== null ? $userCacheKey : $adminCacheKey, $newCache, Application::MODELS_CACHE_TTL); - $this->modelsMemoryCache[$memoryCacheKey] = $newCache; + $this->modelsMemoryCache[$serviceId] = $newCache; return $newCache; } @@ -243,8 +162,8 @@ public function getModels(?string $userId, bool $refresh = false, ?string $servi 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); + $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; @@ -264,58 +183,23 @@ public function getModels(?string $userId, bool $refresh = false, ?string $servi } $cache->set($userId !== null ? $userCacheKey : $adminCacheKey, $modelsResponse, Application::MODELS_CACHE_TTL); - $this->modelsMemoryCache[$memoryCacheKey] = $modelsResponse; + $this->modelsMemoryCache[$serviceId] = $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 +209,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; + // a matching quota rule is a global budget, the fallback quota is the one of the service + $serviceId = $rule['id'] === null ? $service->getId() : null; if ($quota === 0) { // Unlimited quota: @@ -341,7 +227,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 +236,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 +244,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 +290,55 @@ 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); + // a matching quota rule is a global budget, the fallback quota is the one of the service + $serviceId = ($rule === null || $rule['id'] === null) ? $service->getId() : null; + $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, $serviceId, + ); + if ($rule !== null && $rule['pool']) { + $quotaInfo[$quotaType]['used_pool'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod( + $userId, $quotaType, $quotaStart, $rule['id'], $serviceId, + ); + } + } 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 +346,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 +391,7 @@ public function getAdminQuotaInfo(): array { */ public function createCompletion( ?string $userId, + ServiceConfig $service, string $prompt, int $n, string $model, @@ -488,26 +399,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 +428,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 +438,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 +458,7 @@ public function createCompletion( public function createStreamedChatCompletion( ?string $userId, + ServiceConfig $service, string $model, ?string $userPrompt = null, ?string $systemPrompt = null, @@ -556,12 +470,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 +492,12 @@ public function createStreamedChatCompletion( $response = $this->request( $userId, + $service, 'chat/completions', $params, 'POST', null, true, - null, 0, true, ); @@ -592,7 +507,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 +518,7 @@ public function createStreamedChatCompletion( public function createChatCompletion( ?string $userId, + ServiceConfig $service, string $model, ?string $userPrompt = null, ?string $systemPrompt = null, @@ -615,7 +531,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 +539,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 +566,7 @@ public function createChatCompletion( */ public function requestChatCompletion( ?string $userId, + ServiceConfig $service, string $model, ?string $userPrompt = null, ?string $systemPrompt = null, @@ -662,12 +579,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 +599,7 @@ public function requestChatCompletion( $stream, ); - return $this->request($userId, 'chat/completions', $params, 'POST'); + return $this->request($userId, $service, 'chat/completions', $params, 'POST'); } /** @@ -701,6 +619,7 @@ public function requestChatCompletion( */ private function buildChatCompletionRequestParams( ?string $userId, + ServiceConfig $service, string $model, ?string $userPrompt = null, ?string $systemPrompt = null, @@ -713,16 +632,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 +691,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 +711,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 +741,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 { @@ -845,18 +763,18 @@ private function buildChatCompletionRequestParams( if ($tools !== null) { $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], @@ -867,19 +785,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; - } - $arrayValue = json_decode($stringValue, true); - if (!is_array($arrayValue)) { - return null; + private function modelParam(ServiceConfig $service, string $model, string $openAiFallback): ?string { + if ($model !== Application::DEFAULT_MODEL_ID) { + return $model; } - return $arrayValue; + return $service->isUsingOpenAi() ? $openAiFallback : null; } /** @@ -891,12 +808,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 @@ -915,6 +834,7 @@ public function transcribeBase64Mp3( */ public function transcribeFile( ?string $userId, + ServiceConfig $service, File $file, bool $translate = false, string $model = Application::DEFAULT_MODEL_ID, @@ -922,7 +842,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); @@ -943,25 +863,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); @@ -972,7 +891,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'])) { @@ -993,7 +912,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]); } @@ -1023,7 +942,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]); } @@ -1042,12 +961,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); } @@ -1055,17 +975,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]); } @@ -1077,39 +1000,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->getUseBasicAuth()) { + 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; @@ -1126,28 +1032,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()); } @@ -1157,8 +1067,8 @@ public function requestSpeechCreation( /** * @return int */ - public function getExpTextProcessingTime(): int { - return $this->isUsingOpenAi() + public function getExpTextProcessingTime(ServiceConfig $service): int { + return $service->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)); } @@ -1167,11 +1077,11 @@ public function getExpTextProcessingTime(): int { * @param int $runtime * @return void */ - public function updateExpTextProcessingTime(int $runtime): void { - $oldTime = floatval($this->getExpTextProcessingTime()); + public function updateExpTextProcessingTime(int $runtime, ServiceConfig $service): void { + $oldTime = floatval($this->getExpTextProcessingTime($service)); $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * $oldTime + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime); - if ($this->isUsingOpenAi()) { + if ($service->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); @@ -1181,8 +1091,8 @@ public function updateExpTextProcessingTime(int $runtime): void { /** * @return int */ - public function getExpImgProcessingTime(): int { - return $this->isUsingOpenAi(Application::SERVICE_TYPE_IMAGE) + public function getExpImgProcessingTime(ServiceConfig $service): int { + return $service->isUsingOpenAi() ? 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)); } @@ -1191,11 +1101,11 @@ public function getExpImgProcessingTime(): int { * @param int $runtime * @return void */ - public function updateExpImgProcessingTime(int $runtime): void { - $oldTime = floatval($this->getExpImgProcessingTime()); + public function updateExpImgProcessingTime(int $runtime, ServiceConfig $service): void { + $oldTime = floatval($this->getExpImgProcessingTime($service)); $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * $oldTime + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime); - if ($this->isUsingOpenAi(Application::SERVICE_TYPE_IMAGE)) { + if ($service->isUsingOpenAi()) { $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); @@ -1210,25 +1120,25 @@ 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(); + $useBasicAuth = $service->getUseBasicAuth(); + $timeout = $service->getRequestTimeout(); $url = rtrim($serviceUrl, '/') . '/' . $endPoint; $options = [ @@ -1242,7 +1152,7 @@ public function request( return ['error' => 'An API key is required for api.openai.com']; } - if ($this->isUsingOpenAi($serviceType) || !$useBasicAuth) { + if ($service->isUsingOpenAi() || !$useBasicAuth) { if ($apiKey !== '') { $options['headers']['Authorization'] = 'Bearer ' . $apiKey; } @@ -1252,7 +1162,7 @@ public function request( } } - if (!$this->isUsingOpenAi($serviceType)) { + if (!$service->isUsingOpenAi()) { $options['nextcloud']['allow_local_address'] = true; } @@ -1365,7 +1275,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]); } @@ -1392,14 +1302,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( @@ -1413,63 +1323,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>} @@ -1550,12 +1408,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 { @@ -1563,7 +1419,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; } @@ -1571,12 +1427,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 { @@ -1584,7 +1438,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; } @@ -1592,12 +1446,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 { @@ -1608,7 +1460,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; } @@ -1616,17 +1468,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 f63705c1..618fa853 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 */ @@ -318,1213 +125,139 @@ 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'; - } - - /** - * @return 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 getUserSTTLanguage(?string $userId): string { + return $this->config->getUserValue($userId, Application::APP_ID, 'stt_language', 'detect_language'); } /** - * @return string + * Get the instance-wide admin config for the settings page + * + * @return array{quota_period: array, usage_storage_time: int} */ - public function getSttServiceUrl(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_url', '', lazy: true); + 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 getSttServiceName(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_service_name', '', lazy: true); + public function getUserConfig(string $userId): array { + return [ + 'stt_language' => $this->getUserSTTLanguage($userId), + ]; } - /** - * @return string - */ - public function getAdminSttApiKey(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_api_key', '', 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 getAdminSttBasicUser(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_basic_user', '', lazy: 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'); + } + $this->appConfig->setValueString(Application::APP_ID, 'quota_period', json_encode($quotaPeriod), lazy: true); } /** - * @return string + * @param int $usageStorageTime + * @return void */ - public function getAdminSttBasicPassword(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_basic_password', '', true); + public function setUsageStorageTime(int $usageStorageTime): void { + $usageStorageTime = max(1, $usageStorageTime); + $this->appConfig->setValueInt(Application::APP_ID, 'usage_storage_time', $usageStorageTime, lazy: true); } /** - * @return bool + * @param string $userId + * @param string $language + * @throws PreConditionNotMetException */ - public function getAdminSttUseBasicAuth(): bool { - return $this->appConfig->getValueString(Application::APP_ID, 'stt_use_basic_auth', '0', lazy: true) === '1'; + public function setUserSTTLanguage(string $userId, string $language): void { + $this->config->setUserValue($userId, Application::APP_ID, 'stt_language', $language); } /** - * @return int + * Set the instance-wide admin config + * + * @param array $adminConfig + * @throws Exception */ - 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; - } + 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 string - */ - public function getTtsServiceUrl(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'tts_url', '', lazy: true); + // 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 getTtsServiceName(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'tts_service_name', '', 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 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'); - } - $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..7ce1cf2c 100644 --- a/lib/Service/QuotaRuleService.php +++ b/lib/Service/QuotaRuleService.php @@ -25,7 +25,6 @@ class QuotaRuleService { public function __construct( private QuotaRuleMapper $quotaRuleMapper, private QuotaUserMapper $quotaUserMapper, - private OpenAiSettingsService $openAiSettingsService, private IGroupManager $groupManager, private ICacheFactory $cacheFactory, private IUserManager $userManager, @@ -36,15 +35,20 @@ public function __construct( } /** - * Returns the quota rule for the given user + * Returns the quota rule that applies to the given user. + * + * Quota rules are instance-wide: a matching rule is a single budget across + * all services. When no rule matches, the quota configured on the service + * 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 { @@ -57,7 +61,7 @@ public function getRule(int $quotaType, string $userId) { $rule = $this->quotaRuleMapper->getRule($quotaType, $userId, $groups)->jsonSerialize(); } catch (DoesNotExistException|MultipleObjectsReturnedException) { $rule = [ - 'amount' => $this->openAiSettingsService->getQuotas()[$quotaType], + 'amount' => $service->getQuota($quotaType), 'pool' => false, 'id' => null, ]; @@ -187,10 +191,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/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..145f35e9 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('analyze-images'); } 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..325525ca 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('audio2audio:chat'); } 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..acd6e8e5 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('audio2audio:translate'); } 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..a2bed954 100644 --- a/lib/TaskProcessing/AudioToTextEnhancedProvider.php +++ b/lib/TaskProcessing/AudioToTextEnhancedProvider.php @@ -9,22 +9,30 @@ 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\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 { + use ProviderIdentity; public function __construct( private AudioToTextProvider $audioToTextProvider, + private ReformatParagraphsProvider $reformatParagraphsProvider, private OpenAiAPIService $openAiAPIService, - private IManager $taskProcessingManager, private LoggerInterface $logger, + private ServiceConfig $service, + private string $model, ) { } @@ -42,7 +50,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 +93,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..186a7925 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('audio2text'); } 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..c74f2ea1 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('audio2text-subtitles'); } 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..b45d66e2 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('changetone'); } 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..71b01931 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('contextwrite'); } 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 7a517c73..00dcf210 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('text2text:emoji'); } 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' => 100, - 'model' => $adminModel, ]; } @@ -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 54f630ae..cb88e0f9 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('text2text:headline'); } 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' => 100, - 'model' => $adminModel, ]; } @@ -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..3cd6a4c9 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('image2text-ocr'); } 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..069fc413 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('text2text:multimodal-chatwithtools'); } 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..eedfed9b 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('text2text:proofread'); } 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/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php index 8b5d02ae..616d7362 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('text2text:reformatparagraphs'); } 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..a728ba02 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('reformulate'); } 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..22c41af4 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('text2text:summary'); } 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..5e9962b8 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 { + use ProviderIdentity; + public function __construct( private TextToImageProvider $textToImageProvider, - private IManager $taskProcessingManager, + private TextToTextProvider $textToTextProvider, private LoggerInterface $logger, private IL10N $l10n, private OpenAiAPIService $openAiAPIService, + private ServiceConfig $service, + private string $model, ) { } @@ -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..609f470d 100644 --- a/lib/TaskProcessing/TextToImageProvider.php +++ b/lib/TaskProcessing/TextToImageProvider.php @@ -9,11 +9,10 @@ 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\Http\Client\IClientService; -use OCP\IAppConfig; use OCP\IL10N; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Exception\ProcessingException; @@ -24,24 +23,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('text2image'); } public function getName(): string { - return $this->openAiAPIService->getServiceName(Application::SERVICE_TYPE_IMAGE); + return $this->buildProviderName(); } public function getTaskTypeId(): string { @@ -49,7 +49,7 @@ public function getTaskTypeId(): string { } public function getExpectedRuntime(): int { - return $this->openAiAPIService->getExpTextProcessingTime(); + return $this->openAiAPIService->getExpTextProcessingTime($this->service); } public function getInputShapeEnumValues(): array { @@ -63,34 +63,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,7 +112,7 @@ 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']); } @@ -132,15 +120,10 @@ public function process(?string $userId, array $input, callable $reportProgress, 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 +145,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 +159,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..00dc0ae6 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('text2speech'); } 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..735d098a 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('text2text:chat'); } 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..6c35910a 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('text2text:chatwithtools'); } 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..a40fd633 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('improve'); } 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..bec1d437 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('text2text'); } 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..3401704a 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('text2text:topics'); } 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..80d02671 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('translate'); } 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/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index a018a391..96e917ef 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -9,684 +9,112 @@ {{ 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' }) }} -
-
- - - - -
-
- - - - - - - -
-
-
-

- {{ t('integration_openai', 'Authentication') }} -

-
- -
- - {{ t('assistant', 'API key') }} - - - {{ t('assistant', 'Basic Authentication') }} - -
-
-
- - - -
- - {{ t('integration_openai', 'You can create an API key in your OpenAI account settings') }}: -   - - {{ apiKeyUrl }} - - -
-
- - - -
-
- - - -
-
-
-
-

- {{ t('integration_openai', 'Text generation') }} -

-
- -
- - {{ t('assistant', 'Chat completions') }} - - - {{ t('assistant', 'Completions') }} - -
-
- - {{ 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', 'Use "{newParam}" parameter instead of the deprecated "{deprecatedParam}"', { newParam: 'max_completion_tokens', deprecatedParam: 'max_tokens' }) }} - -
-

- {{ t('integration_openai', 'Multimodal LLM Support') }} -

- - {{ t('integration_openai', 'Multimodal LLM Support allows you to enable or disable the use of images, audio, video and document attachments in the LLM.') }} - - - - {{ t('integration_openai', 'Image attachments') }} - - - {{ t('integration_openai', 'Audio attachments') }} - - - {{ t('integration_openai', 'Video attachments') }} - - - {{ t('integration_openai', 'Document attachments') }} - - -
-
-

- {{ t('integration_openai', 'Image generation') }} -

- - - - {{ t('integration_openai', 'No models to list') }} - -
- - - - -
-
- - {{ t('integration_openai', 'Use authentication for image retrieval request') }} - -
-
-
-

- {{ t('integration_openai', 'Audio transcription') }} -

- -
- - - - - - - - - - - + + {{ 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', 'Connected services') }}

+ + {{ 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', 'Connect a service') }} +
-

- {{ t('integration_openai', 'Text to speech') }} -

- -
- - - - - - - - - - - + +

{{ t('integration_openai', 'Usage limits') }}

+
+
- - {{ t('integration_openai', 'No models to list') }} + + {{ t('integration_openai', 'The quota amounts themselves are configured per service. Quota rules apply across all services.') }} -
- - -
- -
-

- {{ 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', 'Quota type') }} - {{ t('integration_openai', 'Per-user quota / period') }} - {{ t('integration_openai', 'Current system-wide usage / period') }} -
- {{ quotaInfo[index].type }} - - - - {{ quotaInfo[index].unit }} - - - {{ quotaInfo[index].used }} -
-
- -
-
- - - - - {{ t('integration_openai', 'Download quota usage') }} - -
-

{{ t('integration_openai', 'Quota Rules') }}

- +
+
-
-

- {{ t('integration_openai', 'Select enabled features') }} -

- - - {{ t('integration_openai', 'Translation provider (to translate Talk messages for example)') }} - - - {{ t('integration_openai', 'Text processing providers (to generate text, summarize, context write, etc.)') }} - - - {{ t('integration_openai', 'Image generation provider') }} - - - {{ t('integration_openai', 'Speech-to-text provider (to transcribe Talk recordings for example)') }} - - - {{ t('integration_openai', 'Text-to-speech provider') }} - - +
+ + + + + + {{ t('integration_openai', 'Download quota usage') }} +
+ +

{{ t('integration_openai', 'Quota Rules') }}

+
- - diff --git a/tests/unit/Providers/OpenAiProviderTest.php b/tests/unit/Providers/OpenAiProviderTest.php index 7177e52a..b4155fa4 100644 --- a/tests/unit/Providers/OpenAiProviderTest.php +++ b/tests/unit/Providers/OpenAiProviderTest.php @@ -19,6 +19,8 @@ use OCA\OpenAi\Service\OpenAiFileService; use OCA\OpenAi\Service\OpenAiSettingsService; use OCA\OpenAi\Service\QuotaRuleService; +use OCA\OpenAi\Service\ServiceConfig; +use OCA\OpenAi\Service\ServicesService; use OCA\OpenAi\Service\StreamingService; use OCA\OpenAi\Service\TranslateService; use OCA\OpenAi\Service\WatermarkingService; @@ -52,9 +54,16 @@ class OpenAiProviderTest extends TestCase { public const TEST_USER1 = 'testuser'; public const OPENAI_API_BASE = 'https://api.openai.com/v1/'; public const AUTHORIZATION_HEADER = 'Bearer This is a PHPUnit test API key'; + public const TEXT_MODEL = Application::DEFAULT_COMPLETION_MODEL_ID; + public const IMAGE_MODEL = Application::DEFAULT_IMAGE_MODEL_ID; + public const STT_MODEL = Application::DEFAULT_TRANSCRIPTION_MODEL_ID; + public const TTS_MODEL = Application::DEFAULT_SPEECH_MODEL_ID; private OpenAiAPIService $openAiApiService; private OpenAiSettingsService $openAiSettingsService; + private ServicesService $servicesService; + /** The service all providers of this test belong to */ + private ServiceConfig $service; private ChunkService $chunkService; private StreamingService $streamingService; private TranslateService $translateService; @@ -90,6 +99,16 @@ protected function setUp(): void { $this->iClient = $this->createMock(IClient::class); $clientService->method('newClient')->willReturn($this->iClient); + $this->servicesService = \OCP\Server::get(ServicesService::class); + // the OpenAI API, exposing one model per modality + $this->service = $this->servicesService->addService([ + 'text_models' => [self::TEXT_MODEL], + 'image_models' => [self::IMAGE_MODEL], + 'stt_models' => [self::STT_MODEL], + 'tts_models' => [self::TTS_MODEL], + 'multimodal_image_enabled' => true, + ]); + $this->openAiApiService = new OpenAiAPIService( \OCP\Server::get(\Psr\Log\LoggerInterface::class), $this->createMock(\OCP\IL10N::class), @@ -100,27 +119,38 @@ protected function setUp(): void { $this->streamingService, new OpenAiFileService( $this->createMock(\OCP\IL10N::class), - $this->openAiSettingsService, $this->createMock(\OCP\Files\IRootFolder::class), $this->createMock(\OCP\TaskProcessing\IManager::class), $this->createMock(\Psr\Log\LoggerInterface::class), ), $this->createMock(\OCP\Notification\IManager::class), \OCP\Server::get(QuotaRuleService::class), + $this->servicesService, $clientService, true, ); - $this->translateService = \OCP\Server::get(TranslateService::class); $this->translateService = new TranslateService( - $this->openAiSettingsService, \OCP\Server::get(\Psr\Log\LoggerInterface::class), $this->openAiApiService, $this->chunkService, \OCP\Server::get(ICacheFactory::class), ); - $this->openAiSettingsService->setUserApiKey(self::TEST_USER1, 'This is a PHPUnit test API key'); + // the user's own API key is used for the requests of this service + $this->servicesService->setUserCredentials(self::TEST_USER1, $this->service->getId(), [ + 'api_key' => 'This is a PHPUnit test API key', + ]); + } + + protected function tearDown(): void { + $this->servicesService->setUserCredentials(self::TEST_USER1, $this->service->getId(), [ + 'api_key' => '', + 'basic_user' => '', + 'basic_password' => '', + ]); + $this->servicesService->deleteService($this->service->getId()); + parent::tearDown(); } public static function tearDownAfterClass(): void { @@ -142,9 +172,9 @@ public static function tearDownAfterClass(): void { public function testFreePromptProvider(): void { $freePromptProvider = new TextToTextProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $prompt = 'This is a test prompt'; @@ -233,7 +263,7 @@ public function testCreateStreamedChatCompletionReturnsStructuredChatResult(): v $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); - $generator = $this->openAiApiService->createStreamedChatCompletion(self::TEST_USER1, Application::DEFAULT_MODEL_ID, 'This is a test prompt'); + $generator = $this->openAiApiService->createStreamedChatCompletion(self::TEST_USER1, $this->service, Application::DEFAULT_MODEL_ID, 'This is a test prompt'); $chunks = iterator_to_array($generator, false); $this->assertSame([ @@ -315,9 +345,9 @@ public function testCreateStreamedChatCompletionCanYieldStructuredReasoningChunk public function testEmojiProvider(): void { $emojiProvider = new EmojiProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $prompt = 'This is a test prompt'; @@ -379,9 +409,9 @@ public function testEmojiProvider(): void { public function testHeadlineProvider(): void { $headlineProvider = new HeadlineProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $prompt = 'This is a test prompt'; @@ -443,10 +473,10 @@ public function testHeadlineProvider(): void { public function testChangeToneProvider(): void { $changeToneProvider = new ChangeToneProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->chunkService, - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $textInput = 'This is a test prompt'; @@ -509,10 +539,10 @@ public function testChangeToneProvider(): void { public function testSummaryProvider(): void { $summaryProvider = new SummaryProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->chunkService, - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $prompt = 'This is a test prompt'; @@ -578,10 +608,10 @@ public function testSummaryProvider(): void { public function testProofreadProvider(): void { $proofreadProvider = new ProofreadProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->chunkService, - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $prompt = 'This is a test prompt'; @@ -646,10 +676,10 @@ public function testProofreadProvider(): void { public function testTranslationProvider(): void { $translationProvider = new TranslateProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->translateService, - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $inputText = 'This is a test prompt'; @@ -736,14 +766,15 @@ public function testAudioToAudioTranslateProvider(): void { $audioToAudioTranslateProvider = new AudioToAudioTranslateProvider( $this->openAiApiService, $this->translateService, - $this->openAiSettingsService, \OCP\Server::get(WatermarkingService::class), $this->createMock(\Psr\Log\LoggerInterface::class), $l10nFactory, $l10n, - \OCP\Server::get(IAppConfig::class), $userManager, - self::TEST_USER1, + $this->service, + self::STT_MODEL, + self::TEXT_MODEL, + self::TTS_MODEL, ); $inputSpeech = file_get_contents(__DIR__ . '/../../res/speech.mp3'); @@ -841,9 +872,9 @@ public function testTextToSpeechProvider(): void { $this->openAiApiService, $l10n = $this->createMock(\OCP\IL10N::class), $this->createMock(\Psr\Log\LoggerInterface::class), - \OCP\Server::get(IAppConfig::class), - self::TEST_USER1, \OCP\Server::get(WatermarkingService::class), + $this->service, + self::TTS_MODEL, ); $l10n->method('t')->willReturn('This was generated using Artificial Intelligence.'); @@ -889,9 +920,9 @@ public function testTextToImageProvider(): void { $this->createMock(\OCP\IL10N::class), $this->createMock(\Psr\Log\LoggerInterface::class), \OCP\Server::get(IClientService::class), - \OCP\Server::get(IAppConfig::class), - self::TEST_USER1, \OCP\Server::get(WatermarkingService::class), + $this->service, + self::IMAGE_MODEL, ); $inputText = 'This is a test prompt'; @@ -945,11 +976,10 @@ public function testReformatParagraphsProvider(): void { $provider = new ReformatParagraphsProvider( $this->openAiApiService, - \OCP\Server::get(IAppConfig::class), - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->chunkService, - self::TEST_USER1, + $this->service, + self::TEXT_MODEL, ); $inputText = 'Alpha part. Beta part.'; @@ -1110,10 +1140,11 @@ public function testCreateChatCompletionExtractsMistralTypedTextContent(): void public function testMultimodalChatWithToolsProvider(): void { $provider = new MultimodalChatWithToolsProvider( $this->openAiApiService, - $this->openAiSettingsService, $this->createMock(\OCP\IL10N::class), $this->createMock(\Psr\Log\LoggerInterface::class), \OCP\Server::get(WatermarkingService::class), + $this->service, + self::TEXT_MODEL, ); $prompt = 'What is in this image?'; diff --git a/tests/unit/Quota/QuotaTest.php b/tests/unit/Quota/QuotaTest.php index e9c845d5..ac0d7c13 100644 --- a/tests/unit/Quota/QuotaTest.php +++ b/tests/unit/Quota/QuotaTest.php @@ -19,6 +19,8 @@ use OCA\OpenAi\Service\OpenAiFileService; use OCA\OpenAi\Service\OpenAiSettingsService; use OCA\OpenAi\Service\QuotaRuleService; +use OCA\OpenAi\Service\ServiceConfig; +use OCA\OpenAi\Service\ServicesService; use OCA\OpenAi\Service\StreamingService; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -54,6 +56,8 @@ class QuotaTest extends TestCase { private $cacheFactory; private QuotaUsageMapper $quotaUsageMapper; private QuotaRuleService $quotaRuleService; + private ServicesService $servicesService; + private ServiceConfig $service; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); @@ -78,6 +82,9 @@ protected function setUp(): void { $this->quotaRuleService = \OCP\Server::get(QuotaRuleService::class); + $this->servicesService = \OCP\Server::get(ServicesService::class); + $this->service = $this->servicesService->addService(['name' => 'Quota test service']); + $this->openAiApiService = new OpenAiAPIService( \OCP\Server::get(LoggerInterface::class), $this->createMock(IL10N::class), @@ -90,18 +97,24 @@ protected function setUp(): void { ), new OpenAiFileService( $this->createMock(IL10N::class), - $this->openAiSettingsService, $this->createMock(\OCP\Files\IRootFolder::class), $this->createMock(\OCP\TaskProcessing\IManager::class), $this->createMock(LoggerInterface::class), ), $this->notificationManager, \OCP\Server::get(QuotaRuleService::class), + $this->servicesService, \OCP\Server::get(IClientService::class), true ); } + protected function tearDown(): void { + $this->servicesService->deleteService($this->service->getId()); + $this->quotaRuleService->clearCache(); + parent::tearDown(); + } + public static function tearDownAfterClass(): void { // Delete quota usage for test user $quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class); @@ -123,28 +136,47 @@ public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); } public function testNotification(): void { - $this->openAiSettingsService->setQuotas([1, 1, 1, 1]); + // the quota amounts belong to the service + $this->service = $this->servicesService->updateService($this->service->getId(), ['quotas' => [1, 1, 1, 1]]); + $this->quotaRuleService->clearCache(); $cache = $this->createMock(ICache::class); $this->cacheFactory->method('createLocal')->willReturn($cache); - $key = 'quota_exceeded_' . self::TEST_USER1 . '_' . Application::QUOTA_TYPE_TEXT; + $key = 'quota_exceeded_' . self::TEST_USER1 . '_' . Application::QUOTA_TYPE_TEXT . '_' . $this->service->getId(); $cache->expects($this->any())->method('get')->with($key)->willReturn($this->onConsecutiveCalls(null, true, true)); $cache->expects($this->once())->method('set')->with($key, true, 3600); $this->notificationManager->expects($this->once())->method('notify'); - $this->assertFalse($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT)); - $this->quotaUsageMapper->createQuotaUsage(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, 100); + $this->assertFalse($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); + $this->quotaUsageMapper->createQuotaUsage(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, 100, -1, $this->service->getId()); // Send notification - $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT)); + $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); // Try again to make sure a notification is only sent once - $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT)); + $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); // Clear quota usage $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); } + + public function testQuotaIsCountedPerService(): void { + $this->service = $this->servicesService->updateService($this->service->getId(), ['quotas' => [10, 0, 0, 0]]); + $otherService = $this->servicesService->addService(['name' => 'Other quota test service', 'quotas' => [10, 0, 0, 0]]); + $this->quotaRuleService->clearCache(); + $cache = $this->createMock(ICache::class); + $this->cacheFactory->method('createLocal')->willReturn($cache); + + // usage on one service does not count against the quota of the other + $this->quotaUsageMapper->createQuotaUsage(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, 100, -1, $this->service->getId()); + $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); + $this->assertFalse($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $otherService)); + + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + $this->servicesService->deleteService($otherService->getId()); + } public function testQuotaPool(): void { // Create a quota rule for both test users as a pool - $this->openAiSettingsService->setQuotas([1000, 1, 1, 1]); + $this->service = $this->servicesService->updateService($this->service->getId(), ['quotas' => [1000, 1, 1, 1]]); + $this->quotaRuleService->clearCache(); $rule = $this->quotaRuleService->addRule(); $rule['type'] = Application::QUOTA_TYPE_TEXT; $rule['amount'] = 10; @@ -161,12 +193,12 @@ public function testQuotaPool(): void { ]; $this->quotaRuleService->updateRule($rule['id'], $rule); - $this->assertFalse($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT)); - $this->quotaUsageMapper->createQuotaUsage(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, 100, $rule['id']); + $this->assertFalse($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); + $this->quotaUsageMapper->createQuotaUsage(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, 100, $rule['id'], $this->service->getId()); - $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT)); + $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER1, Application::QUOTA_TYPE_TEXT, $this->service)); // Check other user - $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER2, Application::QUOTA_TYPE_TEXT)); + $this->assertTrue($this->openAiApiService->isQuotaExceeded(self::TEST_USER2, Application::QUOTA_TYPE_TEXT, $this->service)); // Clear quota usage $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER2); diff --git a/tests/unit/Service/ServiceOverrideTest.php b/tests/unit/Service/ServiceOverrideTest.php deleted file mode 100644 index 56ac159c..00000000 --- a/tests/unit/Service/ServiceOverrideTest.php +++ /dev/null @@ -1,266 +0,0 @@ -createUser(self::TEST_USER1, self::TEST_USER1); - \OCP\Server::get(\OCP\IUserManager::class)->registerBackend($backend); - } - - protected function setUp(): void { - parent::setUp(); - - $this->loginAsUser(self::TEST_USER1); - - $this->openAiSettingsService = \OCP\Server::get(OpenAiSettingsService::class); - - $this->chunkService = \OCP\Server::get(ChunkService::class); - - $this->quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class); - - // We'll hijack the client service and subsequently iClient to return a mock response from the OpenAI API - $clientService = $this->createMock(IClientService::class); - $this->iClient = $this->createMock(IClient::class); - $clientService->method('newClient')->willReturn($this->iClient); - - $this->openAiApiService = new OpenAiAPIService( - \OCP\Server::get(\Psr\Log\LoggerInterface::class), - $this->createMock(\OCP\IL10N::class), - \OCP\Server::get(IAppConfig::class), - \OCP\Server::get(ICacheFactory::class), - \OCP\Server::get(QuotaUsageMapper::class), - $this->openAiSettingsService, - new StreamingService( - $this->createMock(\OCP\IL10N::class), - ), - new OpenAiFileService( - $this->createMock(\OCP\IL10N::class), - $this->openAiSettingsService, - $this->createMock(\OCP\Files\IRootFolder::class), - $this->createMock(\OCP\TaskProcessing\IManager::class), - $this->createMock(\Psr\Log\LoggerInterface::class), - ), - $this->createMock(\OCP\Notification\IManager::class), - \OCP\Server::get(QuotaRuleService::class), - $clientService, - true - ); - } - - public static function tearDownAfterClass(): void { - // Delete quota usage for test user - $quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class); - try { - $quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); - } catch (\OCP\Db\Exception|\RuntimeException|\Exception|\Throwable $e) { - // Ignore - } - - $backend = new \Test\Util\User\Dummy(); - $backend->deleteUser(self::TEST_USER1); - \OCP\Server::get(\OCP\IUserManager::class)->removeBackend($backend); - - $openAiSettingsService = \OCP\Server::get(OpenAiSettingsService::class); - $openAiSettingsService->setImageServiceUrl(''); - $openAiSettingsService->setTtsServiceUrl(''); - $openAiSettingsService->setSttServiceUrl(''); - - parent::tearDownAfterClass(); - } - - public function testTextToSpeechProvider(): void { - $this->openAiSettingsService->setTtsServiceUrl(self::OVERRIDE_SPEECH_BASE); - $this->openAiSettingsService->setAdminTtsApiKey(self::APIKEY_SPEECH); - $this->openAiSettingsService->setTtsRequestTimeout(self::REQUEST_TIMEOUT_SPEECH); - - $TTSProvider = new TextToSpeechProvider( - $this->openAiApiService, - $l10n = $this->createMock(\OCP\IL10N::class), - $this->createMock(\Psr\Log\LoggerInterface::class), - \OCP\Server::get(IAppConfig::class), - self::TEST_USER1, - \OCP\Server::get(WatermarkingService::class), - ); - - $inputText = 'This is a test prompt'; - - $response = file_get_contents(__DIR__ . '/../../res/speech.mp3'); - - if (!$response) { - throw new \RuntimeException('Could not read test resourcce `speech.mp3`'); - } - - $url = self::OVERRIDE_SPEECH_BASE . 'audio/speech'; - - $options = ['timeout' => self::REQUEST_TIMEOUT_SPEECH, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_SPEECH, 'Content-Type' => 'application/json'], 'nextcloud' => ['allow_local_address' => true]]; - $options['body'] = json_encode([ - 'input' => $inputText, - 'voice' => Application::DEFAULT_SPEECH_VOICE, - 'model' => Application::DEFAULT_SPEECH_MODEL_ID, - 'response_format' => 'mp3', - 'speed' => 1, - ]); - - $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); - $iResponse->method('getBody')->willReturn($response); - $iResponse->method('getStatusCode')->willReturn(200); - - $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); - - $TTSProvider->process(self::TEST_USER1, ['input' => $inputText], fn () => null, includeWatermark: false); - } - - public function testTextToImageProvider(): void { - $this->openAiSettingsService->setImageServiceUrl(self::OVERRIDE_IMAGE_BASE); - $this->openAiSettingsService->setAdminImageApiKey(self::APIKEY_IMAGE); - $this->openAiSettingsService->setImageRequestTimeout(self::REQUEST_TIMEOUT_IMAGE); - - $TextToImageProvider = new TextToImageProvider( - $this->openAiApiService, - $this->createMock(\OCP\IL10N::class), - $this->createMock(\Psr\Log\LoggerInterface::class), - \OCP\Server::get(IClientService::class), - \OCP\Server::get(IAppConfig::class), - self::TEST_USER1, - \OCP\Server::get(WatermarkingService::class), - ); - - $inputText = 'This is a test prompt'; - - $responseImage = file_get_contents(__DIR__ . '/../../res/trees.jpg'); - - if (!$responseImage) { - throw new \RuntimeException('Could not read test resourcce `trees.jpg`'); - } - - $response = json_encode([ - 'data' => [ - [ - 'b64_json' => base64_encode($responseImage), - ] - ] - ]); - - $url = self::OVERRIDE_IMAGE_BASE . 'images/generations'; - - $options = ['timeout' => self::REQUEST_TIMEOUT_IMAGE, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_IMAGE, 'Content-Type' => 'application/json'], 'nextcloud' => ['allow_local_address' => true]]; - $options['body'] = json_encode([ - 'prompt' => $inputText, - 'size' => '1024x1024', - 'n' => 1, - 'model' => Application::DEFAULT_IMAGE_MODEL_ID, - ]); - - $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); - $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); - $iResponse->method('getBody')->willReturn($response); - $iResponse->method('getStatusCode')->willReturn(200); - - $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); - - $TextToImageProvider->process(self::TEST_USER1, ['input' => $inputText, 'numberOfImages' => 1], fn () => null); - } - - public function testAudioToTextProvider(): void { - $this->openAiSettingsService->setSttServiceUrl(self::OVERRIDE_TRANSCRIPTION_BASE); - $this->openAiSettingsService->setAdminSttApiKey(self::APIKEY_TRANSCRIPTION); - $this->openAiSettingsService->setSttRequestTimeout(self::REQUEST_TIMEOUT_TRANSCRIPTION); - - $audioToTextProvider = new AudioToTextProvider( - $this->openAiApiService, - $this->createMock(\Psr\Log\LoggerInterface::class), - \OCP\Server::get(IAppConfig::class), - $this->createMock(\OCP\IL10N::class), - ); - - $file = $this->createMock(\OCP\Files\File::class); - - $inputSpeech = file_get_contents(__DIR__ . '/../../res/speech.mp3'); - - if (!$inputSpeech) { - throw new \RuntimeException('Could not read test resource `speech.mp3`'); - } - $file->method('isReadable')->willReturn(true); - $file->method('getContent')->willReturn($inputSpeech); - - $response = json_encode([ - 'text' => 'Transcribed text' - ]); - - $url = self::OVERRIDE_TRANSCRIPTION_BASE . 'audio/transcriptions'; - - $options = ['timeout' => self::REQUEST_TIMEOUT_TRANSCRIPTION, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_TRANSCRIPTION], 'nextcloud' => ['allow_local_address' => true]]; - $options['multipart'] = [ - ['name' => 'model', 'contents' => Application::DEFAULT_TRANSCRIPTION_MODEL_ID], - ['name' => 'file', 'contents' => $inputSpeech, 'filename' => 'file.mp3'], - ['name' => 'response_format', 'contents' => 'verbose_json'], - ]; - $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); - $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); - $iResponse->method('getBody')->willReturn($response); - $iResponse->method('getStatusCode')->willReturn(200); - - $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); - - $audioToTextProvider->process(self::TEST_USER1, ['input' => $file], fn () => null); - } - -} From 3c6f0ad9baf5720e0a6efe14046cdc5c9a7ddd5b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 10:38:28 +0200 Subject: [PATCH 02/11] fix: Some fixes Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- lib/Service/OpenAiAPIService.php | 5 ++- src/components/AdminSettings.vue | 55 ++++++++++++++++++++++------- src/components/PersonalSettings.vue | 26 +++++++++++--- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php index 70768d7a..ef3c50e0 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -1010,7 +1010,7 @@ public function getImageRequestOptions(?string $userId, ServiceConfig $service): ]; if ($service->getImageRequestAuth()) { - if ($service->getUseBasicAuth()) { + if ($service->usesBasicAuth()) { if ($service->getBasicUser() !== '' && $service->getBasicPassword() !== '') { $requestOptions['headers']['Authorization'] = 'Basic ' . base64_encode($service->getBasicUser() . ':' . $service->getBasicPassword()); } @@ -1137,7 +1137,6 @@ public function request( $apiKey = $service->getApiKey(); $basicUser = $service->getBasicUser(); $basicPassword = $service->getBasicPassword(); - $useBasicAuth = $service->getUseBasicAuth(); $timeout = $service->getRequestTimeout(); $url = rtrim($serviceUrl, '/') . '/' . $endPoint; @@ -1152,7 +1151,7 @@ public function request( return ['error' => 'An API key is required for api.openai.com']; } - if ($service->isUsingOpenAi() || !$useBasicAuth) { + if (!$service->usesBasicAuth()) { if ($apiKey !== '') { $options['headers']['Authorization'] = 'Bearer ' . $apiKey; } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 96e917ef..a40038e2 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -185,15 +185,25 @@ export default { ] }, downloadQuotaUsageUrl() { - return generateUrl('/apps/integration_openai/quota/download-usage?type={type}&startDate={startDate}&endDate={endDate}&serviceId={serviceId}', { + const url = generateUrl('/apps/integration_openai/quota/download-usage?type={type}&startDate={startDate}&endDate={endDate}', { type: this.quotaUsage.quota_type?.id ?? 0, startDate: this.quotaUsage.start_date / 1000, endDate: this.quotaUsage.end_date / 1000, - serviceId: this.quotaUsage.service?.id ?? '', }) + // no serviceId at all for "All services": an empty one would be + // taken as a filter and match no usage row + const serviceId = this.quotaUsage.service?.id + return serviceId + ? url + '&' + new URLSearchParams({ serviceId }).toString() + : url }, }, + created() { + // debounced save per service, see debouncedSave() + this.pendingSaves = {} + }, + mounted() { this.loadUsage() }, @@ -242,6 +252,7 @@ export default { } try { await confirmPassword() + this.cancelPendingSaves(service.id) await axios.delete(generateUrl('/apps/integration_openai/services/{id}', { id: service.id })) this.services = this.services.filter(s => s.id !== service.id) showSuccess(t('integration_openai', 'Service removed')) @@ -264,18 +275,38 @@ export default { */ saveService(serviceId, values, sensitive) { this.applyToService(serviceId, values) - if (sensitive) { - this.saveSensitiveServiceDebounced(serviceId) - } else { - this.saveServiceDebounced(serviceId) + this.debouncedSave(serviceId, sensitive)() + }, + /** + * The debounced save of one service. + * + * Every service gets its own timer: a single shared one would drop the + * pending save of a service as soon as another one is edited, losing + * the first service's change. + * + * @param {string} serviceId ID of the service to save + * @param {boolean} sensitive whether the change needs a confirmed password + * @return {Function} the debounced save of this service + */ + debouncedSave(serviceId, sensitive) { + const key = (sensitive ? 'sensitive:' : 'plain:') + serviceId + if (this.pendingSaves[key] === undefined) { + this.pendingSaves[key] = debounce(() => this.putService(serviceId, sensitive), 2000) + } + return this.pendingSaves[key] + }, + /** + * Drop the pending saves of a service, so nothing is written after it + * has been removed + * + * @param {string} serviceId ID of the service + */ + cancelPendingSaves(serviceId) { + for (const key of [`plain:${serviceId}`, `sensitive:${serviceId}`]) { + this.pendingSaves[key]?.clear() + delete this.pendingSaves[key] } }, - saveServiceDebounced: debounce(function(serviceId) { - this.putService(serviceId, false) - }, 2000), - saveSensitiveServiceDebounced: debounce(function(serviceId) { - this.putService(serviceId, true) - }, 2000), /** * @param {string} serviceId ID of the service to save * @param {boolean} sensitive whether to send the URL and the credentials diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 57c8cbf0..2cd2361b 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -206,6 +206,11 @@ export default { }, }, + created() { + // debounced save per service, see debouncedSave() + this.pendingSaves = {} + }, + mounted() { this.loadQuotaInfo() }, @@ -218,11 +223,24 @@ export default { }, 2000), onSensitiveInput(serviceId, values) { this.credentials[serviceId] = { ...this.credentials[serviceId], ...values } - this.saveCredentialsDebounced(serviceId) + this.debouncedSave(serviceId)() + }, + /** + * The debounced save of the credentials of one service. + * + * Every service gets its own timer: a single shared one would drop the + * pending save of a service as soon as the credentials of another one + * are entered, losing the first service's key. + * + * @param {string} serviceId ID of the service to save + * @return {Function} the debounced save of this service + */ + debouncedSave(serviceId) { + if (this.pendingSaves[serviceId] === undefined) { + this.pendingSaves[serviceId] = debounce(() => this.saveCredentials(serviceId), 2000) + } + return this.pendingSaves[serviceId] }, - saveCredentialsDebounced: debounce(function(serviceId) { - this.saveCredentials(serviceId) - }, 2000), async saveCredentials(serviceId) { const stored = this.credentials[serviceId] const values = { From 35ea8a094db922ebd727b8451170b20038d4d1d2 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 10:52:30 +0200 Subject: [PATCH 03/11] fix: Check in missing files Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- lib/Controller/ServiceController.php | 178 +++++ .../TaskProcessingProviderListener.php | 44 ++ .../Version060000Date20260908120000.php | 372 ++++++++++ lib/Service/ServiceConfig.php | 493 +++++++++++++ lib/Service/ServicesService.php | 432 ++++++++++++ lib/TaskProcessing/ProviderFactory.php | 197 ++++++ lib/TaskProcessing/ProviderIdentity.php | 44 ++ src/components/ModelSelector.vue | 79 +++ src/components/ServiceForm.vue | 655 ++++++++++++++++++ tests/unit/Service/MultiServiceTest.php | 307 ++++++++ 10 files changed, 2801 insertions(+) create mode 100644 lib/Controller/ServiceController.php create mode 100644 lib/Listener/TaskProcessingProviderListener.php create mode 100644 lib/Migration/Version060000Date20260908120000.php create mode 100644 lib/Service/ServiceConfig.php create mode 100644 lib/Service/ServicesService.php create mode 100644 lib/TaskProcessing/ProviderFactory.php create mode 100644 lib/TaskProcessing/ProviderIdentity.php create mode 100644 src/components/ModelSelector.vue create mode 100644 src/components/ServiceForm.vue create mode 100644 tests/unit/Service/MultiServiceTest.php diff --git a/lib/Controller/ServiceController.php b/lib/Controller/ServiceController.php new file mode 100644 index 00000000..423eb670 --- /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, true)); + } 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/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/Version060000Date20260908120000.php b/lib/Migration/Version060000Date20260908120000.php new file mode 100644 index 00000000..15b1d18a --- /dev/null +++ b/lib/Migration/Version060000Date20260908120000.php @@ -0,0 +1,372 @@ +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 { + if ($this->appConfig->getValueString(Application::APP_ID, Application::SERVICES_CONFIG_KEY, '', lazy: true) !== '') { + // already migrated + return; + } + + $mainService = $this->buildMainService(); + $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(), + ]; + + foreach ($this->getOverrides() as $prefix => [$modality, $quotaType]) { + $service = $this->buildOverrideService($prefix, $modality); + if ($service === null) { + continue; + } + $services[] = $service; + $serviceIdByQuotaType[$quotaType] = $service->getId(); + } + + $output->info('Migrated the OpenAI/LocalAI configuration to ' . count($services) . ' service(s)'); + + $this->migrateUserCredentials($mainService->getId()); + $this->attributeQuotaUsage($serviceIdByQuotaType); + + 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(): 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), + '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 $this->servicesService->addService($values); + } + + /** + * A modality that had its own URL becomes a service that serves only that + * modality + */ + private function buildOverrideService(string $prefix, string $modality): ?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, + ]; + $values[$modality . '_enabled'] = $this->isModalityEnabledInOldConfig($modality); + $values[$modality . '_models'] = [$this->getOldModel($modality)]; + + return $this->servicesService->addService($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 a modality. The + * "Default" pseudo model means the service serves one fixed model. + */ + 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', + default => 'default_completion_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(); + } + + /** + * 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/Service/ServiceConfig.php b/lib/Service/ServiceConfig.php new file mode 100644 index 00000000..cd1f9126 --- /dev/null +++ b/lib/Service/ServiceConfig.php @@ -0,0 +1,493 @@ + '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', + '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, + private bool $multimodalImageEnabled = true, + private bool $multimodalAudioEnabled = false, + private bool $multimodalVideoEnabled = false, + private bool $multimodalDocumentEnabled = false, + 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 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 '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); + 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)); + } + + /** + * @param mixed $quotas + * @return array + */ + private static function normalizeQuotas(mixed $quotas): array { + $normalized = Application::DEFAULT_QUOTAS; + 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(); + } + + 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, + '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 { + return [ + 'id' => $this->id, + 'display_name' => $this->getDisplayName(), + 'url' => $this->getRequestUrl(), + // 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..37bbb340 --- /dev/null +++ b/lib/Service/ServicesService.php @@ -0,0 +1,432 @@ +servicesCache !== null) { + return $this->servicesCache; + } + $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 = []; + } + + $services = []; + foreach ($stored as $values) { + if (!is_array($values) || !isset($values['id']) || !is_string($values['id'])) { + continue; + } + foreach (ServiceConfig::SECRET_PROPERTIES as $secret) { + $values[$secret] = $this->decrypt($values[$secret] ?? ''); + } + $services[] = ServiceConfig::fromArray($values['id'], $values); + } + $this->servicesCache = $services; + return $services; + } + + /** + * @return ServiceConfig[] all services that expose at least one model for the given modality + */ + public function getServicesForModality(string $modality): array { + return array_values(array_filter( + $this->getServices(), + static fn (ServiceConfig $service) => count($service->getModels($modality)) > 0, + )); + } + + 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; + } + + /** + * The first configured service, used where a single service has to be + * picked without further context + */ + public function getDefaultService(): ?ServiceConfig { + return $this->getServices()[0] ?? null; + } + + public function hasOpenAiService(): bool { + foreach ($this->getServices() as $service) { + if ($service->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); + $this->appConfig->deleteKey(Application::APP_ID, Application::MODELS_CACHE_KEY . '_' . $id); + $this->deleteAllUserCredentials($id); + } + + /** + * 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 that still counts towards an instance-wide quota rule, 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; + $cache = $this->cacheFactory->createDistributed(Application::APP_ID); + // the URL or the credentials may have changed, so the cached model + // lists cannot be trusted anymore + $cache->clear(Application::MODELS_CACHE_KEY); + // the quota amounts of a service are part of the cached fallback quota + // rule, so a changed quota would otherwise not be enforced + $cache->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'); + } + } + } + + private function generateId(): string { + $counter = $this->appConfig->getValueInt(Application::APP_ID, 'service_id_counter', 0) + 1; + $this->appConfig->setValueInt(Application::APP_ID, 'service_id_counter', $counter); + return 's' . $counter; + } + + 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/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php new file mode 100644 index 00000000..5b8e036f --- /dev/null +++ b/lib/TaskProcessing/ProviderFactory.php @@ -0,0 +1,197 @@ +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 TranslateProvider($this->openAiAPIService, $this->l, $this->translateService, $service, $model), + new MultimodalChatWithToolsProvider( + $this->openAiAPIService, $this->l, $this->logger, $this->watermarkingService, $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, $model, + ); + } + 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, $service, $model, + ); + } + // ... and speech generation on top of that + $ttsModel = $service->getFirstModel(Application::MODALITY_TTS); + if ($ttsModel !== null) { + $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..d5a74c14 --- /dev/null +++ b/lib/TaskProcessing/ProviderIdentity.php @@ -0,0 +1,44 @@ +service->getId() . '-' . self::slugifyModel($this->model) . '-' . $taskSlug; + } + + /** + * 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; + } +} diff --git a/src/components/ModelSelector.vue b/src/components/ModelSelector.vue new file mode 100644 index 00000000..2facdfe1 --- /dev/null +++ b/src/components/ModelSelector.vue @@ -0,0 +1,79 @@ + + + + + + diff --git a/src/components/ServiceForm.vue b/src/components/ServiceForm.vue new file mode 100644 index 00000000..06e6217e --- /dev/null +++ b/src/components/ServiceForm.vue @@ -0,0 +1,655 @@ + + + + + + diff --git a/tests/unit/Service/MultiServiceTest.php b/tests/unit/Service/MultiServiceTest.php new file mode 100644 index 00000000..7f98438a --- /dev/null +++ b/tests/unit/Service/MultiServiceTest.php @@ -0,0 +1,307 @@ +createUser(self::TEST_USER1, self::TEST_USER1); + \OCP\Server::get(\OCP\IUserManager::class)->registerBackend($backend); + } + + protected function setUp(): void { + parent::setUp(); + + $this->loginAsUser(self::TEST_USER1); + + $this->servicesService = \OCP\Server::get(ServicesService::class); + + // We'll hijack the client service and subsequently iClient to return a mock response from the OpenAI API + $clientService = $this->createMock(IClientService::class); + $this->iClient = $this->createMock(IClient::class); + $clientService->method('newClient')->willReturn($this->iClient); + + $this->openAiApiService = new OpenAiAPIService( + \OCP\Server::get(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCP\IL10N::class), + \OCP\Server::get(IAppConfig::class), + \OCP\Server::get(ICacheFactory::class), + \OCP\Server::get(QuotaUsageMapper::class), + \OCP\Server::get(OpenAiSettingsService::class), + new StreamingService( + $this->createMock(\OCP\IL10N::class), + ), + new OpenAiFileService( + $this->createMock(\OCP\IL10N::class), + $this->createMock(\OCP\Files\IRootFolder::class), + $this->createMock(\OCP\TaskProcessing\IManager::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + ), + $this->createMock(\OCP\Notification\IManager::class), + \OCP\Server::get(QuotaRuleService::class), + $this->servicesService, + $clientService, + true + ); + } + + protected function tearDown(): void { + foreach ($this->services as $service) { + $this->servicesService->deleteService($service->getId()); + } + $this->services = []; + parent::tearDown(); + } + + public static function tearDownAfterClass(): void { + // Delete quota usage for test user + $quotaUsageMapper = \OCP\Server::get(QuotaUsageMapper::class); + try { + $quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } catch (\OCP\Db\Exception|\RuntimeException|\Exception|\Throwable $e) { + // Ignore + } + + $backend = new \Test\Util\User\Dummy(); + $backend->deleteUser(self::TEST_USER1); + \OCP\Server::get(\OCP\IUserManager::class)->removeBackend($backend); + + parent::tearDownAfterClass(); + } + + /** + * @param array $values + */ + private function addService(array $values): ServiceConfig { + $service = $this->servicesService->addService($values); + $this->services[] = $service; + return $service; + } + + public function testTextToSpeechProvider(): void { + $service = $this->addService([ + 'url' => self::SPEECH_BASE, + 'api_key' => self::APIKEY_SPEECH, + 'request_timeout' => self::REQUEST_TIMEOUT_SPEECH, + 'tts_models' => [self::SPEECH_MODEL], + ]); + + $ttsProvider = new TextToSpeechProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + \OCP\Server::get(WatermarkingService::class), + $service, + self::SPEECH_MODEL, + ); + + // the provider is named after its model and the service it belongs to + $this->assertSame(self::SPEECH_MODEL . ' (speech-generator.ai)', $ttsProvider->getName()); + $this->assertStringContainsString($service->getId(), $ttsProvider->getId()); + + $inputText = 'This is a test prompt'; + + $response = file_get_contents(__DIR__ . '/../../res/speech.mp3'); + + if (!$response) { + throw new \RuntimeException('Could not read test resourcce `speech.mp3`'); + } + + $url = self::SPEECH_BASE . '/audio/speech'; + + $options = ['timeout' => self::REQUEST_TIMEOUT_SPEECH, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_SPEECH, 'Content-Type' => 'application/json'], 'nextcloud' => ['allow_local_address' => true]]; + $options['body'] = json_encode([ + 'input' => $inputText, + 'voice' => Application::DEFAULT_SPEECH_VOICE, + 'model' => self::SPEECH_MODEL, + 'response_format' => 'mp3', + 'speed' => 1, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $ttsProvider->process(self::TEST_USER1, ['input' => $inputText], fn () => null, includeWatermark: false); + } + + public function testTextToImageProvider(): void { + $service = $this->addService([ + 'url' => self::IMAGE_BASE, + 'api_key' => self::APIKEY_IMAGE, + 'request_timeout' => self::REQUEST_TIMEOUT_IMAGE, + 'image_models' => [self::IMAGE_MODEL], + ]); + + $textToImageProvider = new TextToImageProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + \OCP\Server::get(IClientService::class), + \OCP\Server::get(WatermarkingService::class), + $service, + self::IMAGE_MODEL, + ); + + $inputText = 'This is a test prompt'; + + $responseImage = file_get_contents(__DIR__ . '/../../res/trees.jpg'); + + if (!$responseImage) { + throw new \RuntimeException('Could not read test resourcce `trees.jpg`'); + } + + $response = json_encode([ + 'data' => [ + [ + 'b64_json' => base64_encode($responseImage), + ] + ] + ]); + + $url = self::IMAGE_BASE . '/images/generations'; + + $options = ['timeout' => self::REQUEST_TIMEOUT_IMAGE, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_IMAGE, 'Content-Type' => 'application/json'], 'nextcloud' => ['allow_local_address' => true]]; + $options['body'] = json_encode([ + 'prompt' => $inputText, + 'size' => '1024x1024', + 'n' => 1, + 'model' => self::IMAGE_MODEL, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $textToImageProvider->process(self::TEST_USER1, ['input' => $inputText, 'numberOfImages' => 1], fn () => null); + } + + public function testAudioToTextProvider(): void { + $service = $this->addService([ + 'url' => self::TRANSCRIPTION_BASE, + 'api_key' => self::APIKEY_TRANSCRIPTION, + 'request_timeout' => self::REQUEST_TIMEOUT_TRANSCRIPTION, + 'stt_models' => [self::TRANSCRIPTION_MODEL], + ]); + + $audioToTextProvider = new AudioToTextProvider( + $this->openAiApiService, + $this->createMock(\Psr\Log\LoggerInterface::class), + $this->createMock(\OCP\IL10N::class), + $service, + self::TRANSCRIPTION_MODEL, + ); + + $file = $this->createMock(\OCP\Files\File::class); + + $inputSpeech = file_get_contents(__DIR__ . '/../../res/speech.mp3'); + + if (!$inputSpeech) { + throw new \RuntimeException('Could not read test resource `speech.mp3`'); + } + $file->method('isReadable')->willReturn(true); + $file->method('getContent')->willReturn($inputSpeech); + + $response = json_encode([ + 'text' => 'Transcribed text' + ]); + + $url = self::TRANSCRIPTION_BASE . '/audio/transcriptions'; + + $options = ['timeout' => self::REQUEST_TIMEOUT_TRANSCRIPTION, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => 'Bearer ' . self::APIKEY_TRANSCRIPTION], 'nextcloud' => ['allow_local_address' => true]]; + $options['multipart'] = [ + ['name' => 'model', 'contents' => self::TRANSCRIPTION_MODEL], + ['name' => 'file', 'contents' => $inputSpeech, 'filename' => 'file.mp3'], + ['name' => 'response_format', 'contents' => 'verbose_json'], + ]; + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $audioToTextProvider->process(self::TEST_USER1, ['input' => $file], fn () => null); + } + + public function testProvidersOfDifferentServicesHaveDifferentIds(): void { + $first = $this->addService(['url' => self::IMAGE_BASE, 'image_models' => [self::IMAGE_MODEL]]); + $second = $this->addService(['url' => self::SPEECH_BASE, 'image_models' => [self::IMAGE_MODEL]]); + + $makeProvider = fn (ServiceConfig $service) => new TextToImageProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + \OCP\Server::get(IClientService::class), + \OCP\Server::get(WatermarkingService::class), + $service, + self::IMAGE_MODEL, + ); + + $this->assertNotSame($makeProvider($first)->getId(), $makeProvider($second)->getId()); + } +} From 2a4bec2f7aab2d1a116d9beb7dd6a146bfa37d87 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 11:05:35 +0200 Subject: [PATCH 04/11] fix: Add missing validations and other fixes Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- .../Version060000Date20260908120000.php | 28 +++++++++++++------ lib/Service/OpenAiAPIService.php | 6 ++-- lib/Service/ServiceConfig.php | 25 +++++++++++++++-- lib/Service/ServicesService.php | 7 +++++ lib/TaskProcessing/ProviderFactory.php | 8 ++++-- lib/TaskProcessing/TextToImageProvider.php | 6 ++++ src/components/AdminSettings.vue | 1 + src/components/ServiceForm.vue | 6 ++++ 8 files changed, 72 insertions(+), 15 deletions(-) diff --git a/lib/Migration/Version060000Date20260908120000.php b/lib/Migration/Version060000Date20260908120000.php index 15b1d18a..06a47c39 100644 --- a/lib/Migration/Version060000Date20260908120000.php +++ b/lib/Migration/Version060000Date20260908120000.php @@ -100,7 +100,10 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array return; } - $mainService = $this->buildMainService(); + // 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 = [ @@ -111,7 +114,7 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array ]; foreach ($this->getOverrides() as $prefix => [$modality, $quotaType]) { - $service = $this->buildOverrideService($prefix, $modality); + $service = $this->buildOverrideService($prefix, $modality, 's' . (count($services) + 1)); if ($service === null) { continue; } @@ -119,11 +122,18 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $serviceIdByQuotaType[$quotaType] = $service->getId(); } - $output->info('Migrated the OpenAI/LocalAI configuration to ' . count($services) . ' service(s)'); - + // Both of these are re-runnable: the credential migration moves one + // preference at a time, and the usage attribution only touches rows + // that have no service yet. $this->migrateUserCredentials($mainService->getId()); $this->attributeQuotaUsage($serviceIdByQuotaType); + $this->servicesService->setServices($services); + // keep the ID generator in sync with the IDs handed out above + $this->appConfig->setValueInt(Application::APP_ID, 'service_id_counter', count($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); } @@ -147,7 +157,7 @@ private function getOverrides(): array { * The main configuration becomes the first service. It serves every * modality that was enabled and not overridden by its own URL. */ - private function buildMainService(): ServiceConfig { + private function buildMainService(string $id): ServiceConfig { $values = [ 'name' => $this->getString('service_name'), 'url' => $this->getString('url'), @@ -174,6 +184,7 @@ private function buildMainService(): ServiceConfig { '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], ]; @@ -184,14 +195,14 @@ private function buildMainService(): ServiceConfig { $values[$modality . '_models'] = $overridden ? [] : [$this->getOldModel($modality)]; } - return $this->servicesService->addService($values); + 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): ?ServiceConfig { + private function buildOverrideService(string $prefix, string $modality, string $id): ?ServiceConfig { $url = $this->getString($prefix . 'url'); if ($url === '') { return null; @@ -213,11 +224,12 @@ private function buildOverrideService(string $prefix, string $modality): ?Servic '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 $this->servicesService->addService($values); + return ServiceConfig::fromArray($id, $values); } private function isModalityEnabledInOldConfig(string $modality): bool { diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php index ef3c50e0..0681e02d 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -107,8 +107,10 @@ private function isModelListValid($models): bool { public function getModels(?string $userId, ServiceConfig $service, bool $refresh = false): array { $serviceId = $service->getId(); $cache = $this->cacheFactory->createDistributed(Application::APP_ID); - $userCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_' . ($userId ?? ''); - $adminCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_main'; + // the user ID goes into its own 'user_' namespace so that no UID can + // ever produce the admin key and poison the list served to everyone + $userCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_user_' . ($userId ?? ''); + $adminCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_admin'; $dbCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId; if (!$refresh) { diff --git a/lib/Service/ServiceConfig.php b/lib/Service/ServiceConfig.php index cd1f9126..af327857 100644 --- a/lib/Service/ServiceConfig.php +++ b/lib/Service/ServiceConfig.php @@ -50,6 +50,7 @@ class ServiceConfig implements JsonSerializable { 'image_enabled' => 'boolean', 'stt_enabled' => 'boolean', 'tts_enabled' => 'boolean', + 'translation_enabled' => 'boolean', 'text_models' => 'array', 'image_models' => 'array', 'stt_models' => 'array', @@ -94,6 +95,7 @@ public function __construct( 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 = [], @@ -169,6 +171,8 @@ public function with(array $values): self { 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); @@ -177,7 +181,7 @@ public function with(array $values): self { break; case 'tts_models': $new->ttsModels = self::normalizeModels($value); break; - case 'quotas': $new->quotas = self::normalizeQuotas($value); + case 'quotas': $new->quotas = self::normalizeQuotas($value, $new->quotas); break; } } @@ -198,11 +202,16 @@ private static function normalizeModels(mixed $models): array { } /** + * 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 { - $normalized = Application::DEFAULT_QUOTAS; + private static function normalizeQuotas(mixed $quotas, array $base): array { + $normalized = $base; if (!is_array($quotas)) { return $normalized; } @@ -366,6 +375,15 @@ public function getImageRequestAuth(): bool { 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, @@ -449,6 +467,7 @@ public function jsonSerialize(): array { '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, diff --git a/lib/Service/ServicesService.php b/lib/Service/ServicesService.php index 37bbb340..614169fa 100644 --- a/lib/Service/ServicesService.php +++ b/lib/Service/ServicesService.php @@ -401,6 +401,13 @@ private function validate(array $values): void { 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'); + } } private function generateId(): string { diff --git a/lib/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php index 5b8e036f..3653b848 100644 --- a/lib/TaskProcessing/ProviderFactory.php +++ b/lib/TaskProcessing/ProviderFactory.php @@ -89,11 +89,15 @@ private function getTextProviders(ServiceConfig $service, string $model): array 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 TranslateProvider($this->openAiAPIService, $this->l, $this->translateService, $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, @@ -170,7 +174,7 @@ private function getSttProviders(ServiceConfig $service, string $model): array { } // ... and speech generation on top of that $ttsModel = $service->getFirstModel(Application::MODALITY_TTS); - if ($ttsModel !== null) { + if ($ttsModel !== null && $service->getTranslationEnabled()) { $providers[] = new AudioToAudioTranslateProvider( $this->openAiAPIService, $this->translateService, $this->watermarkingService, $this->logger, $this->l10nFactory, $this->l, $this->userManager, diff --git a/lib/TaskProcessing/TextToImageProvider.php b/lib/TaskProcessing/TextToImageProvider.php index 609f470d..dbcd7549 100644 --- a/lib/TaskProcessing/TextToImageProvider.php +++ b/lib/TaskProcessing/TextToImageProvider.php @@ -9,6 +9,7 @@ namespace OCA\OpenAi\TaskProcessing; +use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Service\OpenAiAPIService; use OCA\OpenAi\Service\ServiceConfig; use OCA\OpenAi\Service\WatermarkingService; @@ -116,6 +117,11 @@ public function process(?string $userId, array $input, callable $reportProgress, 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')); diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index a40038e2..e9ea8c4d 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -344,6 +344,7 @@ export default { image_enabled: service.image_enabled, stt_enabled: service.stt_enabled, tts_enabled: service.tts_enabled, + translation_enabled: service.translation_enabled, text_models: service.text_models, image_models: service.image_models, stt_models: service.stt_models, diff --git a/src/components/ServiceForm.vue b/src/components/ServiceForm.vue index 06e6217e..9024b1ad 100644 --- a/src/components/ServiceForm.vue +++ b/src/components/ServiceForm.vue @@ -261,6 +261,12 @@ @update:model-value="onInput({ use_max_completion_tokens_param: $event })"> {{ t('integration_openai', 'Use "{newParam}" parameter instead of the deprecated "{deprecatedParam}"', { newParam: 'max_completion_tokens', deprecatedParam: 'max_tokens' }) }} + + {{ t('integration_openai', 'Offer translation') }} +
{{ t('integration_openai', 'Multimodal LLM Support') }}
{{ t('integration_openai', 'Which kinds of attachments the models of this service accept.') }} From 70660e76c1c720bd94f2e6692fd18eacfab06b95 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 11:34:48 +0200 Subject: [PATCH 05/11] fix: Small fixes Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- lib/AppInfo/Application.php | 7 --- lib/Service/ServicesService.php | 53 +++++++++---------- .../AudioToTextEnhancedProvider.php | 4 +- lib/TaskProcessing/ProviderFactory.php | 2 +- lib/TaskProcessing/TextToImageProvider.php | 4 +- src/components/AdminSettings.vue | 31 +++++++++-- src/components/PersonalSettings.vue | 25 ++++++++- 7 files changed, 81 insertions(+), 45 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4a8423b5..08a37a4f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -78,13 +78,6 @@ class Application extends App implements IBootstrap { public const MODALITY_IMAGE = 'image'; public const MODALITY_STT = 'stt'; public const MODALITY_TTS = 'tts'; - public const MODALITIES = [ - self::MODALITY_TEXT, - self::MODALITY_IMAGE, - self::MODALITY_STT, - self::MODALITY_TTS, - ]; - /** App config key holding the JSON list of connected services */ public const SERVICES_CONFIG_KEY = 'services'; diff --git a/lib/Service/ServicesService.php b/lib/Service/ServicesService.php index 614169fa..e38d7896 100644 --- a/lib/Service/ServicesService.php +++ b/lib/Service/ServicesService.php @@ -52,6 +52,23 @@ public function getServices(): array { if ($this->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); @@ -63,28 +80,14 @@ public function getServices(): array { $stored = []; } - $services = []; + $rows = []; foreach ($stored as $values) { if (!is_array($values) || !isset($values['id']) || !is_string($values['id'])) { continue; } - foreach (ServiceConfig::SECRET_PROPERTIES as $secret) { - $values[$secret] = $this->decrypt($values[$secret] ?? ''); - } - $services[] = ServiceConfig::fromArray($values['id'], $values); + $rows[] = $values; } - $this->servicesCache = $services; - return $services; - } - - /** - * @return ServiceConfig[] all services that expose at least one model for the given modality - */ - public function getServicesForModality(string $modality): array { - return array_values(array_filter( - $this->getServices(), - static fn (ServiceConfig $service) => count($service->getModels($modality)) > 0, - )); + return $rows; } public function getService(string $id): ?ServiceConfig { @@ -107,17 +110,13 @@ public function getServiceOrFail(string $id): ServiceConfig { return $service; } - /** - * The first configured service, used where a single service has to be - * picked without further context - */ - public function getDefaultService(): ?ServiceConfig { - return $this->getServices()[0] ?? null; - } - public function hasOpenAiService(): bool { - foreach ($this->getServices() as $service) { - if ($service->isUsingOpenAi()) { + // 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; } } diff --git a/lib/TaskProcessing/AudioToTextEnhancedProvider.php b/lib/TaskProcessing/AudioToTextEnhancedProvider.php index a2bed954..0117d6c9 100644 --- a/lib/TaskProcessing/AudioToTextEnhancedProvider.php +++ b/lib/TaskProcessing/AudioToTextEnhancedProvider.php @@ -24,7 +24,8 @@ * the first text model selected for that service. */ class AudioToTextEnhancedProvider implements ISynchronousProvider { - use ProviderIdentity; + // 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, @@ -32,7 +33,6 @@ public function __construct( private OpenAiAPIService $openAiAPIService, private LoggerInterface $logger, private ServiceConfig $service, - private string $model, ) { } diff --git a/lib/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php index 3653b848..db0cdb28 100644 --- a/lib/TaskProcessing/ProviderFactory.php +++ b/lib/TaskProcessing/ProviderFactory.php @@ -169,7 +169,7 @@ private function getSttProviders(ServiceConfig $service, string $model): array { $providers[] = new AudioToTextEnhancedProvider( $audioToText, new ReformatParagraphsProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $textModel), - $this->openAiAPIService, $this->logger, $service, $model, + $this->openAiAPIService, $this->logger, $service, ); } // ... and speech generation on top of that diff --git a/lib/TaskProcessing/TextToImageProvider.php b/lib/TaskProcessing/TextToImageProvider.php index dbcd7549..b0797654 100644 --- a/lib/TaskProcessing/TextToImageProvider.php +++ b/lib/TaskProcessing/TextToImageProvider.php @@ -50,7 +50,9 @@ public function getTaskTypeId(): string { } public function getExpectedRuntime(): int { - return $this->openAiAPIService->getExpTextProcessingTime($this->service); + // 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 { diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index e9ea8c4d..da5e91f4 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -117,7 +117,7 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSelect from '@nextcloud/vue/components/NcSelect' import axios from '@nextcloud/axios' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { confirmPassword } from '@nextcloud/password-confirmation' import { generateUrl } from '@nextcloud/router' @@ -224,6 +224,24 @@ export default { ) } }, + /** + * Ask for the password confirmation the sensitive endpoints require. + * + * Dismissing the dialog is a deliberate choice rather than a failure, + * so it must not be reported as one. + * + * @return {Promise} whether the password was confirmed + */ + async confirmPasswordOrCancel() { + try { + await confirmPassword() + return true + } catch (error) { + showWarning(t('integration_openai', 'Password confirmation cancelled, nothing was changed')) + console.debug('Password confirmation was dismissed', error) + return false + } + }, applyToService(serviceId, values) { const index = this.services.findIndex(service => service.id === serviceId) if (index !== -1) { @@ -250,8 +268,10 @@ export default { if (!window.confirm(t('integration_openai', 'Remove {service}? The providers it exposes will stop working.', { service: service.display_name }))) { return } + if (!await this.confirmPasswordOrCancel()) { + return + } try { - await confirmPassword() this.cancelPendingSaves(service.id) await axios.delete(generateUrl('/apps/integration_openai/services/{id}', { id: service.id })) this.services = this.services.filter(s => s.id !== service.id) @@ -352,10 +372,11 @@ export default { quotas: service.quotas, } + if (sensitive && !await this.confirmPasswordOrCancel()) { + return + } + try { - if (sensitive) { - await confirmPassword() - } const url = sensitive ? generateUrl('/apps/integration_openai/services/{id}/sensitive', { id: serviceId }) : generateUrl('/apps/integration_openai/services/{id}', { id: serviceId }) diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 2cd2361b..e60f63fb 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -154,7 +154,7 @@ import NcSelect from '@nextcloud/vue/components/NcSelect' import NcTextField from '@nextcloud/vue/components/NcTextField' import axios from '@nextcloud/axios' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { formatRelativeTime } from '@nextcloud/l10n' import { confirmPassword } from '@nextcloud/password-confirmation' @@ -241,6 +241,24 @@ export default { } return this.pendingSaves[serviceId] }, + /** + * Ask for the password confirmation the credentials endpoint requires. + * + * Dismissing the dialog is a deliberate choice rather than a failure, + * so it must not be reported as one. + * + * @return {Promise} whether the password was confirmed + */ + async confirmPasswordOrCancel() { + try { + await confirmPassword() + return true + } catch (error) { + showWarning(t('integration_openai', 'Password confirmation cancelled, nothing was changed')) + console.debug('Password confirmation was dismissed', error) + return false + } + }, async saveCredentials(serviceId) { const stored = this.credentials[serviceId] const values = { @@ -254,8 +272,11 @@ export default { if (stored.basic_password !== SECRET_PLACEHOLDER) { values.basic_password = (stored.basic_password ?? '').trim() } + if (!await this.confirmPasswordOrCancel()) { + return + } + try { - await confirmPassword() const url = generateUrl('/apps/integration_openai/services/{id}/user-credentials', { id: serviceId }) await axios.put(url, { values }) showSuccess(t('integration_openai', 'OpenAI options saved')) From 6a81f93b77c633fe1a282fae555df6e8b65f6e4b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 9 Sep 2026 12:13:43 +0200 Subject: [PATCH 06/11] fix: Small fixes and cleanups Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Klehr --- CHANGELOG.md | 2 + appinfo/info.xml | 1 - lib/AppInfo/Application.php | 11 +- lib/Controller/ServiceController.php | 2 +- lib/Cron/RefreshModels.php | 39 ----- .../Version030900Date20251006152735.php | 7 +- .../Version060000Date20260908120000.php | 15 +- lib/Service/OpenAiAPIService.php | 163 +++++++----------- lib/Service/ServiceConfig.php | 11 +- lib/Service/ServicesService.php | 11 +- .../AudioToTextEnhancedProvider.php | 4 +- lib/TaskProcessing/ProviderFactory.php | 4 +- .../TextToImageImprovedPromptProvider.php | 4 +- package.json | 2 +- src/components/PersonalSettings.vue | 16 +- src/components/ServiceForm.vue | 32 +++- tests/unit/Providers/OpenAiProviderTest.php | 2 + tests/unit/Service/MultiServiceTest.php | 17 +- 18 files changed, 167 insertions(+), 176 deletions(-) delete mode 100644 lib/Cron/RefreshModels.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 319159db..f30d1194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Quota amounts and usage are tracked per service; quota rules stay instance-wide +- 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 diff --git a/appinfo/info.xml b/appinfo/info.xml index 6eaf2a68..01e59ea8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -127,7 +127,6 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\OpenAi\Cron\CleanupQuotaDb - OCA\OpenAi\Cron\RefreshModels OCA\OpenAi\Settings\Admin diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 08a37a4f..82290679 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -51,6 +51,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; @@ -63,9 +72,7 @@ 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', '粤语']]; diff --git a/lib/Controller/ServiceController.php b/lib/Controller/ServiceController.php index 423eb670..c86e82e2 100644 --- a/lib/Controller/ServiceController.php +++ b/lib/Controller/ServiceController.php @@ -124,7 +124,7 @@ public function destroy(string $id): DataResponse { public function models(string $id): DataResponse { try { $service = $this->servicesService->getServiceOrFail($id); - return new DataResponse($this->openAiAPIService->getModels(null, $service, true)); + 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); diff --git a/lib/Cron/RefreshModels.php b/lib/Cron/RefreshModels.php deleted file mode 100644 index 684bb743..00000000 --- a/lib/Cron/RefreshModels.php +++ /dev/null @@ -1,39 +0,0 @@ -setInterval(60 * 60 * 24); // Daily - } - - protected function run($argument) { - $this->logger->debug('Run daily model refresh job'); - foreach ($this->servicesService->getServices() as $service) { - try { - $this->openAIAPIService->getModels(null, $service, true); - } catch (\Throwable $e) { - $this->logger->info('Could not refresh the model list of service ' . $service->getId(), ['exception' => $e]); - } - } - } -} diff --git a/lib/Migration/Version030900Date20251006152735.php b/lib/Migration/Version030900Date20251006152735.php index bd32e530..5e0d4790 100644 --- a/lib/Migration/Version030900Date20251006152735.php +++ b/lib/Migration/Version030900Date20251006152735.php @@ -21,8 +21,9 @@ class Version030900Date20251006152735 extends SimpleMigrationStep { * @param array $options */ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { - // This used to refresh the model list into oc_appconfig. Model lists are - // stored per service now and refreshed by the migration to multiple - // services, so there is nothing left to do here. + // 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 index 06a47c39..9e486790 100644 --- a/lib/Migration/Version060000Date20260908120000.php +++ b/lib/Migration/Version060000Date20260908120000.php @@ -13,6 +13,7 @@ use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Service\ServiceConfig; use OCA\OpenAi\Service\ServicesService; +use OCP\BackgroundJob\IJobList; use OCP\DB\ISchemaWrapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\Types; @@ -53,12 +54,15 @@ class Version060000Date20260908120000 extends SimpleMigrationStep { 'tts_url', 'tts_service_name', 'tts_api_key', 'tts_basic_user', 'tts_basic_password', 'tts_use_basic_auth', 'tts_request_timeout', 'models', 'models_image', 'models_stt', 'models_tts', + 'openai_text_generation_time', 'localai_text_generation_time', + 'openai_image_generation_time', 'localai_image_generation_time', ]; public function __construct( private IAppConfig $appConfig, private IConfig $config, private IDBConnection $db, + private IJobList $jobList, private ServicesService $servicesService, ) { } @@ -95,6 +99,11 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt * @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; @@ -242,15 +251,15 @@ private function isModalityEnabledInOldConfig(string $modality): bool { } /** - * The model that was configured as the default for a modality. The - * "Default" pseudo model means the service serves one fixed model. + * 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', - default => 'default_completion_model_id', }; return $this->getString($key) ?: Application::DEFAULT_MODEL_ID; } diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php index 0681e02d..5ff3846c 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -33,7 +33,6 @@ use OCP\TaskProcessing\Exception\UserFacingProcessingException; use Psr\Log\LoggerInterface; use RuntimeException; -use Throwable; use function json_encode; /** @@ -41,7 +40,6 @@ */ class OpenAiAPIService { private IClient $client; - private array $modelsMemoryCache = []; public function __construct( private LoggerInterface $logger, @@ -96,74 +94,20 @@ private function isModelListValid($models): bool { } /** - * Get the model list of a service + * 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 ServiceConfig $service - * @param bool $refresh whether to bypass the caches and make a network request * @return array the model list response, with the models in the 'data' key * @throws Exception */ - public function getModels(?string $userId, ServiceConfig $service, bool $refresh = false): array { - $serviceId = $service->getId(); - $cache = $this->cacheFactory->createDistributed(Application::APP_ID); - // the user ID goes into its own 'user_' namespace so that no UID can - // ever produce the admin key and poison the list served to everyone - $userCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_user_' . ($userId ?? ''); - $adminCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId . '_admin'; - $dbCacheKey = Application::MODELS_CACHE_KEY . '_' . $serviceId; - - if (!$refresh) { - if (array_key_exists($serviceId, $this->modelsMemoryCache)) { - $this->logger->debug('Getting OpenAI models from the memory cache'); - return $this->modelsMemoryCache[$serviceId]; - } - - // 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[$serviceId] = $userCachedModels; - return $userCachedModels; - } - } - - // if the user has their own credentials for this service, skip the admin cache - if (!$this->servicesService->userHasOwnCredentials($userId, $service)) { - // here we know there is either no user cache or userId is null - // so if there are 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[$serviceId] = $adminCachedModels; - return $adminCachedModels; - } - } - - // if we don't need to refresh the 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[$serviceId] = $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'); + $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) { @@ -184,11 +128,6 @@ public function getModels(?string $userId, ServiceConfig $service, bool $refresh 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[$serviceId] = $modelsResponse; - // we always store the model list after getting it - $modelsObjectString = json_encode($modelsResponse); - $this->appConfig->setValueString(Application::APP_ID, $dbCacheKey, $modelsObjectString); return $modelsResponse; } @@ -311,11 +250,16 @@ public function getUserQuotaInfo(string $userId): array { foreach (Application::DEFAULT_QUOTAS as $quotaType => $_) { $rule = $ownCredentials ? null : $this->quotaRuleService->getRule($quotaType, $userId, $service); // a matching quota rule is a global budget, the fallback quota is the one of the service - $serviceId = ($rule === null || $rule['id'] === null) ? $service->getId() : null; + $instanceWide = $rule !== null && $rule['id'] !== null; + $serviceId = $instanceWide ? null : $service->getId(); $quotaInfo[$quotaType] = [ 'type' => $this->translatedQuotaType($quotaType), 'unit' => $this->translatedQuotaUnit($quotaType), 'limit' => $rule === null ? 0 : $rule['amount'], + // the usage of an instance-wide budget is the same number + // under every service, so the frontend can say so instead + // of looking like the quota applies several times over + 'instance_wide' => $instanceWide, ]; try { $quotaInfo[$quotaType]['used'] = $this->quotaUsageMapper->getQuotaUnitsOfUserInTimePeriod( @@ -1067,51 +1011,72 @@ public function requestSpeechCreation( } /** - * @return int + * The measured processing time of a service, in seconds */ public function getExpTextProcessingTime(ServiceConfig $service): int { - return $service->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)); + 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, ServiceConfig $service): void { - $oldTime = floatval($this->getExpTextProcessingTime($service)); - $newTime = (1.0 - Application::EXPECTED_RUNTIME_LOWPASS_FACTOR) * $oldTime + Application::EXPECTED_RUNTIME_LOWPASS_FACTOR * floatval($runtime); - - if ($service->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); - } + $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(ServiceConfig $service): int { - return $service->isUsingOpenAi() - ? 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)); + 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, ServiceConfig $service): void { - $oldTime = floatval($this->getExpImgProcessingTime($service)); - $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 ($service->isUsingOpenAi()) { - $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, + ); } /** diff --git a/lib/Service/ServiceConfig.php b/lib/Service/ServiceConfig.php index af327857..7cff54e6 100644 --- a/lib/Service/ServiceConfig.php +++ b/lib/Service/ServiceConfig.php @@ -83,10 +83,13 @@ public function __construct( 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 = false, + private bool $multimodalAudioEnabled = true, private bool $multimodalVideoEnabled = false, - private bool $multimodalDocumentEnabled = 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, @@ -483,10 +486,12 @@ public function jsonSerialize(): array { * @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(), - 'url' => $this->getRequestUrl(), // the effective scheme, so the user is asked for the credential // that requests to this service actually use 'use_basic_auth' => $this->usesBasicAuth(), diff --git a/lib/Service/ServicesService.php b/lib/Service/ServicesService.php index e38d7896..5d7c5832 100644 --- a/lib/Service/ServicesService.php +++ b/lib/Service/ServicesService.php @@ -182,7 +182,9 @@ public function deleteService(string $id): void { throw new Exception('Unknown service: ' . $id); } $this->storeServices($remaining); - $this->appConfig->deleteKey(Application::APP_ID, Application::MODELS_CACHE_KEY . '_' . $id); + foreach (Application::PROCESSING_TIME_KEYS as $key) { + $this->appConfig->deleteKey(Application::APP_ID, $key . '_' . $id); + } $this->deleteAllUserCredentials($id); } @@ -364,13 +366,10 @@ private function storeServices(array $services): void { sensitive: true, ); $this->servicesCache = null; - $cache = $this->cacheFactory->createDistributed(Application::APP_ID); - // the URL or the credentials may have changed, so the cached model - // lists cannot be trusted anymore - $cache->clear(Application::MODELS_CACHE_KEY); // the quota amounts of a service are part of the cached fallback quota // rule, so a changed quota would otherwise not be enforced - $cache->clear(Application::QUOTA_RULES_CACHE_PREFIX); + $this->cacheFactory->createDistributed(Application::APP_ID) + ->clear(Application::QUOTA_RULES_CACHE_PREFIX); } /** diff --git a/lib/TaskProcessing/AudioToTextEnhancedProvider.php b/lib/TaskProcessing/AudioToTextEnhancedProvider.php index 0117d6c9..d5f31d93 100644 --- a/lib/TaskProcessing/AudioToTextEnhancedProvider.php +++ b/lib/TaskProcessing/AudioToTextEnhancedProvider.php @@ -11,6 +11,7 @@ use OCA\OpenAi\Service\OpenAiAPIService; use OCA\OpenAi\Service\ServiceConfig; +use OCP\IL10N; use OCP\TaskProcessing\ISynchronousProvider; use OCP\TaskProcessing\TaskTypes\AudioToText; use Psr\Log\LoggerInterface; @@ -32,6 +33,7 @@ public function __construct( private ReformatParagraphsProvider $reformatParagraphsProvider, private OpenAiAPIService $openAiAPIService, private LoggerInterface $logger, + private IL10N $l, private ServiceConfig $service, ) { } @@ -41,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 { diff --git a/lib/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php index db0cdb28..762df420 100644 --- a/lib/TaskProcessing/ProviderFactory.php +++ b/lib/TaskProcessing/ProviderFactory.php @@ -140,7 +140,7 @@ private function getImageProviders(ServiceConfig $service, string $model): array $providers[] = new TextToImageImprovedPromptProvider( $textToImage, new TextToTextProvider($this->openAiAPIService, $this->l, $service, $textModel), - $this->logger, $this->l, $this->openAiAPIService, $service, $model, + $this->logger, $this->l, $this->openAiAPIService, $service, ); } return $providers; @@ -169,7 +169,7 @@ private function getSttProviders(ServiceConfig $service, string $model): array { $providers[] = new AudioToTextEnhancedProvider( $audioToText, new ReformatParagraphsProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $textModel), - $this->openAiAPIService, $this->logger, $service, + $this->openAiAPIService, $this->logger, $this->l, $service, ); } // ... and speech generation on top of that diff --git a/lib/TaskProcessing/TextToImageImprovedPromptProvider.php b/lib/TaskProcessing/TextToImageImprovedPromptProvider.php index 5e9962b8..ad2a3d50 100644 --- a/lib/TaskProcessing/TextToImageImprovedPromptProvider.php +++ b/lib/TaskProcessing/TextToImageImprovedPromptProvider.php @@ -27,7 +27,8 @@ * for that service. */ class TextToImageImprovedPromptProvider implements ISynchronousWatermarkingProvider { - use ProviderIdentity; + // 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, @@ -36,7 +37,6 @@ public function __construct( private IL10N $l10n, private OpenAiAPIService $openAiAPIService, private ServiceConfig $service, - private string $model, ) { } 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/PersonalSettings.vue b/src/components/PersonalSettings.vue index e60f63fb..58bef389 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -31,7 +31,6 @@
{{ service.display_name }} - {{ service.url }}
@@ -97,6 +96,9 @@ {{ 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', 'Quota types marked with an asterisk (*) are limited by an instance-wide quota rule. That is a single budget spanning every service, so the same usage is shown for all of them.') }} +
{{ service.name }} @@ -118,7 +120,7 @@ - {{ quota.type }} + {{ quota.instance_wide ? quota.type + ' *' : quota.type }} {{ Math.round(quota.used / quota.limit * 100) + ' %' }} @@ -204,6 +206,12 @@ export default { service => Object.values(service.quota_usage).some(quota => quota.used_pool), ) }, + /** Whether any quota type is governed by an instance-wide quota rule */ + instanceWideUsed() { + return (this.quotaInfo?.services ?? []).some( + service => Object.values(service.quota_usage).some(quota => quota.instance_wide), + ) + }, }, created() { @@ -340,10 +348,6 @@ export default { margin-bottom: 4px; } - &__url { - font-weight: normal; - color: var(--color-text-maxcontrast); - } } .quota-table { diff --git a/src/components/ServiceForm.vue b/src/components/ServiceForm.vue index 9024b1ad..671d843c 100644 --- a/src/components/ServiceForm.vue +++ b/src/components/ServiceForm.vue @@ -220,11 +220,13 @@
+ @update:model-value="onLlmExtraParamsInput" />