diff --git a/AUTHORS.md b/AUTHORS.md index a2374c5d..758cc123 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -11,8 +11,9 @@ - John Molakvoæ - Juergen Kellerer - Julien Veyssier +- Lukas Schaefer - Marcel Klehr -- Sami Finnilä - Micke Nordin - rakekniven <2069590+rakekniven@users.noreply.github.com> - Richard Steinmetz +- Sami Finnilä diff --git a/README.md b/README.md index cc2f56ed..30937e95 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Positive: Negative: * The training data is not freely available, limiting the ability of external parties to check and correct for bias or optimise the model’s performance and CO2 usage. +### Rating for Text-To-Speech via the OpenAI API: 🔴 + +Negative: +* The software for training and inferencing of this model is proprietary, limiting running it locally or training by yourself +* The trained model is not freely available, so the model can not be ran on-premises +* The training data is not freely available, limiting the ability of external parties to check and correct for bias or optimise the model’s performance and CO2 usage. + + ### Rating for Text generation via LocalAI: 🟢 Positive: diff --git a/appinfo/info.xml b/appinfo/info.xml index c6a6af45..268160e7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -65,6 +65,13 @@ Positive: Negative: * The training data is not freely available, limiting the ability of external parties to check and correct for bias or optimise the model’s performance and CO2 usage. +### Rating for Text-To-Speech via the OpenAI API: 🔴 + +Negative: +* The software for training and inferencing of this model is proprietary, limiting running it locally or training by yourself +* The trained model is not freely available, so the model can not be ran on-premises +* The training data is not freely available, limiting the ability of external parties to check and correct for bias or optimise the model’s performance and CO2 usage. + ### Rating for Text generation via LocalAI: 🟢 Positive: diff --git a/composer.lock b/composer.lock index 28e7c89f..fbd18824 100644 --- a/composer.lock +++ b/composer.lock @@ -71,12 +71,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "6dafbfc3711b3811baf8bd4e1223eac9409d4b5b" + "reference": "9c1710075ffc568f6b804ac628b8fa990263db0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/6dafbfc3711b3811baf8bd4e1223eac9409d4b5b", - "reference": "6dafbfc3711b3811baf8bd4e1223eac9409d4b5b", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/9c1710075ffc568f6b804ac628b8fa990263db0f", + "reference": "9c1710075ffc568f6b804ac628b8fa990263db0f", "shasum": "" }, "require": { @@ -112,7 +112,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2025-04-03T19:09:28+00:00" + "time": "2025-06-25T00:54:25+00:00" }, { "name": "psr/clock", diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 8a8a453d..02c27003 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,7 @@ use OCA\OpenAi\TaskProcessing\ReformulateProvider; use OCA\OpenAi\TaskProcessing\SummaryProvider; use OCA\OpenAi\TaskProcessing\TextToImageProvider; +use OCA\OpenAi\TaskProcessing\TextToSpeechProvider; use OCA\OpenAi\TaskProcessing\TextToTextChatProvider; use OCA\OpenAi\TaskProcessing\TextToTextProvider; use OCA\OpenAi\TaskProcessing\TopicsProvider; @@ -40,6 +41,12 @@ class Application extends App implements IBootstrap { public const DEFAULT_COMPLETION_MODEL_ID = 'gpt-3.5-turbo'; public const DEFAULT_IMAGE_MODEL_ID = 'dall-e-2'; public const DEFAULT_TRANSCRIPTION_MODEL_ID = 'whisper-1'; + public const DEFAULT_SPEECH_MODEL_ID = 'tts-1-hd'; + public const DEFAULT_SPEECH_VOICE = 'alloy'; + public const DEFAULT_SPEECH_VOICES = [ + 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', + 'onyx', 'nova', 'sage', 'shimmer', 'verse' + ]; public const DEFAULT_DEFAULT_IMAGE_SIZE = '1024x1024'; public const MAX_GENERATION_IDLE_TIME = 60 * 60 * 24 * 10; public const DEFAULT_CHUNK_SIZE = 10000; @@ -56,11 +63,13 @@ class Application extends App implements IBootstrap { public const QUOTA_TYPE_TEXT = 0; public const QUOTA_TYPE_IMAGE = 1; public const QUOTA_TYPE_TRANSCRIPTION = 2; + public const QUOTA_TYPE_SPEECH = 3; public const DEFAULT_QUOTAS = [ self::QUOTA_TYPE_TEXT => 0, // 0 = unlimited self::QUOTA_TYPE_IMAGE => 0, // 0 = unlimited self::QUOTA_TYPE_TRANSCRIPTION => 0, // 0 = unlimited + self::QUOTA_TYPE_SPEECH => 0, // 0 = unlimited ]; @@ -110,6 +119,10 @@ public function register(IRegistrationContext $context): void { $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ProofreadProvider::class); } } + if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')) { + $context->registerTaskProcessingTaskType(\OCA\OpenAi\TaskProcessing\TextToSpeechTaskType::class); + } + $context->registerTaskProcessingProvider(TextToSpeechProvider::class); if ($this->appConfig->getValueString(Application::APP_ID, 't2i_provider_enabled', '1') === '1') { $context->registerTaskProcessingProvider(TextToImageProvider::class); } diff --git a/lib/Service/OpenAiAPIService.php b/lib/Service/OpenAiAPIService.php index 0c3e0afa..09187aa3 100644 --- a/lib/Service/OpenAiAPIService.php +++ b/lib/Service/OpenAiAPIService.php @@ -28,6 +28,8 @@ use OCP\TaskProcessing\ShapeEnumValue; use Psr\Log\LoggerInterface; use RuntimeException; +use Throwable; +use function json_encode; /** * Service to make requests to OpenAI/LocalAI REST API @@ -132,7 +134,7 @@ public function getModels(string $userId): array { throw $e; } if (isset($modelsResponse['error'])) { - $this->logger->warning('Error retrieving models: ' . \json_encode($modelsResponse)); + $this->logger->warning('Error retrieving models: ' . json_encode($modelsResponse)); $this->areCredsValid = false; throw new Exception($modelsResponse['error'], Http::STATUS_INTERNAL_SERVER_ERROR); } @@ -142,7 +144,7 @@ public function getModels(string $userId): array { } if (!$this->isModelListValid($modelsResponse['data'])) { - $this->logger->warning('Invalid models response: ' . \json_encode($modelsResponse)); + $this->logger->warning('Invalid models response: ' . json_encode($modelsResponse)); $this->areCredsValid = false; throw new Exception($this->l10n->t('Invalid models response received'), Http::STATUS_INTERNAL_SERVER_ERROR); } @@ -185,7 +187,7 @@ public function getModelEnumValues(?string $userId): array { array_unshift($modelEnumValues, new ShapeEnumValue($this->l10n->t('Default'), 'Default')); } return $modelEnumValues; - } catch (\Throwable $e) { + } 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 []; @@ -248,6 +250,8 @@ public function translatedQuotaType(int $type): string { return $this->l10n->t('Image generation'); case Application::QUOTA_TYPE_TRANSCRIPTION: return $this->l10n->t('Audio transcription'); + case Application::QUOTA_TYPE_SPEECH: + return $this->l10n->t('Text to speech'); default: return $this->l10n->t('Unknown'); } @@ -266,6 +270,8 @@ public function translatedQuotaUnit(int $type): string { return $this->l10n->t('images'); case Application::QUOTA_TYPE_TRANSCRIPTION: return $this->l10n->t('seconds'); + case Application::QUOTA_TYPE_SPEECH: + return $this->l10n->t('characters'); default: return $this->l10n->t('Unknown'); } @@ -742,6 +748,41 @@ public function getImageRequestOptions(?string $userId): array { return $requestOptions; } + /** + * @param string|null $userId + * @param string $prompt + * @param string $model + * @param string $voice + * @param float $speed + * @return array + * @throws Exception + */ + public function requestSpeechCreation( + ?string $userId, string $prompt, string $model, string $voice, float $speed = 1, + ): array { + if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_SPEECH)) { + 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, + ]; + + $apiResponse = $this->request($userId, 'audio/speech', $params, 'POST'); + + try { + $charCount = mb_strlen($prompt); + $this->quotaUsageMapper->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_SPEECH, $charCount); + } catch (DBException $e) { + $this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_SPEECH . '. Error: ' . $e->getMessage()); + } + return $apiResponse; + } + /** * @return int */ @@ -893,9 +934,16 @@ public function request(?string $userId, string $endPoint, array $params = [], s if ($respCode >= 400) { return ['error' => $this->l10n->t('Bad credentials')]; - } else { - return json_decode($body, true) ?: []; } + if ($response->getHeader('Content-Type') === 'application/json') { + $parsedBody = json_decode($body, true); + if ($parsedBody === null) { + $this->logger->warning('Could not JSON parse the response', ['body' => $body]); + return ['error' => 'Could not JSON parse the response']; + } + return $parsedBody; + } + return ['body' => $body]; } catch (ClientException|ServerException $e) { $responseBody = $e->getResponse()->getBody(); $parsedResponseBody = json_decode($responseBody, true); diff --git a/lib/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php index 8c53947a..62d2bca5 100644 --- a/lib/Service/OpenAiSettingsService.php +++ b/lib/Service/OpenAiSettingsService.php @@ -23,6 +23,9 @@ class OpenAiSettingsService { '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', @@ -36,6 +39,7 @@ class OpenAiSettingsService { 'llm_provider_enabled' => 'boolean', 't2i_provider_enabled' => 'boolean', 'stt_provider_enabled' => 'boolean', + 'tts_provider_enabled' => 'boolean', 'chat_endpoint_enabled' => 'boolean', 'basic_user' => 'string', 'basic_password' => 'string', @@ -118,6 +122,37 @@ public function getAdminDefaultImageSize(): string { return $this->appConfig->getValueString(Application::APP_ID, 'default_image_size') ?: Application::DEFAULT_DEFAULT_IMAGE_SIZE; } + /** + * @return string + */ + public function getAdminDefaultTtsModelId(): string { + return $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id') ?: Application::DEFAULT_MODEL_ID; + } + + /** + * @return string + */ + public function getAdminDefaultTtsVoice(): string { + return $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice') ?: 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) + ) ?: json_encode(Application::DEFAULT_SPEECH_VOICES), + true, + ); + if (!is_array($voices)) { + $voices = Application::DEFAULT_SPEECH_VOICES; + } + return $voices; + } + /** * @return string */ @@ -266,6 +301,9 @@ public function getAdminConfig(): array { '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(), @@ -282,6 +320,7 @@ public function getAdminConfig(): array { 'llm_provider_enabled' => $this->getLlmProviderEnabled(), 't2i_provider_enabled' => $this->getT2iProviderEnabled(), 'stt_provider_enabled' => $this->getSttProviderEnabled(), + 'tts_provider_enabled' => $this->getTtsProviderEnabled(), 'chat_endpoint_enabled' => $this->getChatEndpointEnabled(), 'basic_user' => $this->getAdminBasicUser(), 'basic_password' => $this->getAdminBasicPassword(), @@ -354,6 +393,13 @@ 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'; + } + //////////////////////////////////////////// //////////// Setters for settings ////////// @@ -425,6 +471,15 @@ public function setAdminDefaultSttModelId(string $defaultSttModelId): void { $this->appConfig->setValueString(Application::APP_ID, 'default_stt_model_id', $defaultSttModelId); } + /** + * @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); + } + /** * @param string $defaultImageModelId * @return void @@ -434,6 +489,14 @@ public function setAdminDefaultImageModelId(string $defaultImageModelId): void { $this->appConfig->setValueString(Application::APP_ID, 'default_image_model_id', $defaultImageModelId); } + /** + * @param string $voice + * @return void + */ + public function setAdminDefaultTtsVoice(string $voice): void { + $this->appConfig->setValueString(Application::APP_ID, 'default_speech_voice', $voice); + } + /** * @param string $defaultImageSize * @return void @@ -575,6 +638,15 @@ public function setUseBasicAuth(bool $useBasicAuth): void { $this->invalidateModelsCache(); } + /** + * @param array $voices + * @return void + */ + public function setAdminTtsVoices(array $voices): void { + $this->appConfig->setValueString(Application::APP_ID, 'tts_voices', json_encode($voices)); + $this->invalidateModelsCache(); + } + /** * Set the admin config for the settings page * @param mixed[] $adminConfig @@ -614,6 +686,9 @@ public function setAdminConfig(array $adminConfig): void { 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']); } @@ -653,6 +728,12 @@ public function setAdminConfig(array $adminConfig): void { 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['chat_endpoint_enabled'])) { $this->setChatEndpointEnabled($adminConfig['chat_endpoint_enabled']); } @@ -665,6 +746,9 @@ public function setAdminConfig(array $adminConfig): void { if (isset($adminConfig['use_basic_auth'])) { $this->setUseBasicAuth($adminConfig['use_basic_auth']); } + if (isset($adminConfig['tts_voices'])) { + $this->setAdminTtsVoices($adminConfig['tts_voices']); + } } /** @@ -741,6 +825,14 @@ 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 */ diff --git a/lib/TaskProcessing/SummaryProvider.php b/lib/TaskProcessing/SummaryProvider.php index 9356e134..4608ed4d 100644 --- a/lib/TaskProcessing/SummaryProvider.php +++ b/lib/TaskProcessing/SummaryProvider.php @@ -126,8 +126,8 @@ public function process(?string $userId, array $input, callable $reportProgress) try { $completions = []; if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) { - $summarySystemPrompt = 'You are a helpful assistant that summarizes text in the same language as the text. ' . - 'You should only return the summary without any additional information.'; + $summarySystemPrompt = 'You are a helpful assistant that summarizes text in the same language as the text. ' + . 'You should only return the summary without any additional information.'; foreach ($prompts as $p) { $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $p, $summarySystemPrompt, null, 1, $maxTokens); @@ -135,9 +135,9 @@ public function process(?string $userId, array $input, callable $reportProgress) } } else { $wrapSummaryPrompt = function (string $p): string { - return 'You are a helpful assistant that summarizes text in the same language as the text. ' . - 'You should only return the summary without any additional information. ' . - 'Here is the text to summarize:\n\n' . $p . '\n'; + return 'You are a helpful assistant that summarizes text in the same language as the text. ' + . 'You should only return the summary without any additional information. ' + . 'Here is the text to summarize:\n\n' . $p . '\n'; }; foreach (array_map($wrapSummaryPrompt, $prompts) as $p) { diff --git a/lib/TaskProcessing/TextToSpeechProvider.php b/lib/TaskProcessing/TextToSpeechProvider.php new file mode 100644 index 00000000..59cff238 --- /dev/null +++ b/lib/TaskProcessing/TextToSpeechProvider.php @@ -0,0 +1,162 @@ +openAiAPIService->isUsingOpenAi() + ? $this->l->t('OpenAI\'s Text to Speech') + : $this->openAiAPIService->getServiceName(); + } + + public function getTaskTypeId(): string { + if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToSpeech')) { + return \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID; + } + return TextToSpeechTaskType::ID; + } + + public function getExpectedRuntime(): int { + return $this->openAiAPIService->getExpTextProcessingTime(); + } + + public function getInputShapeEnumValues(): array { + return []; + } + + public function getInputShapeDefaults(): array { + return []; + } + + + public function getOptionalInputShape(): array { + return [ + 'voice' => new ShapeDescriptor( + $this->l->t('Voice'), + $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() + ? $this->l->t('Speech speed modifier (Valid values: 0.25-4)') + : $this->l->t('Speech speed modifier'), + EShapeType::Number + ) + ]; + } + + public function getOptionalInputShapeEnumValues(): array { + $voices = json_decode($this->appConfig->getValueString(Application::APP_ID, 'tts_voices')) ?: Application::DEFAULT_SPEECH_VOICES; + return [ + 'voice' => array_map(function ($v) { return new ShapeEnumValue($v, $v); }, $voices), + 'model' => $this->openAiAPIService->getModelEnumValues($this->userId), + ]; + } + + public function getOptionalInputShapeDefaults(): array { + $adminVoice = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_voice') ?: Application::DEFAULT_SPEECH_VOICE; + $adminModel = $this->appConfig->getValueString(Application::APP_ID, 'default_speech_model_id') ?: Application::DEFAULT_SPEECH_MODEL_ID; + return [ + 'voice' => $adminVoice, + 'model' => $adminModel, + 'speed' => 1, + ]; + } + + public function getOutputShapeEnumValues(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function getOptionalOutputShapeEnumValues(): array { + return []; + } + + public function process(?string $userId, array $input, callable $reportProgress): array { + + if (!isset($input['input']) || !is_string($input['input'])) { + throw new RuntimeException('Invalid prompt'); + } + // For OpenAI the text input limit is 4096 characters (https://platform.openai.com/docs/api-reference/audio/createSpeech#audio-createspeech-input) + $prompt = $input['input']; + + 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) ?: 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) ?: Application::DEFAULT_SPEECH_VOICE; + } + + $speed = 1; + if (isset($input['speed']) && is_numeric($input['speed'])) { + $speed = $input['speed']; + if ($this->openAiAPIService->isUsingOpenAi()) { + if ($speed > 4) { + $speed = 4; + } elseif ($speed < 0.25) { + $speed = 0.25; + } + } + } + + try { + $apiResponse = $this->openAiAPIService->requestSpeechCreation($userId, $prompt, $model, $voice, $speed); + + if (!isset($apiResponse['body'])) { + $this->logger->warning('OpenAI/LocalAI\'s text to speech generation failed: no speech returned'); + throw new RuntimeException('OpenAI/LocalAI\'s text to speech generation failed: no speech returned'); + } + return ['speech' => $apiResponse['body']]; + } catch (\Exception $e) { + $this->logger->warning('OpenAI/LocalAI\'s text to image generation failed with: ' . $e->getMessage(), ['exception' => $e]); + throw new RuntimeException('OpenAI/LocalAI\'s text to image generation failed with: ' . $e->getMessage()); + } + } +} diff --git a/lib/TaskProcessing/TextToSpeechTaskType.php b/lib/TaskProcessing/TextToSpeechTaskType.php new file mode 100644 index 00000000..74685124 --- /dev/null +++ b/lib/TaskProcessing/TextToSpeechTaskType.php @@ -0,0 +1,72 @@ +l->t('Generate speech'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string { + return $this->l->t('Generate speech from a transcript'); + } + + /** + * @return string + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + */ + public function getInputShape(): array { + return [ + 'input' => new ShapeDescriptor( + $this->l->t('Prompt'), + $this->l->t('Write transcript that you want the assistant to generate speech from'), + EShapeType::Text, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + */ + public function getOutputShape(): array { + return [ + 'speech' => new ShapeDescriptor( + $this->l->t('Output speech'), + $this->l->t('The generated speech'), + EShapeType::Audio + ), + ]; + } +} diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index fc50bafd..333628f6 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -388,6 +388,70 @@ {{ t('integration_openai', 'No models to list') }} +

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

