@@ -34,3 +34,9 @@ class UserAlreadyExists(Exception): class DomainNotFound(Exception): def __str__(self): return _('Domain not found') + + +class EmailSendFailed(Exception): + def __str__(self): + return _('Failed to send the email. Verify that the email exists and is available') + @@ -14,7 +14,7 @@ from django.utils.safestring import SafeString from django.utils.translation import gettext_lazy as _ from authentication.exceptions.email_exceptions import LetterNotFound, LetterUnknownException -from authentication.exceptions.user import DomainNotFound +from authentication.exceptions.user import DomainNotFound, EmailSendFailed from authentication.models import BusinessAccount, BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_token_service import EmailTokenService @@ -50,6 +50,8 @@ class EmailService: raise DomainNotFound except smtplib.SMTPRecipientsRefused: raise DomainNotFound + except smtplib.SMTPDataError: + raise EmailSendFailed except Exception as exc: logger.exception(exc) raise Exception(_('Error occured when proceed email sending')) @@ -46,6 +46,7 @@ from authentication.exceptions.email_token import EmailTokenNotFound from authentication.exceptions.user import ( DomainNotFound, EmailNotConfirmed, + EmailSendFailed, PasswordsDoNotMatch, UserAlreadyExists, WrongEmail, @@ -236,6 +237,8 @@ class UserAPIView(APIView): return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) except DomainNotFound: return Response({'detail': _('Email not found')}, status=status.HTTP_400_BAD_REQUEST) + except EmailSendFailed as exc: + return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: logger.exception(exc) return Response( @@ -79,7 +79,6 @@ TOOLS = [ 'tools.apps.PublicAPIConfig', 'tools.apps.ChatsConfig', 'tools.apps.MediaConfig', - 'tools.apps.ShareConfig' ] @@ -275,10 +274,6 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, - 'delete_expired_shares': { - 'task': 'tools.share.tasks.delete_expired_shares', - 'schedule': crontab('0', '*/6', '*', '*', '*'), - }, 'execute_recurring_payments': { 'task': 'payments.tasks.execute_recurring_payments', 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), @@ -466,6 +461,7 @@ if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: 'InvalidParameterError', 'UnsupportedSize', 'OutputSensitiveImageContentError', + 'InputImageSensitiveContentError', ], ) @@ -479,7 +475,6 @@ if CACHEOPS_REDIS: 'ml_model.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.chats.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.media.*': {'ops': 'all', 'timeout': 60 * 60}, - 'tools.share.*': {'ops': 'all', 'timeout': 60 * 60}, 'payments.paymentplan': {'ops': 'all', 'timeout': 60 * 60}, 'payments.invoice': {'ops': 'all', 'timeout': 60 * 60 * 24 * 7}, 'messages.*': {'ops': 'all', 'timeout': 60 * 60}, @@ -496,14 +491,9 @@ UNLEASH_INSTANCE_ID = env.str('UNLEASH_INSTANCE_ID', '') UNLEASH_WEBHOOK_SECRET_KEY = env.str('UNLEASH_WEBHOOK_SECRET_KEY', 'defaultsecretkey') # RECURRING SETTINGS -RECURRING_RETRY_OFFSETS = env.list( - 'RECURRING_RETRY_OFFSETS', default=[1, 3, 5, 8, 12, 16, 21, 28], subcast=int -) +RECURRING_RETRY_OFFSETS = env.list('RECURRING_RETRY_OFFSETS', default=[1, 3, 5, 8, 12, 16, 21, 28], subcast=int) RECURRING_FULL_ACCESS_CUTOFF_DAY = env.int('RECURRING_FULL_ACCESS_CUTOFF_DAY', 4) RECURRING_FAILED_CHARGE_EMAIL_DAYS = env.list('RECURRING_FAILED_CHARGE_EMAIL_DAYS', default=[3], subcast=int) # SSE STREAMING FF__STREAMING_ENABLED = env.bool('FF__STREAMING_ENABLED', False) - -# MESSAGE SHARING -SHARE_LIFETIME = env.int('SHARE_LIFETIME', 1800) \ No newline at end of file @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-30 17:11+0300\n" +"POT-Creation-Date: 2026-08-10 11:02+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,7 +20,7 @@ msgstr "" "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " "(n%100>=11 && n%100<=14)? 2 : 3);\n" -#: authentication/exceptions/business_account.py:6 authentication/views.py:436 +#: authentication/exceptions/business_account.py:6 authentication/views.py:443 msgid "Business account not found" msgstr "Сотрудник не найден" @@ -116,7 +116,7 @@ msgstr "Email токен не найден" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:98 +#: authentication/exceptions/user.py:11 backend/urls.py:97 msgid "Wrong password" msgstr "Неверный пароль" @@ -140,6 +140,11 @@ msgstr "Пользователь уже существует" msgid "Domain not found" msgstr "Домен не найден" +#: authentication/exceptions/user.py:41 +msgid "Failed to send the email. Verify that the email exists and is available" +msgstr "" +"Не удалось отправить письмо. Убедитесь, что email существует и доступен" + #: authentication/models/business_account.py:18 payments/models/promocode.py:72 #: tools/public_api/models.py:34 tools/public_api/services/api_key.py:24 msgid "Owner" @@ -205,9 +210,9 @@ msgstr "Бизнес Группы" #: authentication/models/business_host.py:22 #: authentication/models/email_token.py:13 authentication/models/user.py:233 #: authentication/models/user.py:234 authentication/models/user_telegram.py:22 -#: authentication/models/user_vk.py:12 payments/admin.py:38 -#: payments/admin.py:167 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:43 +#: authentication/models/user_vk.py:12 payments/admin.py:41 +#: payments/admin.py:178 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:42 #: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -383,7 +388,7 @@ msgstr "Имя" msgid "Last name" msgstr "Фамилия" -#: authentication/models/user.py:133 payments/admin.py:131 +#: authentication/models/user.py:133 payments/admin.py:144 msgid "Email" msgstr "Email" @@ -511,25 +516,21 @@ msgstr "Неверный или истёкший refresh токен" msgid "User is already confirmed" msgstr "Аккаунт уже подтвержден" -#: authentication/routes/v2.py:36 authentication/views.py:489 +#: authentication/routes/v2.py:36 authentication/views.py:499 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" #: authentication/security.py:36 -#, fuzzy -#| msgid "Hidden" msgid "Forbidden" -msgstr "Скрытый" +msgstr "Запрещено" #: authentication/security.py:49 msgid "Access token is expired" msgstr "Срок действия токена доступа истек" #: authentication/security.py:51 -#, fuzzy -#| msgid "Access token is expired" msgid "Access token invalid" -msgstr "Срок действия токена доступа истек" +msgstr "Токен доступа недействителен" #: authentication/security.py:79 msgid "User not found" @@ -561,7 +562,7 @@ msgstr "Бизнес-аккаунт для данного юзера не най msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/email_service.py:52 +#: authentication/services/email_service.py:57 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" @@ -585,40 +586,40 @@ msgstr "Пароли не совпадают" msgid "Current password is wrong" msgstr "Текущий пароль неверен" -#: authentication/views.py:133 authentication/views.py:242 -#: authentication/views.py:348 authentication/views.py:381 +#: authentication/views.py:134 authentication/views.py:245 +#: authentication/views.py:353 authentication/views.py:387 msgid "Server error occured" msgstr "Случилась серверная ошибка" -#: authentication/views.py:238 +#: authentication/views.py:239 msgid "Email not found" msgstr "Email не найден" -#: authentication/views.py:318 authentication/views.py:370 +#: authentication/views.py:322 authentication/views.py:375 msgid "Email sending error: email not found" msgstr "Ошибка отправки письма: email не найден" -#: authentication/views.py:340 +#: authentication/views.py:345 msgid "Business account has been deleted" msgstr "Сотрудник успешно удален" -#: authentication/views.py:368 +#: authentication/views.py:372 msgid "Business account has been reinvited" msgstr "Повторное приглашение сотруднику успешно отправлено" -#: authentication/views.py:440 +#: authentication/views.py:449 msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" -#: backend/urls.py:80 +#: backend/urls.py:79 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:87 +#: backend/urls.py:86 msgid "Data size limit exceeded. Please reduce the size" msgstr "Превышен лимит размера данных. Уменьшите размер" -#: backend/urls.py:93 +#: backend/urls.py:92 msgid "Token is invalid" msgstr "" @@ -633,12 +634,10 @@ msgid "A %(model)s with fields %(fields)s already exists" msgstr "Уже существует %(model)s с полями %(fields)s" #: lib/parsers.py:18 -#, fuzzy -#| msgid "Invalid info payload" msgid "Invalid JSON payload" -msgstr "Некорректные данные в поле info" +msgstr "Некорректный JSON" -#: messages/serializers.py:44 ml_model/exceptions.py:87 +#: messages/serializers.py:44 ml_model/exceptions.py:89 #: tools/chats/schemas.py:23 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" @@ -678,18 +677,18 @@ msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " "%(rw)sx%(rh)s" -#: ml_model/exceptions.py:48 +#: ml_model/exceptions.py:49 #, python-format msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." msgstr "" "Размер изображения превышает максимально допустимое количество пикселей " "(%(max_pixels)d)." -#: ml_model/exceptions.py:54 +#: ml_model/exceptions.py:56 msgid "The model is not responding" msgstr "Модель не отвечает" -#: ml_model/exceptions.py:63 +#: ml_model/exceptions.py:65 #, python-format msgid "" "The attached file format is not supported. Available formats: " @@ -698,93 +697,108 @@ msgstr "" "Формат вложенного файла не поддерживается. Доступные форматы: " "%(available_extensions)s." -#: ml_model/exceptions.py:69 +#: ml_model/exceptions.py:71 msgid "The file may be corrupted. Please try another one." msgstr "Возможно, файл повреждён. Попробуйте загрузить другой файл." -#: ml_model/exceptions.py:74 +#: ml_model/exceptions.py:76 msgid "File Uploading Not supported" msgstr "Загрузка файлов не поддерживается" -#: ml_model/exceptions.py:79 +#: ml_model/exceptions.py:81 msgid "Unable to recognize the file" msgstr "Не удаётся распознать файл" -#: ml_model/exceptions.py:92 +#: ml_model/exceptions.py:94 msgid "The length of the context has been exceeded." msgstr "Длина контекста превышена." -#: ml_model/exceptions.py:97 +#: ml_model/exceptions.py:99 msgid "Jinja template not found" msgstr "Jinja-шаблон не найден" -#: ml_model/exceptions.py:102 +#: ml_model/exceptions.py:104 msgid "There was an unknown error while rendering a template" msgstr "При рендеринге шаблона произошла неизвестная ошибка" -#: ml_model/exceptions.py:107 +#: ml_model/exceptions.py:109 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/exceptions.py:115 +#: ml_model/exceptions.py:117 #, python-format msgid "The %(file_type)s is not attached" msgstr "Файл (%(file_type)s) не прикреплен" -#: ml_model/exceptions.py:120 +#: ml_model/exceptions.py:122 msgid "No image content found in response. Try a different request" msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" -#: ml_model/exceptions.py:125 +#: ml_model/exceptions.py:127 msgid "" "The model could not analyze your request. Please rephrase it and try again" msgstr "" "Модель не смогла проанализировать ваш запрос. Перефразируйте его и " "попробуйте снова" -#: ml_model/exceptions.py:130 +#: ml_model/exceptions.py:132 msgid "Image analysis error. Please try another image." msgstr "Ошибка анализа изображения. Попробуйте другую картинку." -#: ml_model/exceptions.py:135 +#: ml_model/exceptions.py:137 msgid "Use style type AUTO or GENERAL when a style preset is selected" msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" -#: ml_model/exceptions.py:140 +#: ml_model/exceptions.py:142 msgid "Prediction interrupted. Please retry again" msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" -#: ml_model/exceptions.py:155 +#: ml_model/exceptions.py:159 #, python-format msgid "Prompt is too long. Maximum length is %(max_length)s characters." msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." -#: ml_model/exceptions.py:161 +#: ml_model/exceptions.py:162 +msgid "Prompt is too long" +msgstr "Промпт слишком длинный" + +#: ml_model/exceptions.py:167 +msgid "" +"Service is currently unavailable due to high demand. Please try again later" +msgstr "" +"Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте позже" + +#: ml_model/exceptions.py:172 msgid "Service is temporarily unavailable. Please try again later" msgstr "Сервис временно недоступен. Пожалуйста, попробуйте позже" -#: ml_model/exceptions.py:169 +#: ml_model/exceptions.py:192 #, python-format msgid "%(feature)s is available only in paid plan." msgstr "%(feature)s доступно только в платном тарифном плане." -#: ml_model/exceptions.py:177 +#: ml_model/exceptions.py:199 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." -#: ml_model/exceptions.py:182 +#: ml_model/exceptions.py:204 msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." -#: ml_model/exceptions.py:187 +#: ml_model/exceptions.py:209 msgid "The generated image may contain private or prohibited content" msgstr "Готовое изображение может содержать приватный или запрещённый контент" -#: ml_model/exceptions.py:196 +#: ml_model/exceptions.py:214 +msgid "The input image may contain private or prohibited content" +msgstr "" +"Загруженное изображение может содержать приватный или запрещённый контент" + +#: ml_model/exceptions.py:223 msgid "not specified" msgstr "не указана" -#: ml_model/exceptions.py:198 +#: ml_model/exceptions.py:224 #, python-format msgid "" "Version \"%(version)s\" is not available. Available versions: " @@ -850,7 +864,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:166 ml_model/models.py:413 payments/admin.py:173 +#: ml_model/models.py:166 ml_model/models.py:413 payments/admin.py:184 msgid "Model" msgstr "Модель" @@ -1083,8 +1097,7 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/FileService.py:110 tools/media/apis.py:295 -#: tools/public_api/views/ml_service.py:56 +#: ml_model/services/FileService.py:110 #: tools/public_api/views/providers/openai_compatible.py:208 msgid "Voice not found." msgstr "Голос не найден." @@ -1125,7 +1138,7 @@ msgstr "3К разрешение не поддерживается для это msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:144 +#: ml_model/tasks.py:184 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1137,21 +1150,21 @@ msgstr "Запрос не должен быть пустым" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:36 payments/admin.py:85 payments/admin.py:165 +#: payments/admin.py:39 payments/admin.py:98 payments/admin.py:175 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:41 payments/admin.py:170 +#: payments/admin.py:44 payments/admin.py:181 msgid "Missing" msgstr "Отсутствующий" -#: payments/admin.py:144 payments/models/user_payment_method.py:23 +#: payments/admin.py:157 payments/models/user_payment_method.py:23 msgid "Gateway" msgstr "Шлюз" -#: payments/admin.py:148 payments/models/user_payment_method.py:24 +#: payments/admin.py:161 payments/models/user_payment_method.py:24 msgid "Payment method UID" msgstr "UID платёжного метода" @@ -1176,31 +1189,33 @@ msgstr "" msgid "The payer does not exist" msgstr "Плательщик не существует" +#: payments/exceptions/subscription_recovery.py:6 +msgid "Active payment method not found" +msgstr "Активный способ оплаты не найден" + +#: payments/exceptions/subscription_recovery.py:11 +msgid "Subscription recovery is already in progress" +msgstr "Восстановление подписки уже выполняется" + #: payments/models/attempt.py:12 payments/models/user_payment_method.py:48 msgid "Payment Method" msgstr "Платежный метод" #: payments/models/attempt.py:15 -#, fuzzy -#| msgid "Cancelled" msgid "Cancel Reason" -msgstr "Отменено" +msgstr "Причина отмены" #: payments/models/attempt.py:16 msgid "In Cycle" msgstr "" #: payments/models/attempt.py:22 -#, fuzzy -#| msgid "Payment Datetime" msgid "Payment Attempt" -msgstr "Дата и время платежа" +msgstr "Попытка платежа" #: payments/models/attempt.py:23 -#, fuzzy -#| msgid "Payment Methods" msgid "Payment Attempts" -msgstr "Платежные методы" +msgstr "Попытки платежа" #: payments/models/invoice.py:23 msgid "Generative Model" @@ -1230,52 +1245,60 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:13 +#: payments/models/payment_plan.py:12 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:17 +#: payments/models/payment_plan.py:16 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:20 +#: payments/models/payment_plan.py:19 msgid "Is corporate" msgstr "Корпоративный" -#: payments/models/payment_plan.py:21 +#: payments/models/payment_plan.py:20 msgid "Individual" msgstr "Индивидуальный" -#: payments/models/payment_plan.py:22 +#: payments/models/payment_plan.py:21 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:34 payments/models/payment_plan.py:49 +#: payments/models/payment_plan.py:33 payments/models/payment_plan.py:48 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:35 +#: payments/models/payment_plan.py:34 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:51 +#: payments/models/payment_plan.py:50 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:52 +#: payments/models/payment_plan.py:51 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:54 +#: payments/models/payment_plan.py:56 +msgid "Last recovery payment id" +msgstr "ID последнего платежа восстановления" + +#: payments/models/payment_plan.py:62 +msgid "Recovery locked at" +msgstr "Время блокировки восстановления" + +#: payments/models/payment_plan.py:65 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:60 +#: payments/models/payment_plan.py:71 msgid "Referral balance" msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:87 payments/models/payment_plan.py:88 +#: payments/models/payment_plan.py:98 payments/models/payment_plan.py:99 msgid "User Balance" msgstr "Баланс пользователя" @@ -1372,20 +1395,16 @@ msgid "SBP" msgstr "СБП" #: payments/models/user_payment_method.py:20 -#, fuzzy -#| msgid "User not found" msgid "User Plan Info" -msgstr "Пользователь не найден" +msgstr "Информация о плане пользователя" #: payments/models/user_payment_method.py:25 tools/media/models.py:78 msgid "Meta" msgstr "Метаданные" #: payments/models/user_payment_method.py:26 -#, fuzzy -#| msgid "Is active" msgid "Active" -msgstr "Является активной" +msgstr "Активен" #: payments/models/user_payment_method.py:27 msgid "Primary" @@ -1395,31 +1414,23 @@ msgstr "" msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:113 +#: payments/routes/v1.py:110 msgid "You do not have an active subscription to cancel" msgstr "У вас нет активной подписки для отмены" -#: payments/routes/v1.py:114 +#: payments/routes/v1.py:111 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/exceptions/subscription_recovery.py -msgid "Active payment method not found" -msgstr "Активный способ оплаты не найден" - -#: payments/exceptions/subscription_recovery.py -msgid "Subscription recovery is already in progress" -msgstr "Восстановление подписки уже выполняется" - -#: payments/routes/v1.py +#: payments/routes/v1.py:129 msgid "Payment could not be completed, please try again later" msgstr "Не удалось провести платёж, пожалуйста, попробуйте позже" -#: payments/routes/v1.py:144 +#: payments/routes/v1.py:199 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:168 +#: payments/routes/v1.py:203 msgid "Refills" msgstr "Пополнения" @@ -1473,7 +1484,7 @@ msgstr "Медиа" msgid "Share" msgstr "Шеринг" -#: tools/chats/apis.py:217 tools/media/apis.py:244 +#: tools/chats/apis.py:219 tools/media/apis.py:246 #: tools/public_api/views/base.py:108 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1482,7 +1493,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:273 +#: tools/chats/apis.py:275 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1496,10 +1507,8 @@ msgid "Chat" msgstr "Чат" #: tools/chats/routes/v1.py:39 tools/public_api/routes/v1.py:68 -#, fuzzy -#| msgid "User not found" msgid "Stream not found" -msgstr "Пользователь не найден" +msgstr "Стрим не найден" #: tools/chats/routes/v1.py:53 msgid "Chat not found" @@ -1513,7 +1522,7 @@ msgstr "Стриминг не поддерживается для этой мо msgid "Stream already in progress" msgstr "" -#: tools/chats/schemas.py:34 tools/public_api/views/ml_service.py:88 +#: tools/chats/schemas.py:34 tools/public_api/views/ml_service.py:60 msgid "Invalid info payload" msgstr "Некорректные данные в поле info" @@ -1521,7 +1530,7 @@ msgstr "Некорректные данные в поле info" msgid "Stream timeout" msgstr "" -#: tools/media/apis.py:236 +#: tools/media/apis.py:238 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." @@ -1608,16 +1617,12 @@ msgid "API Keys" msgstr "API Ключи" #: tools/public_api/routes/providers/openai.py:19 -#, fuzzy -#| msgid "Missing required parameter: model_id" msgid "Missing required parameter: input" -msgstr "Отсутствует обязательный параметр: 'model_id'" +msgstr "Отсутствует обязательный параметр: 'input'" #: tools/public_api/routes/providers/openai.py:24 -#, fuzzy -#| msgid "Invalid info payload" msgid "Invalid input payload" -msgstr "Некорректные данные в поле info" +msgstr "Некорректные данные в поле input" #: tools/public_api/routes/providers/openai.py:85 #: tools/public_api/views/providers/elevenlabs_compatible.py:147 @@ -1627,10 +1632,8 @@ msgid "Model not found" msgstr "Модель не найдена" #: tools/public_api/routes/providers/openai.py:118 -#, fuzzy -#| msgid "File Uploading Not supported" msgid "Only streaming is supported" -msgstr "Загрузка файлов не поддерживается" +msgstr "Поддерживается только стриминг" #: tools/public_api/routes/providers/openai.py:120 #: tools/public_api/views/providers/openai_compatible.py:61 @@ -1650,28 +1653,20 @@ msgid "No API Key in Authorization header" msgstr "" #: tools/public_api/routes/v1.py:45 -#, fuzzy -#| msgid "API Key not found" msgid "API key not found" msgstr "API-ключ не найден" #: tools/public_api/routes/v1.py:48 -#, fuzzy -#| msgid "Access token is expired" msgid "API key expired" -msgstr "Срок действия токена доступа истек" +msgstr "Срок действия API-ключа истёк" #: tools/public_api/routes/v1.py:50 -#, fuzzy -#| msgid "Key limit exceeded" msgid "API key limit exceeded" -msgstr "Превышен лимит по ключу" +msgstr "Превышен лимит API-ключа" #: tools/public_api/routes/v1.py:64 tools/public_api/routes/v1.py:98 -#, fuzzy -#| msgid "Host user is not registered for this account" msgid "API key is not available for this account type" -msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" +msgstr "API-ключ недоступен для этого типа аккаунта" #: tools/public_api/routes/v1.py:106 tools/public_api/views/base.py:69 msgid "Model is blocked by outdating or temporary block, please retry later" @@ -1917,6 +1912,9 @@ msgstr "Шеринги" #~ msgid "Token prefix is missing" #~ msgstr "Отсутствует префикс токена" +#~ msgid "Message" +#~ msgstr "Сообщение" + #~ msgid "Detail" #~ msgstr "Подробности" @@ -1,4 +1,5 @@ import json +import re from enum import StrEnum import logging import time @@ -11,9 +12,11 @@ from messages.services.message_service import MessageService from ml_model.exceptions import ( FileExtensionNotSupported, GenerationException, + InputImageSensitiveContentError, + OutputSensitiveImageContentError, RealPersonDetectedError, RequestBlocked, - OutputSensitiveImageContentError, + PromptLengthExceeded, ) from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -113,6 +116,26 @@ class BytedanceModelArkAdapter: return 'video_url' return 'image_url' + @classmethod + def _handle_error_response(cls, resp: httpx.Response) -> None: + if resp.status_code == 413: + raise PromptLengthExceeded + + if 'Input length' in resp.text and 'exceeds the maximum length' in resp.text: + match = re.search(r"Input length (\d+) exceeds the maximum length (\d+)", resp.text) + if match: + max_length = int(match.group(2)) + raise PromptLengthExceeded(max_length=max_length) + raise PromptLengthExceeded + + logger.error( + 'Bytedance request failed status=%s route=%s body=%s', + resp.status_code, + cls.CONTENT_TYPE_TO_ENDPOINT.get(BytedanceContentType.CHAT), + resp.text, + ) + raise GenerationException + @classmethod def _raise_by_error_payload(cls, data: dict[str, Any], choices: list[dict[str, Any]]) -> None: choice_reasons = {str(choice.get('finish_reason', '')).lower() for choice in choices} @@ -207,12 +230,7 @@ class BytedanceModelArkAdapter: ) as client: resp = client.post(cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], json=payload) if resp.status_code >= 400: - logger.error( - 'Bytedance request failed status=%s route=%s body=%s', - resp.status_code, - cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], - resp.text, - ) + cls._handle_error_response(resp) try: data: BytedanceChatResponse = resp.json() except Exception as exc: @@ -278,13 +296,7 @@ class BytedanceModelArkAdapter: 'POST', cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], json=payload ) as resp: if resp.status_code >= 400: - logger.error( - 'Bytedance request failed status=%s route=%s body=%s', - resp.status_code, - cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], - resp.text, - ) - raise GenerationException + cls._handle_error_response(resp) usage: BytedanceUsage = {} for line in resp.iter_lines(): if not line: @@ -363,6 +375,8 @@ class BytedanceModelArkAdapter: if error_code := data.get('error', {}).get('code', ''): if error_code == 'OutputImageSensitiveContentDetected': raise OutputSensitiveImageContentError + if error_code == 'InputImageSensitiveContentDetected': + raise InputImageSensitiveContentError if image_data := data.get('data'): urls = [ @@ -409,6 +423,8 @@ class BytedanceModelArkAdapter: return data if error_code := data.get('error', {}).get('code', None): match error_code: + case 'InputImageSensitiveContentDetected': + raise InputImageSensitiveContentError case 'InputImageSensitiveContentDetected.PrivacyInformation': raise RealPersonDetectedError case _: @@ -70,14 +70,15 @@ class OpenrouterAdapter: try: data_obj = json.loads(data) - content_chunk = data_obj['choices'][0]['delta'].get('content') or '' - reasoning_chunk = data_obj['choices'][0]['delta'].get('reasoning') or '' - if content_chunk: - content += content_chunk - yield RawSSEChunk(event='token', data={'content': content_chunk}) - if reasoning_chunk: - reasoning += reasoning_chunk - yield RawSSEChunk(event='think', data={'content': reasoning_chunk}) + if data_obj.get('choices'): + content_chunk = data_obj['choices'][0].get('delta', {}).get('content') or '' + reasoning_chunk = data_obj['choices'][0].get('delta', {}).get('reasoning') or '' + if content_chunk: + content += content_chunk + yield RawSSEChunk(event='token', data={'content': content_chunk}) + if reasoning_chunk: + reasoning += reasoning_chunk + yield RawSSEChunk(event='think', data={'content': reasoning_chunk}) if data_obj.get('usage'): input_tokens = data_obj['usage']['prompt_tokens'] output_tokens = data_obj['usage']['completion_tokens'] @@ -1,21 +1,33 @@ import re import subprocess import zipfile +from functools import wraps from uuid import UUID import docx2txt +import filetype import fitz import openpyxl from io import BytesIO +from PIL import Image +from django.core.files.images import get_image_dimensions 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 InvalidParameterError, UnrecognizedFileError +from ml_model.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + ImageTooLargeError, + InvalidParameterError, + UnrecognizedFileError, +) from tools.media.models import Voice, Preset, PresetKind +# FIXME: Переработать сервисы по работе с файлами. Возможно, прибегнуть к использованию миксинов + class FileProcessingService: @classmethod @@ -109,3 +121,55 @@ class FileProcessingService: except (Voice.DoesNotExist, Preset.DoesNotExist) as exc: raise InvalidParameterError(_('Voice not found.')) from exc return voice.file + + +class ImageFileProcessingService: + ALLOWED_EXTENSIONS = ['PNG', 'JPG', 'JPEG', 'WEBP'] + + def __init__(self, image: FieldFile) -> None: + self.image = image + + @staticmethod + def __reset_image(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + finally: + self.image.seek(0) + + return wrapper + + @__reset_image + def get_bytes(self, size: int | None = None) -> bytes: + return self.image.read(size) + + def get_kind(self, file_bytes: bytes): + kind = filetype.guess(file_bytes) + if not kind: + raise CorruptedFileError + if kind.extension.upper() not in self.ALLOWED_EXTENSIONS: + raise FileExtensionNotSupported(self.ALLOWED_EXTENSIONS) + return kind + + @__reset_image + def get_dimensions(self, max_pixels: int) -> tuple[int, int]: + try: + w, h = get_image_dimensions(self.image) + if not (w and h): + raise CorruptedFileError + if w * h > max_pixels: + raise ImageTooLargeError(max_pixels) + return w, h + except Image.DecompressionBombError: + raise ImageTooLargeError(max_pixels) + + def get_normalized_image(self, file_bytes: bytes) -> BytesIO: + normalized_image = BytesIO(file_bytes) + with Image.open(normalized_image) as source_image: + img = source_image.convert('RGBA') + normalized_image = BytesIO() + img.save(normalized_image, format='PNG') + img.close() + normalized_image.seek(0) + return normalized_image @@ -60,7 +60,7 @@ from ml_model.services.pulid import Pulid from ml_model.services.qwen import Qwen from ml_model.services.qwen_235B import Qwen_235B from ml_model.services.qwen_3_6 import Qwen_3_6 -from ml_model.services.qwen_3_7 import Qwen_3_7 +from ml_model.services.qwen_3_8 import Qwen_3_8 from ml_model.services.qwen_3_max_thinking import Qwen_3_Max_Thinking from ml_model.services.raifgpt import Raifgpt from ml_model.services.ray import Ray @@ -47,9 +47,9 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): 'input': Decimal('0.0025'), # $5 / 1M tokens 'output': Decimal('0.015'), # $30 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), @@ -58,31 +58,31 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): 'input': Decimal('0.0025'), # $5 / 1M tokens 'output': Decimal('0.015'), # $30 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), }, 'gpt-5.6-luna': { - 'input': Decimal('0.0005'), # $1 / 1M tokens - 'output': Decimal('0.003'), # $6 / 1M tokens + 'input': Decimal('0.0001'), # $0.2 / 1M tokens + 'output': Decimal('0.0006'), # $1.2 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), }, 'gpt-5.6-terra': { - 'input': Decimal('0.00125'), # $2.5 / 1M tokens - 'output': Decimal('0.0075'), # $15 / 1M tokens + 'input': Decimal('0.001'), # $2 / 1M tokens + 'output': Decimal('0.006'), # $12 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), @@ -490,7 +490,9 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): Decimal(serper_sources * 250) / Decimal(2.7) * self.TOKENS_COST[model_name]['input'] ) if info.get('code_interpreter'): - json_data['tools'].append({'type': 'code_interpreter', 'container': {'type': 'auto'}}) + json_data['tools'].append( + {'type': 'code_interpreter', 'container': {'type': 'auto', 'memory_limit': '1g'}} + ) messages[-1]['content'] += ' the python tool ' predicted_input_price += self.TOKENS_COST[model_name]['code_interpreter'] if ctx['image']: @@ -67,18 +67,18 @@ class Chatgpt_4(SimpleService): 'input': Decimal('0.0003'), 'output': Decimal('0.0003'), 'web_search': { - 'low': Decimal('12.5'), # 1 call - 'medium': Decimal('13.75'), # 1 call - 'high': Decimal('15'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-4o': { 'input': Decimal('0.005'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('15'), # 1 call - 'medium': Decimal('17.5'), # 1 call - 'high': Decimal('25'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-oss-120b': {'input': Decimal('0.0002'), 'output': Decimal('0.0002')}, @@ -576,7 +576,7 @@ class Chatgpt_4(SimpleService): 'input': messages, 'tools': [ { - 'type': 'web_search_preview', + 'type': 'web_search', 'search_context_size': search_context_size, 'user_location': {'type': 'approximate', 'country': 'RU'}, } @@ -26,9 +26,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000625'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), }, @@ -36,9 +36,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000125'), 'output': Decimal('0.001'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), }, @@ -51,9 +51,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000625'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call }, @@ -61,9 +61,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.0075'), 'output': Decimal('0.06'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-5.1-codex-max': { @@ -82,9 +82,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000875'), 'output': Decimal('0.007'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call }, @@ -238,7 +238,9 @@ class Chatgpt_5(Chatgpt_4): 'gpt-5', 'gpt-5.1', ): - json_data['tools'].append({'type': 'code_interpreter', 'container': {'type': 'auto'}}) + json_data['tools'].append( + {'type': 'code_interpreter', 'container': {'type': 'auto', 'memory_limit': '1g'}} + ) messages[-1]['content'] += 'the python tool' input_tokens, output_tokens, response = self.call_openai_api( proxy=proxy, endpoint='responses', json_data=json_data @@ -21,9 +21,9 @@ class Chatgpt_5_4(Chatgpt): 'input': Decimal('0.00125'), 'output': Decimal('0.0075'), 'web_search': { - 'low': Decimal('5'), - 'medium': Decimal('5'), - 'high': Decimal('5'), + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), 'generated_image': Decimal('10.2'), @@ -32,9 +32,9 @@ class Chatgpt_5_4(Chatgpt): 'input': Decimal('0.0075'), 'output': Decimal('0.045'), 'web_search': { - 'low': Decimal('5'), - 'medium': Decimal('5'), - 'high': Decimal('5'), + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'generated_image': Decimal('10.2'), }, @@ -228,7 +228,11 @@ class Claude(SerperMixin, StreamSimpleService): return memory def _build_callback_data(self, input_message: Message) -> dict[str, Any]: - return {'provider': {'order': ['anthropic']}, **input_message.info, 'tools': []} + return { + **input_message.info, + 'provider': {'order': ['anthropic'], 'allow_fallbacks': False}, + 'tools': [], + } def _prepare_messages( self, input_message: Message, version_slug: str, callback_data: dict @@ -26,12 +26,12 @@ class Deepseek(SimpleService): # 'output': Decimal('0'), # }, 'deepseek/deepseek-v4-pro': { - 'input': Decimal('1050') / 1_000_000, - 'output': Decimal('2200') / 1_000_000, + 'input': Decimal('261') / 1_000_000, # $0.87 / 1M tokens + 'output': Decimal('522') / 1_000_000, # $1.74 / 1M tokens }, - 'deepseek/deepseek-v4-flash': { - 'input': Decimal('100') / 1_000_000, - 'output': Decimal('175') / 1_000_000, + 'deepseek/deepseek-v4-flash-0731': { + 'input': Decimal('24') / 1_000_000, # $0.08 / 1M tokens + 'output': Decimal('75.6') / 1_000_000, # $0.252 / 1M tokens }, } @@ -62,6 +62,7 @@ class Deepseek(SimpleService): callback_data = { **info, + 'provider': {'order': ['digitalocean'], 'allow_fallbacks': False}, } messages = [ {'role': 'system', 'content': system_prompt}, @@ -79,7 +79,7 @@ class Flux_2(SimpleService): height = input_message.info.pop('height', 1024) output_mp = math.ceil((width * height) / 1_000_000) callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'aspect_ratio': 'custom', 'width': width, 'height': height, @@ -99,8 +99,8 @@ class Gemini(SimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'google/{version_slug}' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -80,8 +80,8 @@ class Gemini_3_1(StreamSimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) model_slug = f'google/{version_slug}:online' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages, embedding_tokens = self._prepare_messages(input_message) @@ -104,8 +104,8 @@ class Gemini_3_1(StreamSimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) model_slug = f'google/{version_slug}:online' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages, embedding_tokens = self._prepare_messages(input_message) input_tokens = output_tokens = 0 @@ -44,7 +44,7 @@ class Geminiimage(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: if input_message.content: callback_data = dict( - {'prompt': self.translate_prompt(input_message.content), **input_message.info} + {'prompt': input_message.content, **input_message.info} ) start_time = time.time() images = replicate_run('google/gemini-2.5-flash-image', callback_data) @@ -41,8 +41,8 @@ class Gemma(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: callback_data = { - 'provider': {'order': ['DeepInfra']}, **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -32,6 +32,7 @@ class Grok(SerperMixin, StreamSimpleService): TOKENS_COST = { 'grok-4.3': {'input': Decimal('875'), 'output': Decimal('1750'), 'coefficient': Decimal('5')}, 'grok-4.5': {'input': Decimal('1000'), 'output': Decimal('3000'), 'coefficient': Decimal('5')}, + 'grok-4.6': {'input': Decimal('600'), 'output': Decimal('1800'), 'coefficient': Decimal('5')}, } TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} @@ -52,8 +52,8 @@ class Grok_4_1_Fast(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: callback_data = { - 'provider': {'order': ['xAI']}, **input_message.info, + 'provider': {'order': ['xai'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -45,7 +45,7 @@ class Grok_Image(SimpleService): raise InsufficientBalance(balance, self.TOKENS_COST) callback_data = dict( { - 'prompt': f'{self.translate_prompt(input_message.content)}\n{self.OPTIMIZATION_PROMPT}', + 'prompt': f'{input_message.content}\n{self.OPTIMIZATION_PROMPT}', **input_message.info, } ) @@ -10,7 +10,7 @@ import requests from django.core.files import File from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import GenerationException, PromptLengthExceeded, RequestBlocked from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -42,12 +42,14 @@ class Grok_Imagine_Video(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: + if len(input_message.content or '') > 2000: + raise PromptLengthExceeded(max_length=2000) duration = input_message.info.get('duration', 5) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.TOKENS_COST * duration ): raise InsufficientBalance(balance, cost) - callback_data = dict({'prompt': self.translate_prompt(input_message.content), **input_message.info}) + callback_data = dict({'prompt': input_message.content, **input_message.info}) if image := input_message.file: callback_data.update({'image': image.url}) start_time = time.time() @@ -63,7 +63,10 @@ class Llama(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'meta-llama/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) image = input_message.file @@ -17,9 +17,9 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Ltx(SimpleService): TOKENS_COST = { - '1080p': Decimal('12'), - '2k': Decimal('24'), - '4k': Decimal('48'), + '1080p': Decimal('18'), # $0.06 / sec + '2k': Decimal('36'), # $0.12 / sec + '4k': Decimal('72'), # $0.24 / sec } @classmethod @@ -55,7 +55,10 @@ class Mistral(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: version = 'mistralai/mistral-small-3.1-24b-instruct' - callback_data = {'provider': {'order': ['Parasail']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['parasail'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) image = input_message.file @@ -65,7 +65,10 @@ class Perplexity(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'perplexity/{version_slug}' - callback_data = {'provider': {'order': ['Perplexity']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['perplexity'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) try: @@ -54,7 +54,7 @@ class Pixverse(SimpleService): raise InsufficientBalance(balance, cost) thinking_types = {'авто': 'auto', 'выкл.': 'disabled', 'вкл.': 'enabled'} callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'quality': quality, 'thinking_type': thinking_types[input_message.info.pop('thinking_type', 'авто').lower()], **input_message.info, @@ -48,7 +48,10 @@ class Qwen(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'qwen/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.insert( 0, @@ -49,7 +49,10 @@ class Qwen_235B(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'qwen/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.insert( 0, @@ -22,15 +22,16 @@ from tools.copywrite.models import Copywrite from tools.public_api.models import APIStore -class Qwen_3_7(StreamSimpleService): +class Qwen_3_8(StreamSimpleService): COEFFICIENT = Decimal('300.0') TOKENS_COST = { - 'qwen3.7-max': {'input': Decimal('750'), 'output': Decimal('2250')}, - 'qwen3.7-plus': {'input': Decimal('120'), 'output': Decimal('480')}, + 'qwen3.8-max': {'input': Decimal('600'), 'output': Decimal('1800')}, # $2 / $6 + 'qwen3.7-max': {'input': Decimal('442.5'), 'output': Decimal('1327.5')}, # $1.475 / $4.425 + 'qwen3.7-plus': {'input': Decimal('96'), 'output': Decimal('384')}, # $0.32 / $1.28 } - MAX_OUTPUT_TOKENS = 30_000 + MAX_OUTPUT_TOKENS = 131_072 // 2 TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} @@ -80,7 +81,7 @@ class Qwen_3_7(StreamSimpleService): version_slug, model_slug, callback_data, messages, embedding_tokens = self._prepare_data( input_message ) - result = openrouter_run(model_slug, messages, callback_data, 'Qwen 3.7') + result = openrouter_run(model_slug, messages, callback_data, 'Qwen 3.8') process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, @@ -40,7 +40,10 @@ class Qwen_3_Max_Thinking(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - callback_data = {'provider': {'order': ['alibaba']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['alibaba'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) result = openrouter_run('qwen/qwen3-max-thinking:online', messages, callback_data, 'Qwen') @@ -20,12 +20,12 @@ from ml_model.tasks import bytedance_model_ark_run class Reve(SimpleService): # Reve временно не работает на репликейте. Временно используем сидрим - TEMPORARY_PROVIDER_MODEL = 'seedream-5-0-260128' + TEMPORARY_PROVIDER_MODEL = 'seedream-5-0-lite-260128' PRICE = { - '2K': Decimal('25'), - '3K': Decimal('50'), - '4K': Decimal('100'), + '2K': Decimal('17.5'), # $0.035 / image (seedream-5-0-lite-260128) + '3K': Decimal('17.5'), # $0.035 / image + '4K': Decimal('17.5'), # $0.035 / image } # PRICE = { # 'create': Decimal('12.5'), @@ -76,7 +76,7 @@ class Reve(SimpleService): raise InvalidParameterError(f'Unsupported size: {size}') callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, **input_message.info, 'size': size, 'watermark': False, @@ -7,12 +7,12 @@ from typing import Any import requests from django.utils.translation import gettext as _ from django.core.files import File -from replicate.exceptions import ModelError from messages.models import Message from ml_model.adapters.bytedance_model_ark import BytedanceContentType from ml_model.exceptions import InvalidParameterError from ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.FileService import ImageFileProcessingService from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -22,18 +22,18 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Seedream(SimpleService): TOKEN_COST = { 'seedream-boosted': { - '2K': Decimal('25'), - '3K': Decimal('50'), - '4K': Decimal('100'), + '2K': Decimal('17.5'), # $0.035 / image + '3K': Decimal('17.5'), # $0.035 / image + '4K': Decimal('17.5'), # $0.035 / image }, 'seedream-4.5': { - '2K': Decimal('25'), - '4K': Decimal('100'), + '2K': Decimal('20'), # $0.04 / image + '4K': Decimal('20'), # $0.04 / image }, } VERSION_MAPPING = { - 'seedream-boosted': 'seedream-5-0-260128', + 'seedream-boosted': 'seedream-5-0-lite-260128', 'seedream-4.5': 'seedream-4-5-251128', } @@ -94,6 +94,11 @@ class Seedream(SimpleService): **input_message.info, } if image := input_message.file: + image_processor = ImageFileProcessingService(image) + file_bytes = image_processor.get_bytes(20) + image_processor.get_kind(file_bytes) + image_processor.get_dimensions(max_pixels=36000000) + image_processor.image.close() callback_data.update({'image': image.url}) images = bytedance_model_ark_run( @@ -3,4 +3,4 @@ from ml_model.services import Minimaxmusic class Suno(Minimaxmusic): - TOKENS_COST = Decimal('17.5') + TOKENS_COST = Decimal('15') # $0.03 * 100 * 5 @@ -57,7 +57,7 @@ class Wan(SimpleService): input_message.file.close() callback_data = dict( { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'image': image, 'resolution': resolution, 'duration': duration, @@ -151,13 +151,15 @@ class InvalidParameterError(Exception): class PromptLengthExceeded(Exception): - def __init__(self, max_length: int = 3000) -> None: + def __init__(self, max_length: int | None = None) -> None: self.max_length = max_length def __str__(self) -> str: - return _('Prompt is too long. Maximum length is %(max_length)s characters.') % { - 'max_length': self.max_length - } + if self.max_length: + return _('Prompt is too long. Maximum length is %(max_length)s characters.') % { + 'max_length': self.max_length + } + return _('Prompt is too long') class ServiceHighDemandError(Exception): @@ -207,6 +209,11 @@ class OutputSensitiveImageContentError(Exception): return _('The generated image may contain private or prohibited content') +class InputImageSensitiveContentError(Exception): + def __str__(self) -> str: + return _('The input image may contain private or prohibited content') + + class ModelVersionNotAvailable(Exception): def __init__(self, version: str | None, available_versions: Iterable[str]) -> None: self.version = version @@ -245,7 +245,7 @@ def openrouter_run(version: str, messages: list, callback_data: dict, model_name logger.error(f'Model {model_name} disabled') raise DeploymentDisabled else: - if re.match(r'^qwen/qwen3\.7-.*$', data['model']): + if re.match(r'^qwen/qwen3\.[78]-.*$', data['model']): input_tokens = data['usage']['cost'] output_tokens = 0 else: @@ -80,11 +80,14 @@ def _build_migration_source( return f"""# Generated by makemigration_payment_features on {datetime.now():%Y-%m-%d %H:%M} import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def {func_name}(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -97,6 +100,7 @@ def {func_name}(apps, schema_editor): try: model = NeuronModel.objects.get(slug='{model_slug}') except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', '{model_slug}') return category = model.category @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 15:33 import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_flux_3_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -18,6 +21,7 @@ def add_flux_3_payment_features(apps, schema_editor): try: model = NeuronModel.objects.get(slug='flux_3') except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'flux_3') return category = model.category @@ -52,7 +56,6 @@ def add_flux_3_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0031_remove_paymentmethod_attempts_and_more'), ] @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 15:34 import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_grok_image_ultra_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -18,6 +21,7 @@ def add_grok_image_ultra_payment_features(apps, schema_editor): try: model = NeuronModel.objects.get(slug='grok_image_ultra') except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'grok_image_ultra') return category = model.category @@ -52,7 +56,6 @@ def add_grok_image_ultra_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0032_add_flux_3_payment_features'), ] @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 17:27 +import logging import math from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_flux_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -15,6 +18,7 @@ def add_flux_payment_features(apps, schema_editor): try: model = NeuronModel.objects.get(slug='flux') except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'flux') return price = Decimal('9.5') @@ -53,7 +57,6 @@ def add_flux_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0033_add_grok_image_ultra_payment_features'), ] @@ -121,7 +121,7 @@ class PaymentPlanUserInfoAdmin(admin.ModelAdmin): @admin.register(PaymentPlanFeature) class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_display = ('plan', 'model', 'move_up_down_links') - list_filter = ('plan', 'model__category') + list_filter = ('plan__tokens_per_plan', 'model__category') class PaymentAttemptInline(admin.TabularInline): @@ -28,12 +28,13 @@ from ml_model.exceptions import ( FileUploadUnsupported, ImageAnalysisError, ImageTooLargeError, + InputImageSensitiveContentError, InvalidParameterError, ModelVersionNotAvailable, + OutputSensitiveImageContentError, PaidPlanRequiredError, PromptLengthExceeded, RequestBlocked, - OutputSensitiveImageContentError, TemplateNotFound, TemplateUnknownException, UnrecognizedFileError, @@ -196,6 +197,7 @@ class MessagesAPIView(APIView): UnrecognizedFileError, InvalidParameterError, ModelVersionNotAvailable, + InputImageSensitiveContentError, OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: @@ -23,14 +23,15 @@ from ml_model.exceptions import ( ImageAnalysisError, ImageContentNotFound, ImageTooLargeError, + InputImageSensitiveContentError, InvalidParameterError, InvalidStyleCombinationError, ModelCouldNotInterpretPrompt, ModelVersionNotAvailable, + OutputSensitiveImageContentError, PromptLengthExceeded, RealPersonDetectedError, RequestBlocked, - OutputSensitiveImageContentError, ServiceHighDemandError, UnrecognizedFileError, UnsupportedSize, @@ -39,7 +40,7 @@ from ml_model.models import NeuronModel from ml_model.validators import ModelInputValidator from payments.exceptions.insufficient_balance import InsufficientBalance -from .models import Audio, Image, Video, VoiceClone, Voice, Preset +from .models import Audio, Image, Video, VoiceClone logger = logging.getLogger(__name__) @@ -219,6 +220,7 @@ class MediaAPIView(APIView): UnrecognizedFileError, FaceNotFoundError, RealPersonDetectedError, + InputImageSensitiveContentError, OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: @@ -266,40 +268,6 @@ class ModelVideosAPIView(MediaAPIView): class ModelAudiosAPIVIew(MediaAPIView): manager = Audio - @extend_schema( - parameters=[ - OpenApiParameter('model', str, 'path', required=True), - ], - request=MessageSerializer, - responses={ - 201: MessageSerializer(many=True), - }, - ) - def post(self, request: Request, model: str, *args, **kwargs) -> Response: - data = get_request_data(request) - if not (request.FILES.get('file') or data.get('file')): - voice_id = data.pop('voice_id', None) - preset_id = data.pop('preset_id', None) - - try: - if voice_id: - voice = Voice.objects.get(pk=voice_id, user=request.user) - transcription = voice.transcription - elif preset_id: - voice = Preset.objects.get(uid=preset_id) - transcription = voice.metadata.get('transcription', '') - else: - return super().post(request, model, *args, **kwargs) - except (Voice.DoesNotExist, Preset.DoesNotExist): - return Response( - {'detail': _('Voice not found.')}, - status=HTTP_400_BAD_REQUEST, - ) - - info = data['info'] - data.update({'file': voice.file, 'info': {'transcription': transcription, **info}}) - return super().post(request, model, data=data, *args, **kwargs) - class ModelVoiceCloneAPIView(MediaAPIView): manager = VoiceClone @@ -1,6 +1,7 @@ import json import logging +from django.http import QueryDict from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import status @@ -13,7 +14,6 @@ from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.selectors.param_selector import ParamSelector from ml_model.serializers import ModelParameterSerializer -from tools.media.models import Preset, Voice from tools.public_api.models import APIStore from tools.public_api.selectors.api_key import APIKeySelector from tools.public_api.views.base import BaseGenerationView @@ -35,34 +35,6 @@ class AudioView(BaseGenerationView): output_content_type = ContentTypes.AUDIO description = 'Get Audio 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', '')) - try: - if not voice_id: - return super().post(request, model_slug, *args, **kwargs) - 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']} - } - ) - return super().post(request, model_slug, *args, **kwargs) - class VideoView(BaseGenerationView): output_content_type = ContentTypes.VIDEO @@ -83,16 +55,19 @@ class VoiceView(BaseGenerationView): info = request.data.get('info', {}) if isinstance(info, str): try: - info = json.loads(info) + info = json.loads(info) if info else {} except json.JSONDecodeError: return Response({'detail': _('Invalid info payload')}, status=HTTP_400_BAD_REQUEST) + if not isinstance(info, dict): + info = {} if voice_id.isdigit(): - info.update({'voice_id': int(voice_id)}) + info['voice_id'] = int(voice_id) else: - info.update({'preset_id': voice_id}) - ct = getattr(request, 'content_type', '') - if 'multipart/form-data' in ct: + info['preset_id'] = voice_id + if isinstance(request.data, QueryDict): + request.data._mutable = True request.data['info'] = json.dumps(info, ensure_ascii=False) + request.data._mutable = False else: request.data['info'] = info return super().post(request, model_slug, *args, **kwargs) @@ -15,7 +15,7 @@ HF_API_KEY=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 GOOGLE_API_KEY=AIzaSyBf9el4d_CY610zjCcesKxKL70BLfl57OM MISTRAL_API_KEY=CYtZSCQXZFzHcpJvWOjWNx4EHjf5kWQc DEEPL_API_KEY=4bb58b98-ca95-5978-9be0-ed437df6c15c:fx -SERPER_API_KEY=ed8e0dbcc26dacf3f7f99fbc8b3add9ada0c793e +SERPER_API_KEY=301101dad80f91df00bf4ff60a63c8a71922e2b7 FLUX_API_KEY=dccaf377-aecf-4cf0-aff4-dde47cee340d OPENROUTER_API_KEY=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 BYTEDANCE_MODEL_ARK_API_KEY=ark-151e9e89-7275-4dbf-bbb3-d2b32bb69d61-3ca5b