@@ -0,0 +1,38 @@ +import logging + +from asgiref.sync import sync_to_async +from ninja import Router +from ninja.errors import HttpError + +from authentication.models import EmailToken +from authentication.schemas import ConfirmInSchema, TokensOutSchema + +from django.utils.translation import gettext as _ +from rest_framework_simplejwt.tokens import RefreshToken + +logger = logging.getLogger(__name__) + +router = Router(tags=['auth']) + + +@router.post('confirm', auth=None, tags=['auth/confirm'], response=TokensOutSchema) +async def confirm_user(request, payload: ConfirmInSchema): + try: + token = await EmailToken.objects.select_related('user').aget(key=payload.token) + user = token.user + await token.adelete() + if user.is_confirmed: + raise HttpError(400, _('User is already confirmed')) + user.is_confirmed = True + await user.asave() + refresh = await sync_to_async(RefreshToken.for_user)(user) + access = refresh.access_token + return TokensOutSchema(access=str(access), refresh=str(refresh)) + except EmailToken.DoesNotExist: + raise HttpError(400, _('No email token found')) + except HttpError as exc: + raise HttpError(exc.status_code, exc.message) + except Exception as exc: + logger.exception(exc) + raise HttpError(500, _('Could not confirm email, please try again.')) + @@ -51,3 +51,11 @@ class RefreshInSchema(Schema): class AccessOutSchema(Schema): access: str + +class ConfirmInSchema(Schema): + token: str + + +class TokensOutSchema(Schema): + access: str + refresh: str @@ -294,10 +294,14 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, - # 'execute_recurring_payments': { - # 'task': 'payments.tasks.execute_recurring_payments', - # 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), - # }, + 'execute_recurring_payments': { + 'task': 'payments.tasks.execute_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, + 'revoke_recurring_payments': { + 'task': 'payments.tasks.revoke_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, } CACHES = { @@ -18,6 +18,7 @@ from backend.public import urlpatterns as public_urlpatterns api = NinjaAPI(title='AIR API', version='1.0.0', docs_url=None) compatibility_api = NinjaAPI(title='AIR API DEBUG', version='0.0.1', docs_url=None) +compatibility_api_v2 = NinjaAPI(title='AIR API DEBUG v2', version='2.0.0', docs_url=None) api.add_router('copywrite/', 'tools.copywrite.routes.v1.router') api.add_router('users/', 'users.routes.v1.router') @@ -29,6 +30,8 @@ compatibility_api.add_router('payments/', 'payments.routes.v1.router') compatibility_api.add_router('reports/', 'reports.routes.v1.router') compatibility_api.add_router('ml_model/', 'ml_model.routes.v1.router') +compatibility_api_v2.add_router('auth/', 'authentication.routes.v2.router') + logger = logging.getLogger(__name__) @@ -72,6 +75,7 @@ if settings.DEBUG: ] api.docs_url = '/docs' compatibility_api.docs_url = '/docs' + compatibility_api_v2.docs_url = '/docs' urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) @@ -87,6 +91,7 @@ urlpatterns += [ path('api/v1/public/', include('tools.public_api.urls')), path('api/v1/api/', api.urls), path('api/v1/', compatibility_api.urls), + path('api/v1/v2/', compatibility_api_v2.urls), ] urlpatterns += public_urlpatterns @@ -1,6 +1,7 @@ from types import MappingProxyType -from typing import List, Mapping +from typing import List, Mapping, Never +import orjson from UnleashClient import UnleashClient from django.conf import settings @@ -16,7 +17,7 @@ class UnleashFeatureFlagService(FeatureFlagService): app_name=settings.FEATURE_FLAG_APP_NAME, instance_id=settings.FEATURE_FLAG_INSTANCE_ID, cache=UnleashRedisCache(), - environment=settings.FEATURE_FLAG_APP_NAME + environment=settings.FEATURE_FLAG_APP_NAME, ) def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: @@ -24,3 +25,18 @@ class UnleashFeatureFlagService(FeatureFlagService): def get_flag_state(self, name: str, email: Email) -> State: return self.client.is_enabled(feature_name=name, context={'userId': email}) + + def is_feature_enabled(self, name: str) -> bool: + for feature in orjson.loads(self.client.engine.get_state())['features']: + if feature['name'] == name: + return bool(feature.get('enabled')) + raise ValueError(f'Feature flag "{name}" not found in Unleash state') + + def get_user_emails(self, name: str) -> List[Email] | List[Never]: + for feature in orjson.loads(self.client.engine.get_state())['features']: + if feature['name'] == name: + userIds = feature['strategies'][0]['parameters'].get('userIds') + if userIds: + return userIds.replace(' ', '').split(',') + return [] + raise ValueError(f'Feature flag "{name}" not found in Unleash state') @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-14 22:35+0300\n" +"POT-Creation-Date: 2026-05-24 01:30+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -207,7 +207,7 @@ msgstr "Бизнес Группы" #: authentication/models/user.py:224 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 #: payments/admin.py:95 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: payments/models/payment.py:26 payments/models/payment_plan.py:43 #: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -666,7 +666,9 @@ msgstr "" #: ml_model/exceptions.py:47 #, python-format msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." -msgstr "Размер изображения превышает максимально допустимое количество пикселей (%(max_pixels)d)." +msgstr "" +"Размер изображения превышает максимально допустимое количество пикселей " +"(%(max_pixels)d)." #: ml_model/exceptions.py:53 msgid "The model is not responding" @@ -749,24 +751,30 @@ msgstr "" "Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " "позже" -#: ml_model/exceptions.py:166 -msgid "Available only in paid plan" -msgstr "Доступно только в платном тарифе" - -#: ml_model/exceptions.py:171 -msgid "The \"Use code\" option cannot be used together with an attached image." -msgstr "" -"Нельзя одновременно использовать параметр «Использовать код» вместе с " -"прикреплённым изображением." +#: ml_model/exceptions.py:169 +#, python-format +msgid "%(feature)s is available only in paid plan." +msgstr "%(feature)s доступно только в платном тарифном плане." -#: ml_model/exceptions.py:177 +#: ml_model/exceptions.py:176 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." -#: ml_model/exceptions.py:168 +#: ml_model/exceptions.py:181 msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." +#: ml_model/exceptions.py:185 +msgid "not specified" +msgstr "не указана" + +#: ml_model/exceptions.py:187 +msgid "" +"Version \"%(version)s\" is not available. Available versions: " +"%(available_versions)s." +msgstr "" +"Версия «%(version)s» недоступна. Доступные версии: %(available_versions)s." + #: ml_model/models.py:18 ml_model/models.py:38 ml_model/models.py:70 #: ml_model/models.py:182 tools/media/models.py:67 msgid "Slug" @@ -1054,14 +1062,25 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:154 +#: ml_model/services/chatgpt.py:149 msgid "No matching version found" msgstr "Соответствующая версия не найдена" -#: ml_model/services/chatgpt_5_4.py:270 +#: ml_model/services/chatgpt_5.py:130 ml_model/services/chatgpt_5_4.py:156 +#: 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 msgid "Image is ready" msgstr "Изображение готово" +#: ml_model/services/chatgpt_5_5.py:188 +msgid "File analysis" +msgstr "Анализ файлов" + #: ml_model/services/elevenlabs_music.py:45 msgid "Duration cannot be less than 5 seconds" msgstr "Длительность не может быть меньше 5 секунд" @@ -1089,7 +1108,7 @@ msgstr "Неизвестный бакет для загрузки" msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:78 +#: ml_model/services/seedream.py:87 msgid "3K output is not supported for Seedream 4.5" msgstr "3К разрешение не поддерживается для Seedream 4.5" @@ -1097,7 +1116,7 @@ msgstr "3К разрешение не поддерживается для Seedre msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:188 +#: ml_model/tasks.py:137 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1115,7 +1134,7 @@ msgstr "" msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:12 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" @@ -1184,36 +1203,36 @@ msgstr "Индивидуальный" msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 +#: payments/models/payment_plan.py:34 payments/models/payment_plan.py:49 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:42 +#: payments/models/payment_plan.py:35 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:58 +#: payments/models/payment_plan.py:51 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:59 +#: payments/models/payment_plan.py:52 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:25 +#: payments/models/payment_plan.py:56 payments/models/user_payment_method.py:25 msgid "Payment Method" msgstr "Платежный метод" -#: payments/models/payment_plan.py:69 +#: payments/models/payment_plan.py:62 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:75 +#: payments/models/payment_plan.py:68 msgid "Referral balance" msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:96 payments/models/payment_plan.py:97 +#: payments/models/payment_plan.py:89 payments/models/payment_plan.py:90 msgid "User Balance" msgstr "Баланс пользователя" @@ -1353,13 +1372,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:194 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 +#: payments/tests/test_plans.py:193 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:192 -#: payments/tests/test_plans.py:197 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 +#: payments/tests/test_plans.py:196 msgid "Images" msgstr "Изображения" @@ -1399,7 +1418,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:201 tools/media/apis.py:209 +#: tools/chats/apis.py:203 tools/media/apis.py:209 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1408,7 +1427,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:257 +#: tools/chats/apis.py:259 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1555,6 +1574,9 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." +#~ msgid "Available only in paid plan" +#~ msgstr "Доступно только в платном тарифе" + #~ msgid "Card type" #~ msgstr "Тип карты" @@ -9,6 +9,7 @@ from backend import settings from ml_model.exceptions import ( FileExtensionNotSupported, GenerationException, + NSFWDetectedException, RealPersonDetectedError, RequestBlocked, ) @@ -284,6 +285,10 @@ class BytedanceModelArkAdapter: resp.text, ) raise GenerationException from exc + if error_code := data.get('error', {}).get('code', ''): + if error_code == 'OutputImageSensitiveContentDetected': + raise NSFWDetectedException + if image_data := data.get('data'): urls = [item.get('url') for item in image_data if isinstance(item, dict) and item.get('url')] if urls: @@ -33,6 +33,7 @@ from ml_model.exceptions import ( CorruptedFileError, FileExtensionNotSupported, FileUploadUnsupported, + ModelVersionNotAvailable, ) from ml_model.models import ModelConfiguration, NeuronModel from ml_model.services.EmbeddingService import EmbeddingService @@ -104,7 +105,9 @@ class Chatgpt(SimpleService): ) -> list[Message]: start_time = time.time() info = input_message.info.copy() - model_name = info.pop('version', 'gpt-4o') + model_name = info.pop('version', None) + if model_name is None or model_name not in self.TOKENS_COST: + raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) user_system_prompt = info.pop('system_prompt', '') input_content = [{'type': 'text', 'text': input_message.content or ''}] file = input_message.file @@ -145,8 +148,6 @@ class Chatgpt(SimpleService): 'top_p': info.pop('top_p', 0.5), } self.llm.tiktoken_model_name = 'gpt-4' - if model_name not in self.TOKENS_COST.keys(): - raise Exception(_('No matching version found')) chat_history = self.get_chat_history(model_name=model_name) chat_history.add_message(HumanMessage(content=input_message.content)) conversation = RunnableWithMessageHistory( @@ -14,6 +14,7 @@ from ml_model.exceptions import ( ) from ml_model.models import NeuronModel from ml_model.services import Chatgpt +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from poller.models import Proxy @@ -99,7 +100,9 @@ class Chatgpt_5(Chatgpt): ) -> list[Message]: start_time = time.time() info = input_message.info.copy() - model_name = info.pop('version', 'gpt-5') + model_name = info.pop('version', None) + if model_name is None or model_name not in self.TOKENS_COST: + raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) user_system_prompt = info.pop('system_prompt', '') input_content = [{'type': 'text', 'text': input_message.content or ''}] file = input_message.file @@ -22,6 +22,7 @@ from ml_model.exceptions import ( ) from ml_model.models import NeuronModel from ml_model.services import Chatgpt +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from poller.models import Proxy @@ -113,13 +114,15 @@ class Chatgpt_5_4(Chatgpt): ) -> list[Message]: start_time = time.time() info = input_message.info.copy() - model_name = info.pop('version', 'gpt-5.4') + model_name = info.pop('version', None) + if model_name is None or model_name not in self.TOKENS_COST: + raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) user_system_prompt = info.pop('system_prompt', '') plan_info = self.store.user.payment_plan is_regular_user = self.store.user.account_type == 'regular' is_free_plan = is_regular_user and plan_info and plan_info.plan.price <= 0 if is_free_plan and model_name == 'gpt-5.4-pro': - raise PaidPlanRequiredError() + raise PaidPlanRequiredError('ChatGPT 5.4 PRO') if is_free_plan: info.pop('web_search', None) info.pop('code_interpreter', None) @@ -16,7 +16,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, System from messages.models import Message -from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, InvalidParameterError +from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, InvalidParameterError, PaidPlanRequiredError from ml_model.services import Chatgpt from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService @@ -155,7 +155,7 @@ class Chatgpt_5_5(Chatgpt): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() info = input_message.info.copy() - model_name = info.pop('version', 'gpt-5.5') + model_name = 'gpt-5.5' user_system_prompt = info.pop('system_prompt', '') file = input_message.file image = None @@ -185,11 +185,7 @@ class Chatgpt_5_5(Chatgpt): file_extension = file_service.get_file_extension(raw_file_extension, file_bytes) if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): if is_free_plan: - return self.save_results( - results=[AIMessage('**Анализ файла пропускается в бесплатной версии!**')], - elapsed_time=timedelta(minutes=0, seconds=0), - generated_image=None, - ) + raise PaidPlanRequiredError(gettext('File analysis')) text = file_service.get_file_data(file_extension, file_bytes) text_chunks = EmbeddingService.split_text_to_chunks(text) chunks = [HumanMessage(content=chunk_text) for chunk_text in text_chunks] @@ -9,7 +9,7 @@ import filetype from PIL import Image from messages.models import Message -from ml_model.exceptions import FileExtensionNotSupported +from ml_model.exceptions import FileExtensionNotSupported, ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from ml_model.services.base import SimpleService @@ -65,7 +65,10 @@ class Claude(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'anthropic/{input_message.info.pop("version", "claude-sonnet-4.6")}' + version_slug = input_message.info.pop('version', None) + if version_slug is None or version_slug not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) + version = f'anthropic/{version_slug}' system_prompt = input_message.info.pop('system_prompt', '') callback_data = {'provider': {'order': ['Anthropic']}, **input_message.info} messages = [ @@ -3,6 +3,7 @@ from _decimal import Decimal from datetime import timedelta from messages.models import Message +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from tools.chats.models import Chat @@ -54,7 +55,9 @@ class Deepseek(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: info = input_message.info.copy() - version = info.pop('version') + version = info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) system_prompt = info.pop('system_prompt', '') callback_data = { @@ -10,6 +10,7 @@ from messages.models import Message from ml_model.adapters.bytedance_model_ark import BytedanceContentType, BytedanceModelArkAdapter from ml_model.exceptions import FileExtensionNotSupported from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run from tools.chats.models import Chat @@ -72,7 +73,9 @@ class Dola_Seed(SimpleService): return msgs def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'seed-2-0-pro') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) callback_data = { "reasoning_effort": "minimal", **input_message.info, @@ -73,7 +73,7 @@ class Flux(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version') + version = 'flux-schnell' user_prompt = self.translate_prompt(input_message.content) callback_data = dict( { @@ -84,7 +84,7 @@ class Flux(SimpleService): } ) runner = replicate_run( - f'{self._CALLBACK_BASE}{callback_data.get("version", "flux-schnell")}', + f'{self._CALLBACK_BASE}{version}', callback_data, ) images = runner if isinstance(runner, list) else [runner] @@ -14,6 +14,7 @@ from django.core.files.images import get_image_dimensions from messages.models import Message from ml_model.exceptions import PredictionInterruptedError, RequestBlocked, GenerationException, \ FileExtensionNotSupported +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -67,7 +68,9 @@ class Flux_2(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version', 'flux-2-pro') + version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) width = input_message.info.pop('width', 1024) height = input_message.info.pop('height', 1024) output_mp = math.ceil((width*height) / 1_000_000) @@ -70,7 +70,7 @@ class Fluxkrea(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version') + version = 'flux-krea-dev' callback_data = dict( { 'prompt': self.translate_prompt(input_message.content), @@ -85,7 +85,7 @@ class Fluxkrea(SimpleService): input_message.file.close() callback_data.update({'image': image}) runner = replicate_run( - f'{self._CALLBACK_BASE}{callback_data.get("version", "flux-krea-dev")}', + f'{self._CALLBACK_BASE}{version}', callback_data, ) images = runner if isinstance(runner, list) else [runner] @@ -1,18 +1,17 @@ import time -import httpx - -from backend import settings -from decimal import Decimal from datetime import timedelta +from decimal import Decimal from io import BytesIO from typing import Any +import httpx from django.core.files import File +from backend import settings from messages.models import Message - -from ml_model.exceptions import ModelTimeoutError, GenerationException +from ml_model.exceptions import GenerationException, ModelTimeoutError from ml_model.services.base import SimpleService +from poller.models import Proxy class Fluxlorafast(SimpleService): @@ -43,12 +42,27 @@ class Fluxlorafast(SimpleService): ) -> list[Message]: messages: list[Message] = [] for image in images: + for proxy in Proxy.objects.all(): + client = httpx.Client( + base_url='https://queue.fal.run', + headers={'Authorization': f'Key {settings.FAL_API_KEY}'}, + timeout=600, + proxy=f'{proxy.protocol}://{proxy.address}', + ) + + try: + file = File(BytesIO(client.get(image).content), '.png') + except Exception: + continue + + break + messages.append( Message( content_object=self.store, elapsed_time=time, content=prompt, - file=File(BytesIO(httpx.get(image).content), '.png'), + file=file, ) ) if save: @@ -64,20 +78,19 @@ class Fluxlorafast(SimpleService): '4:3': 'landscape_4_3', '16:9': 'landscape_16_9', } - requests_number = 0 start_time = time.time() - version = input_message.info.get('version') + version = 'flux-lora' translated_prompt = self.translate_prompt(input_message.content) callback_data = dict( { 'prompt': f'in style of raif3_corporate Isometric illustration, ' - f'contemporary vector art style, 3/4 perspective view: {translated_prompt}', + f'contemporary vector art style, 3/4 perspective view: {translated_prompt}', 'model_version': 'fb90c17a-d410-41e7-9961-dc7c687bc627', 'image_size': sizes.get(input_message.info.get('image_size', '1:1')), 'loras': [ { 'path': 'https://v3.fal.media/files/elephant/JthCZoCdAr7' - 'LqnOiNNVCC_pytorch_lora_weights.safetensors' + 'LqnOiNNVCC_pytorch_lora_weights.safetensors' } ], 'guidance_scale': 5, @@ -85,29 +98,53 @@ class Fluxlorafast(SimpleService): 'num_images': input_message.info.get('num_images', 4), } ) - client = httpx.Client( - base_url="https://queue.fal.run", - headers={"Authorization": f"Key {settings.FAL_API_KEY}"}, - timeout=600, - ) - result = client.post( - f'fal-ai/{version}', - json={'prompt': input_message.content, **callback_data}, - ).json() - try: - while True: - status = client.get(result['status_url']).json() - if status.get('status') == 'COMPLETED': - break - requests_number += 1 - if requests_number == 271: - raise ModelTimeoutError - time.sleep(1/3) - except Exception as exc: - raise GenerationException from exc + + final_result = self._request(version, input_message, callback_data) process_time = timedelta(seconds=(time.time() - start_time)) - final_result = client.get(result['response_url']).json() + images = [img['url'] for img in final_result['images']] self.handle_invoice(input_message.content_object.model, input_message=input_message, version=version) msgs = self.save_results(input_message.content, images, process_time, save) return msgs + + def _request(self, version: str, input_message: Message, callback_data: dict) -> dict: + proxies = Proxy.objects.all() + for proxy in proxies: + requests_number = 0 + client = httpx.Client( + base_url='https://queue.fal.run', + headers={'Authorization': f'Key {settings.FAL_API_KEY}'}, + timeout=600, + proxy=f'{proxy.protocol}://{proxy.address}', + ) + result = client.post( + f'fal-ai/{version}', + json={'prompt': input_message.content, **callback_data}, + ).json() + + is_success = False + + while True: + if requests_number == 271 / len(proxies): + break + + try: + status = client.get(result['status_url']).json() + except Exception: + requests_number += 1 + continue + if status.get('status') == 'COMPLETED': + is_success = True + break + + time.sleep(1 / 3) + + if is_success: + break + + if not is_success: + raise GenerationException from ModelTimeoutError + + final_result = client.get(result['response_url']).json() + + return final_result @@ -14,6 +14,7 @@ from ml_model.models import ( ModelInput, NeuronModel, ) +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -84,6 +85,8 @@ class Fluxproultra(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) callback_data = dict( { 'prompt': self.translate_prompt(input_message.content), @@ -9,7 +9,7 @@ from django.db.models.fields.files import FieldFile from PIL import Image from messages.models import Message -from ml_model.exceptions import FileExtensionNotSupported +from ml_model.exceptions import FileExtensionNotSupported, ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from ml_model.services.base import SimpleService @@ -94,14 +94,17 @@ class Gemini(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: info = input_message.info.copy() - version = info.pop('version') + version_slug = info.pop('version', None) + if version_slug is None or version_slug not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) + version = f'google/{version_slug}' callback_data = { 'provider': {'order': ['Google AI Studio']}, **input_message.info, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) - if version == 'google/gemini-3-flash-preview': + if version_slug == 'gemini-3-flash-preview': messages.insert( 0, { @@ -12,6 +12,7 @@ from messages.models import Message from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from poller.models import Proxy @@ -66,7 +67,10 @@ class Gemini_3_1(SimpleService): return msgs def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.get('version', 'gemini-3.1-pro-preview:online') + version_slug = input_message.info.get('version') + if version_slug is None or version_slug not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) + model_slug = f'google/{version_slug}:online' callback_data = { 'provider': {'order': ['Google AI Studio']}, **input_message.info, @@ -135,11 +139,11 @@ class Gemini_3_1(SimpleService): else: raise FileExtensionNotSupported(supported_extensions) start_time = time.time() - result = openrouter_run(f'google/{version}:online', messages, callback_data, 'Gemini 3.1') + result = openrouter_run(model_slug, messages, callback_data, 'Gemini 3.1') process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, - version=version, + version=version_slug, input_tokens=result[1], output_tokens=result[2], embedding_tokens=embedding_tokens, @@ -44,7 +44,7 @@ class Glm_4_7(SimpleService): return msgs def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'glm-4-7-251222') + version = 'glm-4-7-251222' callback_data = { "reasoning_effort": "minimal", **input_message.info, @@ -9,7 +9,7 @@ from pathlib import Path import filetype from PIL import Image from ml_model.services.FileService import FileProcessingService -from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError +from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported from ml_model.services.EmbeddingService import EmbeddingService from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector @@ -68,7 +68,7 @@ class Grok(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'x-ai/{input_message.info.pop("version", "grok-4.3")}' + version = 'x-ai/grok-4.3' callback_data = {**input_message.info} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -11,6 +11,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -53,7 +54,9 @@ class Hailuo(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.get('version', 'hailuo-2.3-fast') + version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) resolution = input_message.info.get('resolution', '768p') if ( (balance := PaymentPlanSelector(self.store.user).get_current_balance()) @@ -67,7 +67,7 @@ class Ideogram(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version') + version = 'ideogram-v3-turbo' if ( input_message.info.get('style_preset', 'None') != 'None' and input_message.info.get('style_type', 'None') not in ('None', 'Auto', 'General') @@ -80,7 +80,7 @@ class Ideogram(SimpleService): } ) runner = replicate_run( - f'{self._CALLBACK_BASE}{callback_data.get("version", "ideogram-v3-turbo")}', + f'{self._CALLBACK_BASE}{version}', callback_data, ) images = runner if isinstance(runner, list) else [runner] @@ -63,7 +63,7 @@ class Leonardo(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version', 'lucid-origin') + version = 'lucid-origin' generation_mode = input_message.info.get('generation_mode', 'standard') num_images = input_message.info.get('num_images', 1) callback_data = dict( @@ -73,7 +73,7 @@ class Leonardo(SimpleService): } ) runner = replicate_run( - f'{self._CALLBACK_BASE}{callback_data.get("version", "lucid-origin")}', + f'{self._CALLBACK_BASE}{version}', callback_data, ) images = runner if isinstance(runner, list) else [runner] @@ -10,6 +10,7 @@ from datetime import timedelta from django.db.models.fields.files import FieldFile from messages.models import Message +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from tools.chats.models import Chat @@ -61,7 +62,10 @@ class Llama(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'meta-llama/{input_message.info.pop('version', 'llama-4-maverick')}' + version_slug = input_message.info.pop('version', None) + 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} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -9,6 +9,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -37,7 +38,9 @@ class Lyria(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'lyria-3') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.TOKENS_COST[version] ): @@ -42,7 +42,7 @@ class Minimaxvideo(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'video-01') + version = 'video-01' if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[version]: raise InsufficientBalance(balance, self.TOKENS_COST[version]) callback_data = dict({'prompt': input_message.content, **input_message.info}) @@ -54,8 +54,7 @@ class Mistral(SimpleService): return msgs def make(self, input_message: Message, save: bool = True) -> list[Message]: - info = input_message.info.copy() - version = f'mistralai/{info.pop("version")}' + version = 'mistralai/mistral-small-3.1-24b-instruct' callback_data = {'provider': {'order': ['Parasail']}, **input_message.info} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -16,6 +16,7 @@ from ml_model.exceptions import ( ModelCouldNotInterpretPrompt, RequestBlocked, ) +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -62,7 +63,9 @@ class Nanobanana(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version', 'nano-banana') + version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) resolution = input_message.info.get('resolution', '2K') if version == 'nano-banana-pro' else None callback_data = dict( { @@ -8,6 +8,7 @@ import httpx from django.conf import settings from messages.models import Message +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from poller.models import Proxy from tools.chats.models import Chat @@ -64,7 +65,10 @@ class Perplexity(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'perplexity/{input_message.info.pop("version", "sonar")}' + version_slug = input_message.info.pop('version', None) + 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} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -11,6 +11,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import GenerationException, RequestBlocked +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -42,7 +43,9 @@ class Prunaai(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.get('version', 'p-image') + version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) aspect_ratio = input_message.info.pop('aspect_ratio', 'custom') if input_message.file: version = 'p-image-edit' @@ -4,6 +4,7 @@ from decimal import Decimal from typing import Any, Dict, Iterator from messages.models import Message +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from tools.chats.models import Chat @@ -49,7 +50,10 @@ class Qwen(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'qwen/{input_message.info.pop("version", "qwq-32b")}' + version_slug = input_message.info.pop('version', None) + 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} messages = self.get_chat_history() messages.insert( @@ -4,6 +4,7 @@ from decimal import Decimal from typing import Any, Dict, Iterator from messages.models import Message +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from tools.chats.models import Chat @@ -53,7 +54,10 @@ class Qwen_235B(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = f'qwen/{input_message.info.pop("version", "qwen3-235b-a22b")}' + version_slug = input_message.info.pop('version', None) + 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} messages = self.get_chat_history() messages.insert( @@ -11,6 +11,7 @@ from messages.models import Message from ml_model.exceptions import FileExtensionNotSupported from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from poller.models import Proxy @@ -62,7 +63,10 @@ class Qwen_3_6(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = self.MODELS_LINKS[input_message.info.get('version', 'qwen3.6-flash')] + version_slug = input_message.info.get('version') + if version_slug is None or version_slug not in self.MODELS_LINKS: + raise ModelVersionNotAvailable(version_slug, self.MODELS_LINKS) + api_slug = self.MODELS_LINKS[version_slug] callback_data = {'max_tokens': self.MAX_OUTPUT_TOKENS, **input_message.info} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -112,12 +116,12 @@ class Qwen_3_6(SimpleService): ] else: raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP']) - model_slug = f'qwen/{version}:online' + model_slug = f'qwen/{api_slug}:online' result = openrouter_run(model_slug, messages, callback_data, 'Qwen 3.6') process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, - version=version, + version=api_slug, input_tokens=result[1], output_tokens=result[2], embedding_tokens=embedding_tokens, @@ -11,6 +11,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -44,7 +45,9 @@ class Ray(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: duration = input_message.info.get('duration', 5) - version = input_message.info.get('version', 'ray-2-720p') + version = input_message.info.get('version') + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.TOKENS_COST[version] * duration ): @@ -14,6 +14,7 @@ from ml_model.models import ( ModelParameter, ModelVersion, ) +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -178,14 +179,15 @@ class Recraft(SimpleService): 'линогравюра': 'linocut', } start_time = time.time() - extension = ( - '.svg' if input_message.info.get('version', 'recraft-v3') == self.versions[1].slug else '.png' - ) + version = input_message.info.get('version') + if version is None or version not in self.payment_rules: + raise ModelVersionNotAvailable(version, self.payment_rules) + extension = '.svg' if version == self.versions[1].slug else '.png' size = self._get_size( input_message.info.pop('width', 1024), input_message.info.pop('height', 1024), ) - callback_url = f'recraft-ai/{input_message.info.get("version", "recraft-v3")}' + callback_url = f'recraft-ai/{version}' callback_data = dict( { 'prompt': ( @@ -47,7 +47,7 @@ class Runway(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'gen4-turbo') + version = 'gen4-turbo' duration = input_message.info.get('duration', 5) file = input_message.file if ( @@ -11,6 +11,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import RequestBlocked, GenerationException, FileExtensionNotSupported from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -63,7 +64,9 @@ class Seedance(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'seedance-2.0') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) resolution = input_message.info.get('resolution', '720p') duration = input_message.info.get('duration', 5) file = input_message.file or None @@ -16,6 +16,7 @@ from messages.models import Message from ml_model.adapters.bytedance_model_ark import BytedanceContentType from ml_model.exceptions import FileExtensionNotSupported, InvalidParameterError from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run @@ -100,7 +101,9 @@ class Seedance_2_Dreamina(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'dreamina-seedance-2-0') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) resolution = input_message.info.get('resolution', '720p') duration = input_message.info.get('duration', 5) ratio = input_message.info.get('ratio', '16:9') @@ -12,6 +12,7 @@ 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.base import SimpleService from ml_model.tasks import bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -80,7 +81,9 @@ class Seedream(SimpleService): )): raise InsufficientBalance(balance, predicted) - version = input_message.info.pop('version', 'seedream-boosted') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKEN_COST: + raise ModelVersionNotAvailable(version, self.TOKEN_COST) size = input_message.info.get('size', '2K') if version == 'seedream-4.5' and size == '3K': @@ -13,6 +13,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import ModelTimeoutError, RequestBlocked, UnsupportedSize +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from poller.models import Proxy @@ -50,7 +51,9 @@ class Sora(SimpleService): def make(self, input_message: "Message", save: bool = True) -> list["Message"]: if input_message.content: for proxy in Proxy.objects.all(): - version = input_message.info.pop('version', 'sora-2') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) seconds = int(input_message.info.pop('seconds', 4)) callback_data = { 'prompt': input_message.content, @@ -11,7 +11,7 @@ from django.conf import settings from django.core.files import File from messages.models import Message -from ml_model.exceptions import RequestBlocked +from ml_model.exceptions import ModelVersionNotAvailable, RequestBlocked from ml_model.services.base import SimpleService from poller.models import Proxy @@ -76,7 +76,10 @@ class Stablediffusion(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() info = input_message.info.copy() - model_name = self.MODELS_LINKS[info.get('version', 'sd3')] + version = info.get('version') + if version is None or version not in self.MODELS: + raise ModelVersionNotAvailable(version, self.MODELS) + model_name = self.MODELS_LINKS[version] translated_prompt = self.translate_prompt(input_message.content) callback_data = { 'prompt': translated_prompt, @@ -11,6 +11,7 @@ from django.core.files import File from messages.models import Message from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -41,7 +42,9 @@ class Veo(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', 'veo-3-fast') + version = input_message.info.pop('version', None) + if version is None or version not in self.TOKENS_COST: + raise ModelVersionNotAvailable(version, self.TOKENS_COST) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[ version ]: @@ -162,8 +162,13 @@ class ServiceHighDemandError(Exception): class PaidPlanRequiredError(Exception): + def __init__(self, feature: str) -> None: + self.feature = feature + def __str__(self) -> str: - return _('Available only in paid plan') + return _('%(feature)s is available only in paid plan.') % { + 'feature': self.feature, + } class FaceNotFoundError(Exception): @@ -174,3 +179,18 @@ class FaceNotFoundError(Exception): class RealPersonDetectedError(Exception): def __str__(self) -> str: return _('The input image may contain real person.') + + +class ModelVersionNotAvailable(Exception): + def __init__(self, version: str | None, available_versions: Iterable[str]) -> None: + self.version = version + self.available_versions = list(available_versions) + + def __str__(self) -> str: + version_label = self.version if self.version is not None else _('not specified') + return _( + 'Version "%(version)s" is not available. Available versions: %(available_versions)s.' + ) % { + 'version': version_label, + 'available_versions': ', '.join(self.available_versions), + } @@ -1,3 +1,4 @@ +import hashlib import logging from datetime import timedelta @@ -102,7 +103,8 @@ class PaymentService: return await sync_to_async(YookassaPayment.find_one)(payment_id) def handle_captured_payment(self, payment_id: UUID) -> None: - YookassaPayment.capture(str(payment_id)) + idempotency_key = hashlib.sha256(f'capture:{payment_id}'.encode('utf-8')).hexdigest() + YookassaPayment.capture(str(payment_id), idempotency_key=idempotency_key) logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) def calculate_buying_tokens(self, plan: PaymentPlan): @@ -1,8 +1,13 @@ +import hashlib +from datetime import timezone as dt_timezone from decimal import Decimal -from uuid import UUID, uuid4 +from itertools import islice +from uuid import UUID from celery import shared_task from celery.utils.log import get_task_logger +from django.core.cache import cache +from django.db import transaction from django.db.models import F from django.utils import timezone @@ -10,14 +15,15 @@ from authentication.models.business_host import BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_service import EmailService from lib.unleash.client import celery_client -from payments.models import PaymentPlanUserInfo, PaymentMethod -from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.models import PaymentMethod, PaymentPlan, PaymentPlanUserInfo from payments.services.payment_plan_service import PaymentPlanService - from yookassa import Payment as YookassaPayment logger = get_task_logger(__name__) +CHUNK_SIZE = 1000 +LOCK_TIMEOUT_SECONDS = 10 * 60 + @shared_task def send_low_balance_message(): @@ -38,39 +44,42 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: - overdue_payments = PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method').filter( - next_payment_at__isnull=False, - next_payment_at__lte=timezone.now(), - plan__price__gt=0, - plan__individual=False, + if not celery_client.is_feature_enabled('recurring_payments'): + logger.info('Recurring payments feature disabled, skipping execute') + return + + emails = celery_client.get_user_emails('recurring_payments') + overdue_payments = ( + PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method') + .filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + method__isnull=False, + ) + .only( + 'uid', + 'next_payment_at', + 'user_id', + 'plan_id', + 'method_id', + 'user__uid', + 'user__email', + 'plan__uid', + 'plan__price', + 'plan__tokens_per_plan', + 'method__uid', + 'method__payment_method_id', + 'method__attempts', + ) ) - canceled_recurring_payments = [] - logger.info('Recurring payments task started: overdue_count=%s', overdue_payments.count()) - for overdue_payment in overdue_payments: + if emails: + overdue_payments = overdue_payments.filter(user__email__in=emails) + for overdue_payment in overdue_payments.iterator(chunk_size=CHUNK_SIZE): customer = overdue_payment.user plan = overdue_payment.plan - if not celery_client.get_flag_state('recurring_payments', overdue_payment.user.email): - overdue_payment.next_payment_at = None - canceled_recurring_payments.append(overdue_payment) - logger.info( - 'Recurring payment canceled by feature flag: email=%s plan_uid=%s', - customer.email, - plan.uid, - ) - continue - if not overdue_payment.is_recurring: - free_plan = PaymentPlanSelector(customer).get_free_plan(plan.is_corporate) - overdue_payment.next_payment_at = None - overdue_payment.plan = free_plan - overdue_payment.current_token_balance = 0 - canceled_recurring_payments.append(overdue_payment) - logger.info( - 'Recurring payment canceled due to missing method: email=%s plan_uid=%s switched_to_free_plan_uid=%s', - customer.email, - plan.uid, - free_plan.uid, - ) - continue receipt_data = { 'customer': {'email': customer.email}, 'items': [ @@ -93,7 +102,14 @@ def execute_recurring_payments() -> None: 'plan_uid': str(plan.uid), }, } - YookassaPayment.create(payment_data, uuid4()) + dt = overdue_payment.next_payment_at + if timezone.is_naive(dt): + dt = timezone.make_aware(dt) + period = dt.astimezone(dt_timezone.utc).replace(microsecond=0).isoformat() + idempotency_key = hashlib.sha256( + f'recurring:{customer.uid}:{plan.uid}:{period}:{overdue_payment.method.attempts}'.encode('utf-8') + ).hexdigest() + YookassaPayment.create(payment_data, idempotency_key=idempotency_key) logger.info( 'Recurring payment initiated: email=%s plan_uid=%s amount=%s method_uid=%s', customer.email, @@ -101,13 +117,63 @@ def execute_recurring_payments() -> None: plan.price, overdue_payment.method.uid, ) - methods_for_delete = [crp.method.uid for crp in canceled_recurring_payments if crp.method] - PaymentPlanUserInfo.objects.bulk_update( - canceled_recurring_payments, fields=['next_payment_at', 'plan', 'current_token_balance'] - ) - deleted_methods_count, deleted_details = PaymentMethod.objects.filter(uid__in=methods_for_delete).delete() - logger.info( - 'Recurring payments task finished: canceled_count=%s deleted_methods=%s', - len(canceled_recurring_payments), - deleted_methods_count, - ) \ No newline at end of file + + +@shared_task +def revoke_recurring_payments() -> None: + lock_key = 'recurring_payments:revoke_lock' + if not cache.add(lock_key, 'locked', timeout=LOCK_TIMEOUT_SECONDS): + logger.info('Revoke recurring already running, skipping') + return + try: + feature_name = 'recurring_payments' + base_qs = PaymentPlanUserInfo.objects.filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + ) + revoked_count = 0 + canceled_count = 0 + deleted_methods_count = 0 + + if not celery_client.is_feature_enabled(feature_name): + flag_off_qs = base_qs + flag_on_qs = base_qs.none() + else: + allowed_emails = celery_client.get_user_emails(feature_name) + if allowed_emails: + flag_off_qs = base_qs.exclude(user__email__in=allowed_emails) + flag_on_qs = base_qs.filter(user__email__in=allowed_emails, method__isnull=True) + else: + flag_off_qs = base_qs.none() + flag_on_qs = base_qs.filter(method__isnull=True) + + uid_iter = flag_off_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(uid_iter, CHUNK_SIZE)): + with transaction.atomic(): + deleted_methods_count += PaymentMethod.objects.filter( + user_plan_info__uid__in=uids + ).delete()[0] + revoked_count += flag_off_qs.filter(uid__in=uids).update(next_payment_at=None) + + if flag_on_qs.exists(): + free_regular_plan = PaymentPlan.objects.get_or_create(price=0, is_corporate=False)[0] + flag_on_iter = flag_on_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(flag_on_iter, CHUNK_SIZE)): + with transaction.atomic(): + canceled_count += flag_on_qs.filter(uid__in=uids).update( + next_payment_at=None, + plan_id=free_regular_plan.pk, + current_token_balance=0, + ) + + logger.info( + 'Revoke recurring finished: revoked=%s free_regular=%s deleted_methods=%s', + revoked_count, + canceled_count, + deleted_methods_count, + ) + finally: + cache.delete(lock_key) @@ -27,13 +27,14 @@ from ml_model.exceptions import ( FileTooLargeError, FileUploadUnsupported, ImageAnalysisError, - UnrecognizedFileError, + InvalidParameterError, + ModelVersionNotAvailable, PaidPlanRequiredError, PromptLengthExceeded, RequestBlocked, TemplateNotFound, TemplateUnknownException, - InvalidParameterError + UnrecognizedFileError, ) from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance @@ -184,6 +185,7 @@ class MessagesAPIView(APIView): FileUploadUnsupported, UnrecognizedFileError, InvalidParameterError, + ModelVersionNotAvailable, ) as exc: return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) except TemplateNotFound as exc: @@ -17,17 +17,18 @@ from ml_model.exceptions import ( FileExtensionNotSupported, FileNotProvided, FileTooLargeError, - UnrecognizedFileError, ImageAnalysisError, ImageContentNotFound, InvalidParameterError, - ModelCouldNotInterpretPrompt, InvalidStyleCombinationError, + ModelCouldNotInterpretPrompt, + ModelVersionNotAvailable, PromptLengthExceeded, + RealPersonDetectedError, RequestBlocked, ServiceHighDemandError, + UnrecognizedFileError, UnsupportedSize, - RealPersonDetectedError ) from ml_model.models import NeuronModel from ml_model.services.base import SimpleService @@ -190,6 +191,7 @@ class MediaAPIView(APIView): ModelCouldNotInterpretPrompt, InvalidStyleCombinationError, InvalidParameterError, + ModelVersionNotAvailable, PromptLengthExceeded, ExceededContextLengthError, FileExtensionNotSupported,