@@ -2,13 +2,13 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-24 01:30+0300\n" +"POT-Creation-Date: 2026-06-04 10:09+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "При отправке письма произошла неизвест msgid "Email token not found. Please contact support" msgstr "E-mail токен не найден. Пожалуйста, свяжитесь со службой поддержки" -#: authentication/exceptions/email_token.py:6 +#: authentication/exceptions/email_token.py:6 authentication/routes/v2.py:32 msgid "No email token found" msgstr "Email токен не найден" @@ -116,7 +116,7 @@ msgstr "Email токен не найден" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:47 +#: authentication/exceptions/user.py:11 backend/urls.py:49 msgid "Wrong password" msgstr "Неверный пароль" @@ -507,6 +507,14 @@ msgstr "Вайтлисты для отмены политик" msgid "Invalid or expired refresh token" msgstr "Неверный или истёкший refresh токен" +#: authentication/routes/v2.py:25 +msgid "User is already confirmed" +msgstr "Аккаунт уже подтвержден" + +#: authentication/routes/v2.py:37 authentication/views.py:489 +msgid "Could not confirm email, please try again." +msgstr "Невозможно подтвердить email, попробуйте позже" + #: authentication/security.py:35 #, fuzzy #| msgid "Hidden" @@ -602,15 +610,11 @@ msgstr "Повторное приглашение сотруднику успе msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" -#: authentication/views.py:489 -msgid "Could not confirm email, please try again." -msgstr "Невозможно подтвердить email, попробуйте позже" - -#: backend/urls.py:37 +#: backend/urls.py:39 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:42 +#: backend/urls.py:44 msgid "Token is invalid" msgstr "" @@ -764,11 +768,12 @@ msgstr "Не найдено лицо на картинке. Попробуйте msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." -#: ml_model/exceptions.py:185 +#: ml_model/exceptions.py:190 msgid "not specified" msgstr "не указана" -#: ml_model/exceptions.py:187 +#: ml_model/exceptions.py:192 +#, python-format msgid "" "Version \"%(version)s\" is not available. Available versions: " "%(available_versions)s." @@ -1062,18 +1067,20 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:149 -msgid "No matching version found" -msgstr "Соответствующая версия не найдена" +#: ml_model/services/FileService.py:110 tools/media/apis.py:258 +#: tools/public_api/views/ml_service.py:56 +#: tools/public_api/views/providers/openai_compatible.py:209 +msgid "Voice not found." +msgstr "Голос не найден." -#: ml_model/services/chatgpt_5.py:130 ml_model/services/chatgpt_5_4.py:156 +#: ml_model/services/chatgpt_5.py:133 ml_model/services/chatgpt_5_4.py:159 #: ml_model/services/chatgpt_5_5.py:210 msgid "The \"Use code\" option cannot be used together with an attached image." msgstr "" "Нельзя одновременно использовать параметр «Использовать код» вместе с " "прикреплённым изображением." -#: ml_model/services/chatgpt_5_4.py:315 ml_model/services/chatgpt_5_5.py:362 +#: ml_model/services/chatgpt_5_4.py:318 ml_model/services/chatgpt_5_5.py:362 msgid "Image is ready" msgstr "Изображение готово" @@ -1104,11 +1111,11 @@ msgstr "" msgid "Unknown bucket destination" msgstr "Неизвестный бакет для загрузки" -#: ml_model/services/seedance_2_dreamina.py:108 +#: ml_model/services/seedance_2_dreamina.py:111 msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:87 +#: ml_model/services/seedream.py:90 msgid "3K output is not supported for Seedream 4.5" msgstr "3К разрешение не поддерживается для Seedream 4.5" @@ -1348,19 +1355,19 @@ msgstr "Попытки" msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:94 +#: payments/routes/v1.py:92 msgid "You do not have an active subscription to cancel" msgstr "У вас нет активной подписки для отмены" -#: payments/routes/v1.py:95 +#: payments/routes/v1.py:93 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/routes/v1.py:145 +#: payments/routes/v1.py:143 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:149 +#: payments/routes/v1.py:147 msgid "Refills" msgstr "Пополнения" @@ -1372,13 +1379,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 -#: payments/tests/test_plans.py:193 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:205 +#: payments/tests/test_plans.py:208 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:196 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:206 +#: payments/tests/test_plans.py:211 msgid "Images" msgstr "Изображения" @@ -1398,28 +1405,24 @@ msgstr "Прокси" msgid "Proxies" msgstr "Прокси" -#: tools/apps.py:9 +#: tools/apps.py:8 msgid "Tools" msgstr "Инструменты" -#: tools/apps.py:15 tools/chats/models.py:21 +#: tools/apps.py:14 tools/chats/models.py:21 msgid "Chats" msgstr "Чаты" -#: tools/apps.py:21 -msgid "Copywrite" -msgstr "Копирайт" - -#: tools/apps.py:32 +#: tools/apps.py:20 msgid "Public API" msgstr "Публичный API" -#: tools/apps.py:38 +#: tools/apps.py:26 msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:203 tools/media/apis.py:209 -#: tools/public_api/views/base.py:100 +#: tools/chats/apis.py:205 tools/media/apis.py:211 +#: tools/public_api/views/base.py:104 msgid "" "An unexpected generation error has occurred. Please try again later or use a " "different model" @@ -1427,7 +1430,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:259 +#: tools/chats/apis.py:261 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1440,16 +1443,11 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:176 +#: tools/media/apis.py:177 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:256 tools/public_api/views/ml_service.py:63 -#: tools/public_api/views/ml_service.py:107 -msgid "Voice not found." -msgstr "Голос не найден." - #: tools/media/models.py:45 msgid "Voice clone store" msgstr "Хранилище клонирования голоса" @@ -1506,8 +1504,8 @@ msgstr "Неизвестный файл" msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:86 -#: tools/public_api/views/voice.py:98 +#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:100 +#: tools/public_api/views/voice.py:114 msgid "Voice not found" msgstr "Голос не найден" @@ -1531,48 +1529,86 @@ msgstr "API Ключ" msgid "API Keys" msgstr "API Ключи" -#: tools/public_api/views/base.py:63 +#: tools/public_api/views/base.py:65 msgid "Key limit exceeded" msgstr "Превышен лимит по ключу" -#: tools/public_api/views/base.py:68 +#: tools/public_api/views/base.py:70 msgid "Model is blocked by outdating or temporary block, please retry later" msgstr "" "Модель заблокирована, т.к закончила обновляться или временно заблокирована, " "попробуйте позже" -#: tools/public_api/views/base.py:75 +#: tools/public_api/views/base.py:77 msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:128 +#: tools/public_api/views/ml_service.py:88 +msgid "Invalid info payload" +msgstr "Некорректные данные в поле info" + +#: tools/public_api/views/providers/elevenlabs_compatible.py:133 +msgid "Missing required parameter: model_id" +msgstr "Отсутствует обязательный параметр: 'model_id'" + +#: tools/public_api/views/providers/elevenlabs_compatible.py:147 +#: tools/public_api/views/providers/openai_compatible.py:110 +#: tools/public_api/views/providers/openai_compatible.py:246 +msgid "Model not found" +msgstr "Модель не найдена" + +#: tools/public_api/views/providers/openai_compatible.py:61 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" -#: tools/public_api/views/ml_service.py:143 +#: tools/public_api/views/providers/openai_compatible.py:67 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/ml_service.py:193 -msgid "Model not found" -msgstr "Модель не найдена" +#: tools/public_api/views/providers/openai_compatible.py:177 +msgid "Missing audio_sample." +msgstr "Отсутствует параметр audio_sample." + +#: tools/public_api/views/providers/openai_compatible.py:232 +#, python-format +msgid "Missing required parameter: %(param)s" +msgstr "Отсутствует обязательный параметр: %(param)s" + +#: tools/public_api/views/providers/openai_compatible.py:250 +msgid "Voice must be an object with id." +msgstr "Поле voice должно быть объектом с полем id." + +#: tools/public_api/views/providers/openai_compatible.py:256 +msgid "Voice id is empty." +msgstr "Идентификатор голоса не указан." -#: tools/public_api/views/voice.py:44 -msgid "Your voice has been uploaded successfully" -msgstr "Ваш голос успешно загружен" +#: tools/public_api/views/providers/openai_compatible.py:287 +msgid "Failed to fetch generated audio" +msgstr "Не удалось получить сгенерированное аудио" -#: tools/public_api/views/voice.py:77 +#: tools/public_api/views/voice.py:93 msgid "Preset voices are shared and cannot be edited. Use your own voice id." -msgstr "" -"Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." +msgstr "Пресеты общие и не редактируются. Используйте id собственного голоса." -#: tools/public_api/views/voice.py:87 +#: tools/public_api/views/voice.py:103 msgid "Voice title updated successfully" msgstr "Название голоса успешно обновлено" -#: tools/public_api/views/voice.py:93 +#: tools/public_api/views/voice.py:109 msgid "Preset voices are shared and cannot be deleted. Use your own voice id." -msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." +msgstr "Пресеты общие и не удаляются. Используйте id собственного голоса." + +#~ msgid "No matching version found" +#~ msgstr "Соответствующая версия не найдена" + +#~ msgid "Copywrite" +#~ msgstr "Копирайт" + +#~ msgid "Your voice has been uploaded successfully" +#~ msgstr "Ваш голос успешно загружен" + +#~ msgid "You cannot edit a voice by this id. Use your own voice_id." +#~ msgstr "Нельзя изменить голос по этому id. Используйте свой voice_id." #~ msgid "Available only in paid plan" #~ msgstr "Доступно только в платном тарифе" @@ -1660,9 +1696,6 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ msgid "Issued achievement" #~ msgstr "Выданное достижение" -#~ msgid "Account is already confirmed" -#~ msgstr "Аккаунт уже подтвержден" - #~ msgid "Stories" #~ msgstr "Истории" @@ -10,10 +10,11 @@ import openpyxl from io import BytesIO from django.db.models.fields.files import FieldFile +from django.utils.translation import gettext as _ from authentication.models import CustomUserModel -from ml_model.exceptions import UnrecognizedFileError -from tools.media.models import Voice, Preset +from ml_model.exceptions import InvalidParameterError, UnrecognizedFileError +from tools.media.models import Voice, Preset, PresetKind class FileProcessingService: @@ -98,11 +99,14 @@ class FileProcessingService: user: CustomUserModel, default_voice_slug: str = 'russian_1', ) -> FieldFile: - if voice_id: - voice = Voice.objects.get(pk=voice_id, user=user) - elif preset_id: - voice = Preset.objects.get(uid=preset_id) - else: - voice = Preset.objects.get(slug=default_voice_slug) + try: + if voice_id: + voice = Voice.objects.get(pk=voice_id, user=user) + elif preset_id: + voice = Preset.objects.get(uid=preset_id, kind=PresetKind.VOICE) + else: + voice = Preset.objects.get(slug=default_voice_slug, kind=PresetKind.VOICE) + except (Voice.DoesNotExist, Preset.DoesNotExist) as exc: + raise InvalidParameterError(_('Voice not found.')) from exc return voice.file @@ -0,0 +1,13 @@ +from .openai_compatible import ( + OpenAICompatibleAPIView, + OpenAIAudioSpeechAPIView, + OpenAIVoiceAPIView, + OpenAIVoiceDeleteAPIView, +) +from .elevenlabs_compatible import ( + ElevenlabsVoiceDeleteAPIView, + ElevenlabsVoiceEditAPIView, + ElevenlabsVoiceListAPIView, + ElevenlabsVoiceUploadAPIView, + ElevenlabsVoiceCloneAPIView, +) @@ -0,0 +1,167 @@ +import httpx +from uuid import uuid4 + +from django.http import HttpResponse +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.response import Response + +from ml_model.models import NeuronModel +from tools.public_api.permissions import HasElevenlabsAPIKey +from tools.public_api.views import VoiceView +from tools.public_api.views.voice import ( + PublicVoiceDetailAPIView, + PublicVoiceListAPIView, + PublicVoiceUploadAPIView, +) + + +class BaseElevenlabsAPIView: + permission_classes = (HasElevenlabsAPIKey,) + + def _authorization_headers(self, request): + return { + 'Authorization': request.headers.get('Xi-Api-Key') + or request.headers.get('Authorization', ''), + } + + def _proxy_request(self, request, data=None): + return type( + 'RequestProxy', + (), + { + 'data': data if data is not None else request.data, + 'headers': self._authorization_headers(request), + 'FILES': getattr(request, 'FILES', {}), + }, + )() + + +def _elevenlabs_error_response(response): + source_status_code = getattr(response, 'status_code', status.HTTP_400_BAD_REQUEST) + data = getattr(response, 'data', None) + detail = data.get('detail') if isinstance(data, dict) else data + + if isinstance(detail, dict): + msg = str(detail.get('message') or detail.get('detail') or detail or 'Request failed') + param = detail.get('param') + else: + msg = str(detail or data or 'Request failed') + param = None + + if source_status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN): + err_type, code = 'authentication_error', 'unauthorized' + response_status_code = source_status_code + else: + err_type, code = 'validation_error', 'invalid_parameters' + response_status_code = status.HTTP_400_BAD_REQUEST + + return Response( + { + 'detail': { + 'type': err_type, + 'code': code, + 'message': msg, + 'status': code, + 'request_id': str(uuid4()), + 'param': param, + } + }, + status=response_status_code, + ) + + +class ElevenlabsVoiceUploadAPIView(BaseElevenlabsAPIView, PublicVoiceUploadAPIView): + def post(self, request, *args, **kwargs): + data = request.data.copy() + data.update({'file': data.pop('files')[0], 'title': data.pop('name')[0]}) + response = super().post(self._proxy_request(request, data=data), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + voice = response.data + return Response({'voice_id': voice['id'], 'requires_verification': False}) + + +class ElevenlabsVoiceListAPIView(BaseElevenlabsAPIView, PublicVoiceListAPIView): + def get(self, request, *args, **kwargs): + response = super().get(self._proxy_request(request), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + voices = response.data + return Response( + { + 'voices': [ + { + 'voice_id': voice['id'], + 'name': voice['title'], + 'preview_url': voice['file'], + 'created_at_unix': voice['created_at'], + } + for voice in voices + ] + } + ) + + +class ElevenlabsVoiceEditAPIView(BaseElevenlabsAPIView, PublicVoiceDetailAPIView): + def post(self, request, *args, **kwargs): + data = request.data.copy() + data.update({'title': data.pop('name')[0]}) + response = super().patch(self._proxy_request(request, data=data), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + return Response({'status': 'ok'}) + + +class ElevenlabsVoiceDeleteAPIView(BaseElevenlabsAPIView, PublicVoiceDetailAPIView): + def delete(self, request, *args, **kwargs): + response = super().delete(self._proxy_request(request), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + return Response({'status': 'ok'}) + + +class ElevenlabsVoiceCloneAPIView(BaseElevenlabsAPIView, VoiceView): + def post(self, request, voice_id, *args, **kwargs): + # TODO: check for attachment text-files + data = request.data.copy() + model_ref = data.get('model_id') or data.get('model') + if not model_ref: + return _elevenlabs_error_response( + Response( + {'detail': _('Missing required parameter: model_id')}, status=status.HTTP_400_BAD_REQUEST + ) + ) + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=str(model_ref)) | Q(slug=str(model_ref)), + category__slug='voice', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _elevenlabs_error_response( + Response({'detail': _('Model not found')}, status=status.HTTP_400_BAD_REQUEST) + ) + data.update({'voice_id': voice_id}) + data.update({'content': data.pop('text')}) + data.update({'info': {'version': str(model_ref)}}) + data.pop('model_id', None) + data.pop('model', None) + result = super().post(self._proxy_request(request, data=data), model_slug) + if result.status_code >= 400: + return _elevenlabs_error_response(result) + file_url = result.data[0]['file'] + httpx_response = httpx.get(file_url) + if httpx_response.status_code >= 400: + return _elevenlabs_error_response( + Response({'detail': httpx_response.text}, status=httpx_response.status_code) + ) + return HttpResponse( + httpx_response.content, + status=httpx_response.status_code, + content_type=httpx_response.headers.get('content-type', 'application/octet-stream'), + ) @@ -0,0 +1,289 @@ +import base64 +import uuid +from datetime import datetime +from io import BytesIO + +import filetype +import httpx +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db.models import Q +from django.http import HttpResponse +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.renderers import JSONRenderer +from rest_framework.request import Request +from rest_framework.response import Response + +from ml_model.models import NeuronModel +from tools.public_api.selectors.api_key import APIKeySelector +from tools.public_api.views.base import BaseGenerationView +from tools.public_api.views.ml_service import VoiceView +from tools.public_api.views.voice import ( + PublicVoiceDetailAPIView, + PublicVoiceListAPIView, + PublicVoiceUploadAPIView, +) + + +def _get_api_user(request): + api_key_value = request.headers.get('Authorization', '') + if api_key_value.startswith('Bearer '): + api_key_value = api_key_value.split(' ', 1)[1] + return APIKeySelector.get_user_by_key(key_value=api_key_value) + + +def _openai_error( + message, + *, + param=None, + code=None, + err_type='invalid_request_error', + http_status=status.HTTP_400_BAD_REQUEST, +): + return Response( + {'error': {'message': str(message), 'type': err_type, 'param': param, 'code': code}}, + status=http_status, + ) + + +class _OctetStreamRenderer(JSONRenderer): + media_type = 'application/octet-stream' + format = 'octet-stream' + + +class OpenAICompatibleAPIView(BaseGenerationView): + def post(self, request: Request, *args, **kwargs): + data = request.data.copy() + + try: + version = data.pop('model') + except KeyError: + return _openai_error(_('You must provide a model parameter')) + content_lines = [] + file = None + messages = data.pop('messages', []) + if not messages: + return _openai_error( + _("Missing required parameter: 'messages'"), + param='messages', + code='missing_required_parameter', + ) + for m in messages: + role = m['role'].capitalize() + msg_content = m.get('content', []) + if isinstance(msg_content, str): + content_lines.append(f'[{role}] {msg_content}') + else: + for c in msg_content: + if c['type'] == 'text': + content_lines.append(f'[{role}] {c["text"]}') + elif c['type'] == 'image_url': + data_url = c['image_url']['url'] + encoded = data_url.split('base64')[-1] + buf = BytesIO(base64.b64decode(encoded)) + kind = filetype.guess(buf.read(20)) + buf.seek(0) + mime = kind.mime if kind else 'application/octet-stream' + ext = kind.extension if kind else 'bin' + file = InMemoryUploadedFile( + buf, + field_name='file', + name=f'api-file.{ext}', + content_type=mime, + size=buf.getbuffer().nbytes, + charset=None, + ) + content = '\n'.join(content_lines) + data['info'] = {key: data.pop(key) for key in data.copy().keys()} + data['info']['version'] = version + + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=data['info']['version']) | Q(slug=data['info']['version']), + category__slug='chat-bots', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _openai_error(_('Model not found')) + + data['content'] = content + data['file'] = file + + result = super().post( + type('Request', (Request,), {'data': data, 'headers': request.headers.copy()}), + model_slug, + *args, + **kwargs, + ) + if isinstance(result.data, dict): + return _openai_error(result.data['detail']) + + return Response( + { + 'id': str(uuid.uuid4()), + 'object': 'chat.completion', + 'created': int( + datetime.fromisoformat(result.data[0]['created_at'].replace('Z', '+00:00')).timestamp() + ), + 'model': version, + 'choices': [ + { + 'index': 0, + 'message': {'role': 'assistant', 'content': result.data[0]['content']}, + 'finish_reason': 'stop', + } + ], + 'service_tier': 'auto', + 'system_fingerprint': str(uuid.uuid4()), + 'usage': None, + }, + 201, + ) + + +class OpenAIVoiceAPIView(PublicVoiceUploadAPIView, PublicVoiceListAPIView): + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if response.status_code >= 400: + return response + data = [ + { + 'id': voice['id'], + 'created_at': voice['created_at'], + 'name': voice.get('title') or '', + 'object': 'audio.voice', + } + for voice in response.data + ] + ids = [item['id'] for item in data] + return Response( + { + 'data': data, + 'has_more': False, + 'object': 'list', + 'first_id': ids[0] if ids else None, + 'last_id': ids[-1] if ids else None, + } + ) + + def post(self, request, *args, **kwargs): + name = request.data.get('name') + sample = request.FILES.get('audio_sample') + if not sample: + return _openai_error(_('Missing audio_sample.'), param='audio_sample', code='missing_required_parameter') + + proxy = type( + 'RequestProxy', + (), + {'data': {'file': sample, 'title': name}, 'headers': request.headers}, + )() + response = super().post(proxy, *args, **kwargs) + if response.status_code >= 400: + detail = ( + response.data.get('detail', response.data) + if isinstance(response.data, dict) + else response.data + ) + return _openai_error(str(detail), http_status=response.status_code) + + return Response( + { + 'id': str(response.data['id']), + 'object': 'audio.voice', + 'created_at': response.data['created_at'], + 'name': response.data.get('title') or '', + }, + status=status.HTTP_201_CREATED, + ) + + +class OpenAIVoiceDeleteAPIView(PublicVoiceDetailAPIView): + def delete(self, request, voice_id: str, *args, **kwargs): + response = super().delete(request, voice_id, *args, **kwargs) + if response.status_code >= 400: + if response.status_code == status.HTTP_404_NOT_FOUND: + return _openai_error(_('Voice not found.'), param='voice_id', http_status=404) + detail = ( + response.data.get('detail', response.data) + if isinstance(response.data, dict) + else response.data + ) + return _openai_error(str(detail), param='voice_id', http_status=response.status_code) + return Response( + {'id': str(voice_id), 'object': 'audio.voice', 'deleted': True}, + status=status.HTTP_200_OK, + ) + + +class OpenAIAudioSpeechAPIView(VoiceView): + renderer_classes = (_OctetStreamRenderer,) + + def post(self, request, *args, **kwargs): + try: + model_ref = request.data['model'] + input_text = request.data['input'] + except KeyError as exc: + p = exc.args[0] + return _openai_error( + _('Missing required parameter: %(param)s') % {'param': p}, + param=p, + code='missing_required_parameter', + ) + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=model_ref) | Q(slug=model_ref), + category__slug='voice', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _openai_error(_('Model not found'), param='model') + voice_raw = request.data.get('voice') + if not isinstance(voice_raw, dict): + return _openai_error( + _('Voice must be an object with id.'), + param='voice', + code='invalid_type', + ) + voice_ref = voice_raw.get('id') + if voice_ref in (None, ''): + return _openai_error(_('Voice id is empty.'), param='voice', code='missing_required_parameter') + voice_ref = str(voice_ref).strip() + post_data = {'content': input_text, 'voice_id': voice_ref, 'info': {'version': model_ref}} + proxy = type( + 'RequestProxy', + (), + { + 'data': post_data, + 'headers': request.headers, + 'FILES': {}, + }, + )() + result = super().post(proxy, model_slug, *args, **kwargs) + if result.status_code >= 400: + detail = result.data.get('detail', result.data) if isinstance(result.data, dict) else result.data + st = result.status_code + if st == status.HTTP_402_PAYMENT_REQUIRED: + return _openai_error( + str(detail), + err_type='insufficient_quota', + code='billing_hard_limit_reached', + http_status=st, + ) + if st == status.HTTP_403_FORBIDDEN: + return _openai_error(str(detail), err_type='permission_error', http_status=st) + if st >= 500: + return _openai_error(str(detail), err_type='api_error', code='internal_error', http_status=st) + return _openai_error(str(detail), http_status=st) + r = httpx.get(result.data[0]['file'], timeout=120.0) + if r.status_code >= 400: + return _openai_error( + r.text or _('Failed to fetch generated audio'), + http_status=status.HTTP_502_BAD_GATEWAY, + ) + return HttpResponse(r.content, status=status.HTTP_200_OK, content_type='audio/mpeg') @@ -7,7 +7,21 @@ from .ml_service import ( CodeView, VoiceView, ParamView, - OpenAICompatibleAPIView ) from .user import UserInfoAPIView -from .voice import PublicVoiceViewSet +from .voice import ( + PublicVoiceUploadAPIView, + PublicVoiceListAPIView, + PublicVoiceDetailAPIView, +) +from .providers import ( + OpenAICompatibleAPIView, + OpenAIAudioSpeechAPIView, + OpenAIVoiceAPIView, + OpenAIVoiceDeleteAPIView, + ElevenlabsVoiceDeleteAPIView, + ElevenlabsVoiceEditAPIView, + ElevenlabsVoiceListAPIView, + ElevenlabsVoiceUploadAPIView, + ElevenlabsVoiceCloneAPIView, +) @@ -1,6 +1,7 @@ import logging import sys +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from rest_framework.response import Response from rest_framework.status import ( @@ -14,6 +15,7 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer from ml_model.choices import ContentTypes +from ml_model.exceptions import InvalidParameterError from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.serializers import PublicNeuronModelSerializer @@ -93,6 +95,8 @@ class BaseGenerationView(APIView): input_message.save() if isinstance(exc, InsufficientBalance): return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + elif isinstance(exc, (InvalidParameterError, ValidationError)): + return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) logger.exception(exc) return Response( { @@ -1,16 +1,9 @@ -import base64 +import json import logging -import uuid -from datetime import datetime -from io import BytesIO -import filetype -from django.core.files.uploadedfile import InMemoryUploadedFile -from django.db.models import Q from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import status -from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.views import APIView @@ -86,164 +79,25 @@ class VoiceView(BaseGenerationView): description = 'Get Voice Generation from model in URL slug. Only POST Requests.' def post(self, request, model_slug, *args, **kwargs): - if not (request.FILES.get('file') or request.data.get('file')): - api_key_value = request.headers.get('Authorization') - if (split_api_key := api_key_value.split())[0] == 'Bearer': - api_key_value = split_api_key[-1] - user = APIKeySelector.get_user_by_key(key_value=api_key_value) - voice_id = str(request.data.pop('voice_id', '')) + voice_id = str(request.data.get('voice_id', '')) + info = request.data.get('info', {}) + if isinstance(info, str): try: - if not voice_id: - voice = Preset.objects.get(slug='russian_1') - # transcription = voice.metadata.get('transcription', '') - elif voice_id.isdigit(): - voice = Voice.objects.get(pk=voice_id, user=user) - # transcription = voice.transcription - else: - voice = Preset.objects.get(uid=voice_id) - # transcription = voice.metadata.get('transcription', '') - except (Voice.DoesNotExist, Preset.DoesNotExist): - return Response( - {'detail': _('Voice not found.')}, - status=HTTP_400_BAD_REQUEST, - ) - request.data.update( - { - 'file': voice.file, # 'info': {'transcription': transcription, **request.data['info']} - } - ) + info = json.loads(info) + except json.JSONDecodeError: + return Response({'detail': _('Invalid info payload')}, status=HTTP_400_BAD_REQUEST) + if voice_id.isdigit(): + info.update({'voice_id': int(voice_id)}) + else: + info.update({'preset_id': voice_id}) + ct = getattr(request, 'content_type', '') + if 'multipart/form-data' in ct: + request.data['info'] = json.dumps(info, ensure_ascii=False) + else: + request.data["info"] = info return super().post(request, model_slug, *args, **kwargs) -class OpenAICompatibleAPIView(BaseGenerationView): - def post(self, request: Request, *args, **kwargs): - data = request.data.copy() - - try: - version = data.pop('model') - except KeyError: - return Response( - { - 'error': { - 'message': _('You must provide a model parameter'), - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - content_lines = [] - file = None - messages = data.pop('messages', []) - if not messages: - return Response( - { - 'error': { - 'message': _("Missing required parameter: 'messages'"), - 'type': 'invalid_request_error', - 'param': 'messages', - 'code': 'missing_required_parameter', - } - }, - status=HTTP_400_BAD_REQUEST, - ) - for m in messages: - role = m['role'].capitalize() - msg_content = m.get('content', []) - if isinstance(msg_content, str): - content_lines.append(f'[{role}] {msg_content}') - else: - for c in msg_content: - if c['type'] == 'text': - content_lines.append(f'[{role}] {c["text"]}') - elif c['type'] == 'image_url': - data_url = c['image_url']['url'] - encoded = data_url.split('base64')[-1] - buf = BytesIO(base64.b64decode(encoded)) - kind = filetype.guess(buf.read(20)) - buf.seek(0) - mime = kind.mime if kind else 'application/octet-stream' - ext = kind.extension if kind else 'bin' - file = InMemoryUploadedFile( - buf, - field_name='file', - name=f'api-file.{ext}', - content_type=mime, - size=buf.getbuffer().nbytes, - charset=None, - ) - content = '\n'.join(content_lines) - data['info'] = {key: data.pop(key) for key in data.copy().keys()} - data['info']['version'] = version - - try: - model_slug = ( - NeuronModel.objects.filter( - Q(model_modelversions__slug=data['info']['version']) | Q(slug=data['info']['version']), - category__slug='chat-bots', - ) - .get() - .slug - ) - except NeuronModel.DoesNotExist: - return Response( - { - 'error': { - 'message': _('Model not found'), - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - - data['content'] = content - data['file'] = file - - result = super().post( - type('Request', (Request,), {'data': data, 'headers': request.headers.copy()}), - model_slug, - *args, - **kwargs, - ) - if isinstance(result.data, dict): - return Response( - { - 'error': { - 'message': result.data['detail'], - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - - return Response( - { - 'id': str(uuid.uuid4()), - 'object': 'chat.completion', - 'created': int( - datetime.fromisoformat(result.data[0]['created_at'].replace('Z', '+00:00')).timestamp() - ), - 'model': version, - 'choices': [ - { - 'index': 0, - 'message': {'role': 'assistant', 'content': result.data[0]['content']}, - 'finish_reason': 'stop', - } - ], - 'service_tier': 'auto', - 'system_fingerprint': str(uuid.uuid4()), - 'usage': None, - }, - 201, - ) - - class ParamView(APIView): @extend_schema(responses={200: ModelParameterSerializer}) def get(self, request, model_slug, *args, **kwargs): @@ -2,19 +2,19 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.response import Response -from rest_framework.viewsets import ViewSet +from rest_framework.views import APIView from tools.media.models import Preset, PresetKind, Voice from tools.public_api.permissions import HasAPIKey from tools.public_api.serializers import ( - VoiceListItemSerializer, + VoiceItemSerializer, VoiceTitleUpdateSerializer, VoiceUploadSerializer, ) from tools.public_api.selectors.api_key import APIKeySelector -class PublicVoiceViewSet(ViewSet): +class BasePublicVoiceAPIView(APIView): authentication_classes = [] permission_classes = (HasAPIKey,) @@ -24,7 +24,9 @@ class PublicVoiceViewSet(ViewSet): api_key_value = api_key_value.split(' ', 1)[1] return APIKeySelector.get_user_by_key(key_value=api_key_value) - def create(self, request, *args, **kwargs): + +class PublicVoiceUploadAPIView(BasePublicVoiceAPIView): + def post(self, request, *args, **kwargs): user = self._get_api_user(request) serializer = VoiceUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -41,36 +43,50 @@ class PublicVoiceViewSet(ViewSet): except ValidationError as exc: return Response({'detail': ';\n'.join(exc.messages)}, status=status.HTTP_400_BAD_REQUEST) return Response( - {'detail': _('Your voice has been uploaded successfully')}, status=status.HTTP_201_CREATED + VoiceItemSerializer( + { + 'id': str(voice.pk), + 'title': voice.title, + 'file': voice.file.url, + 'created_at': int(voice.created_at.timestamp()), + } + ).data, + status=status.HTTP_201_CREATED, ) - def list(self, request, *args, **kwargs): + +class PublicVoiceListAPIView(BasePublicVoiceAPIView): + def get(self, request, *args, **kwargs): user = self._get_api_user(request) items = [] - for voice in Voice.objects.filter(user=user): + for voice in Voice.objects.filter(user=user, file__isnull=False): items.append( { 'id': str(voice.pk), 'title': voice.title, - 'file': voice.file.url if voice.file else None, + 'file': voice.file.url, + 'created_at': int(voice.created_at.timestamp()), # 'transcription': voice.transcription, } ) - for preset in Preset.objects.filter(kind=PresetKind.VOICE): + for preset in Preset.objects.filter(kind=PresetKind.VOICE, file__isnull=False): items.append( { 'id': str(preset.uid), 'title': preset.title, - 'file': preset.file.url if preset.file else None, + 'file': preset.file.url, + 'created_at': int(preset.created_at.timestamp()), # 'transcription': preset.metadata.get('transcription'), } ) - return Response(VoiceListItemSerializer(items, many=True).data, status=status.HTTP_200_OK) + return Response(VoiceItemSerializer(items, many=True).data, status=status.HTTP_200_OK) - def partial_update(self, request, voice_id: str, *args, **kwargs): + +class PublicVoiceDetailAPIView(BasePublicVoiceAPIView): + def patch(self, request, voice_id: str, *args, **kwargs): user = self._get_api_user(request) if not voice_id.isdigit(): return Response( @@ -79,14 +95,14 @@ class PublicVoiceViewSet(ViewSet): ) serializer = VoiceTitleUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) - updated = Voice.objects.filter(pk=voice_id, user=user).update( - title=serializer.validated_data['title'] - ) - if not updated: + voice = Voice.objects.filter(pk=voice_id, user=user).first() + if not voice: return Response({'detail': _('Voice not found')}, status=status.HTTP_404_NOT_FOUND) + voice.title = serializer.validated_data['title'] + voice.save() return Response({'detail': _('Voice title updated successfully')}, status=status.HTTP_200_OK) - def destroy(self, request, voice_id: str, *args, **kwargs): + def delete(self, request, voice_id: str, *args, **kwargs): user = self._get_api_user(request) if not voice_id.isdigit(): return Response( @@ -7,8 +7,10 @@ from tools.public_api.models import APIKey class HasAPIKey(permissions.BasePermission): + auth_header = 'Authorization' + def has_permission(self, request, view): - api_key_value = request.headers.get('Authorization', '') + api_key_value = request.headers.get(self.auth_header, '') if ( (split_api_key := api_key_value.split()) and len(split_api_key) > 0 @@ -21,3 +23,7 @@ class HasAPIKey(permissions.BasePermission): if api_key.expires_at and api_key.expires_at < date.today(): raise PermissionDenied('API Key is expired') return api_key + + +class HasElevenlabsAPIKey(HasAPIKey): + auth_header = 'Xi-Api-Key' @@ -10,7 +10,7 @@ __all__ = [ 'APIKeyDeleteSerializer', 'VoiceUploadSerializer', 'VoiceTitleUpdateSerializer', - 'VoiceListItemSerializer', + 'VoiceItemSerializer', ] @@ -59,8 +59,9 @@ class VoiceTitleUpdateSerializer(serializers.Serializer): title = serializers.CharField(max_length=50) -class VoiceListItemSerializer(serializers.Serializer): +class VoiceItemSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField(allow_null=True, allow_blank=True) file = serializers.CharField() + created_at = serializers.IntegerField(required=False) # transcription = serializers.CharField(allow_null=True, allow_blank=True) @@ -1,28 +1,45 @@ from django.urls import path from tools.public_api import views -urlpatterns = [ - path('api-key', views.APIKeyView.as_view()), - path('me', views.UserInfoAPIView.as_view()), - path( - 'voices', - views.PublicVoiceViewSet.as_view({'get': 'list', 'post': 'create'}), - ), - path( - 'voices/', - views.PublicVoiceViewSet.as_view({'patch': 'partial_update', 'delete': 'destroy'}), - ), +openai_urlpatterns = [ path('openai/chat/completions', views.OpenAICompatibleAPIView.as_view()), path('openai/v1/chat/completions', views.OpenAICompatibleAPIView.as_view()), + path('openai/audio/voices/', views.OpenAIVoiceDeleteAPIView.as_view()), + path('openai/v1/audio/voices/', views.OpenAIVoiceDeleteAPIView.as_view()), + path('openai/audio/voices', views.OpenAIVoiceAPIView.as_view()), + path('openai/v1/audio/voices', views.OpenAIVoiceAPIView.as_view()), + path('openai/audio/speech', views.OpenAIAudioSpeechAPIView.as_view()), + path('openai/v1/audio/speech', views.OpenAIAudioSpeechAPIView.as_view()), ] +elevenlabs_urlpatterns = [ + path('elevenlabs/v1/voices', views.ElevenlabsVoiceListAPIView.as_view()), + path('elevenlabs/v1/voices/add', views.ElevenlabsVoiceUploadAPIView.as_view()), + path('elevenlabs/v1/voices//edit', views.ElevenlabsVoiceEditAPIView.as_view()), + path('elevenlabs/v1/voices/', views.ElevenlabsVoiceDeleteAPIView.as_view()), + path('elevenlabs/v1/text-to-speech/', views.ElevenlabsVoiceCloneAPIView.as_view()), +] + +urlpatterns = ( + [ + path('api-key', views.APIKeyView.as_view()), + path('me', views.UserInfoAPIView.as_view()), + path('voices', views.PublicVoiceListAPIView.as_view()), + path('voices/upload', views.PublicVoiceUploadAPIView.as_view()), + path('voices/', views.PublicVoiceDetailAPIView.as_view()), + ] + + openai_urlpatterns + + elevenlabs_urlpatterns +) + + for view in ( views.TextView, views.ImageView, views.AudioView, views.VideoView, views.CodeView, - views.VoiceView + views.VoiceView, ): urlpatterns.extend( [