+ + + {{ t('integration_openai', 'No models to list') }} + +
+ + +
+

{{ t('integration_openai', 'Usage limits') }} @@ -499,6 +563,11 @@ @update:model-value="onCheckboxChanged($event, 'stt_provider_enabled', false)"> {{ t('integration_openai', 'Speech-to-text provider (to transcribe Talk recordings for example)') }} + + {{ t('integration_openai', 'Text-to-speech provider') }} +

@@ -559,6 +628,7 @@ export default { text: null, image: null, stt: null, + tts: null, }, apiKeyUrl: 'https://platform.openai.com/account/api-keys', quotaInfo: null, @@ -643,9 +713,16 @@ export default { || this.models[1] || this.models[0] + const defaultTtsModelId = this.state.default_tts_model_id || response.data?.default_tts_model_id + const ttsModelToSelect = this.models.find(m => m.id === defaultTtsModelId) + || this.models.find(m => m.id.match(/tts/i)) + || this.models[1] + || this.models[0] + this.selectedModel.text = this.modelToNcSelectObject(completionModelToSelect) this.selectedModel.image = this.modelToNcSelectObject(imageModelToSelect) this.selectedModel.stt = this.modelToNcSelectObject(sttModelToSelect) + this.selectedModel.tts = this.modelToNcSelectObject(ttsModelToSelect) // save if url/credentials were changed OR if the values are not up-to-date in the stored settings if (shouldSave @@ -681,6 +758,9 @@ export default { } else if (type === 'stt') { this.selectedModel.stt = this.modelToNcSelectObject(DEFAULT_MODEL_ITEM) this.state.default_stt_model_id = DEFAULT_MODEL_ITEM.id + } else if (type === 'tts') { + this.selectedModel.tts = this.modelToNcSelectObject(DEFAULT_MODEL_ITEM) + this.state.default_tts_model_id = DEFAULT_MODEL_ITEM.id } } else { if (type === 'image') { @@ -689,12 +769,15 @@ export default { this.state.default_completion_model_id = selected.id } else if (type === 'stt') { this.state.default_stt_model_id = selected.id + } else if (type === 'tts') { + this.state.default_tts_model_id = selected.id } } this.saveOptions({ default_completion_model_id: this.state.default_completion_model_id, default_image_model_id: this.state.default_image_model_id, default_stt_model_id: this.state.default_stt_model_id, + default_tts_model_id: this.state.default_tts_model_id, }) }, loadQuotaInfo() { @@ -747,6 +830,8 @@ export default { default_image_size: this.state.default_image_size, quota_period: parseInt(this.state.quota_period), quotas: this.state.quotas, + tts_voices: this.state.tts_voices, + default_tts_voice: this.state.default_tts_voice, } await this.saveOptions(values, false) }, 2000), diff --git a/tests/unit/Providers/OpenAiProviderTest.php b/tests/unit/Providers/OpenAiProviderTest.php index d3c1435f..ee9923b2 100644 --- a/tests/unit/Providers/OpenAiProviderTest.php +++ b/tests/unit/Providers/OpenAiProviderTest.php @@ -21,6 +21,7 @@ use OCA\OpenAi\TaskProcessing\HeadlineProvider; use OCA\OpenAi\TaskProcessing\ProofreadProvider; use OCA\OpenAi\TaskProcessing\SummaryProvider; +use OCA\OpenAi\TaskProcessing\TextToSpeechProvider; use OCA\OpenAi\TaskProcessing\TextToTextProvider; use OCA\OpenAi\TaskProcessing\TranslateProvider; use OCP\Http\Client\IClient; @@ -148,6 +149,7 @@ public function testFreePromptProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -211,6 +213,7 @@ public function testEmojiProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -275,6 +278,7 @@ public function testHeadlineProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -339,6 +343,7 @@ public function testChangeToneProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -391,8 +396,8 @@ public function testSummaryProvider(): void { $url = self::OPENAI_API_BASE . 'chat/completions'; $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; - $systemPrompt = 'You are a helpful assistant that summarizes text in the same language as the text. ' . - 'You should only return the summary without any additional information.'; + $systemPrompt = 'You are a helpful assistant that summarizes text in the same language as the text. ' + . 'You should only return the summary without any additional information.'; $options['body'] = json_encode([ 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, 'messages' => [['role' => 'system', 'content' => $systemPrompt], @@ -405,6 +410,7 @@ public function testSummaryProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -468,6 +474,7 @@ public function testProofreadProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -535,6 +542,7 @@ public function testTranslationProvider(): void { $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); $iResponse->method('getBody')->willReturn($response); $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); @@ -548,4 +556,44 @@ public function testTranslationProvider(): void { $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); } + public function testTextToSpeechProvider(): void { + $TTSProvider = new TextToSpeechProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->createMock(\Psr\Log\LoggerInterface::class), + \OC::$server->get(IAppConfig::class), + self::TEST_USER1, + ); + + $inputText = 'This is a test prompt'; + + $response = 'BINARYDATA'; + + $url = self::OPENAI_API_BASE . 'audio/speech'; + + $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; + $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); + + $result = $TTSProvider->process(self::TEST_USER1, ['input' => $inputText], fn () => null); + $this->assertEquals(['speech' => 'BINARYDATA'], $result); + + // Check that token usage is logged properly (should be 21 characters) + $usage = $this->quotaUsageMapper->getQuotaUnitsOfUser(self::TEST_USER1, Application::QUOTA_TYPE_SPEECH); + $this->assertEquals(21, $usage); + // Clear quota usage + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } + } diff --git a/vendor-bin/php-cs-fixer/composer.lock b/vendor-bin/php-cs-fixer/composer.lock index 26f08665..35abff84 100644 --- a/vendor-bin/php-cs-fixer/composer.lock +++ b/vendor-bin/php-cs-fixer/composer.lock @@ -9,16 +9,16 @@ "packages-dev": [ { "name": "kubawerlos/php-cs-fixer-custom-fixers", - "version": "v3.24.0", + "version": "v3.27.0", "source": { "type": "git", "url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git", - "reference": "93222100a91399314c3726857e249e76c4a7d760" + "reference": "d860473d16b906c7945206177edc7d112357a706" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/93222100a91399314c3726857e249e76c4a7d760", - "reference": "93222100a91399314c3726857e249e76c4a7d760", + "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/d860473d16b906c7945206177edc7d112357a706", + "reference": "d860473d16b906c7945206177edc7d112357a706", "shasum": "" }, "require": { @@ -49,27 +49,33 @@ "description": "A set of custom fixers for PHP CS Fixer", "support": { "issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues", - "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.24.0" + "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.27.0" }, - "time": "2025-03-22T16:51:39+00:00" + "funding": [ + { + "url": "https://github.com/kubawerlos", + "type": "github" + } + ], + "time": "2025-06-10T20:53:07+00:00" }, { "name": "nextcloud/coding-standard", - "version": "v1.3.2", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/nextcloud/coding-standard.git", - "reference": "9c719c4747fa26efc12f2e8b21c14a9a75c6ba6d" + "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/9c719c4747fa26efc12f2e8b21c14a9a75c6ba6d", - "reference": "9c719c4747fa26efc12f2e8b21c14a9a75c6ba6d", + "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011", + "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011", "shasum": "" }, "require": { "kubawerlos/php-cs-fixer-custom-fixers": "^3.22", - "php": "^7.3|^8.0", + "php": "^8.0", "php-cs-fixer/shim": "^3.17" }, "type": "library", @@ -89,11 +95,14 @@ } ], "description": "Nextcloud coding standards for the php cs fixer", + "keywords": [ + "dev" + ], "support": { "issues": "https://github.com/nextcloud/coding-standard/issues", - "source": "https://github.com/nextcloud/coding-standard/tree/v1.3.2" + "source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0" }, - "time": "2024-10-14T16:49:05+00:00" + "time": "2025-06-19T12:27:27+00:00" }, { "name": "php-cs-fixer/shim", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 82212405..4bb5d2fa 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -9,16 +9,16 @@ "packages-dev": [ { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -57,7 +57,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -65,20 +65,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -121,9 +121,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "phar-io/manifest", @@ -566,16 +566,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.45", + "version": "10.5.47", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8" + "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bd68a781d8e30348bc297449f5234b3458267ae8", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", + "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", "shasum": "" }, "require": { @@ -585,7 +585,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -647,7 +647,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.45" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.47" }, "funding": [ { @@ -658,12 +658,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-02-06T16:08:12+00:00" + "time": "2025-06-20T11:29:11+00:00" }, { "name": "sebastian/cli-parser", diff --git a/vendor-bin/psalm-phar/composer.lock b/vendor-bin/psalm-phar/composer.lock index a75d9cb2..aaac0b55 100644 --- a/vendor-bin/psalm-phar/composer.lock +++ b/vendor-bin/psalm-phar/composer.lock @@ -1176,26 +1176,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1215,9 +1218,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "felixfbecker/language-server-protocol", @@ -1621,16 +1624,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -1673,9 +1676,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -1732,16 +1735,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", "shasum": "" }, "require": { @@ -1790,9 +1793,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2025-04-13T19:20:35+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -2319,23 +2322,24 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -2392,7 +2396,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.3.0" }, "funding": [ { @@ -2408,20 +2412,20 @@ "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" + "time": "2025-05-24T10:34:04+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -2434,7 +2438,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -2459,7 +2463,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -2475,11 +2479,11 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/filesystem", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", @@ -2525,7 +2529,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + "source": "https://github.com/symfony/filesystem/tree/v7.3.0" }, "funding": [ { @@ -2545,7 +2549,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -2604,7 +2608,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -2624,7 +2628,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -2682,7 +2686,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -2702,7 +2706,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -2763,7 +2767,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -2783,19 +2787,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -2843,7 +2848,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -2859,20 +2864,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd" + "reference": "000df7860439609837bbe28670b0be15783b7fbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/e5493eb51311ab0b1cc2243416613f06ed8f18bd", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", "shasum": "" }, "require": { @@ -2919,7 +2924,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" }, "funding": [ { @@ -2935,20 +2940,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T12:04:04+00:00" + "time": "2025-02-20T12:04:08+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -2966,7 +2971,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -3002,7 +3007,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -3018,20 +3023,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -3089,7 +3094,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -3105,20 +3110,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "vimeo/psalm", - "version": "6.10.0", + "version": "6.12.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252" + "reference": "cf420941d061a57050b6c468ef2c778faf40aee2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/9c0add4eb88d4b169ac04acb7c679918cbb9c252", - "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/cf420941d061a57050b6c468ef2c778faf40aee2", + "reference": "cf420941d061a57050b6c468ef2c778faf40aee2", "shasum": "" }, "require": { @@ -3223,7 +3228,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-03-31T10:12:50+00:00" + "time": "2025-05-28T12:52:06+00:00" }, { "name": "webmozart/assert",