@@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-10 11:02+0300\n" +"POT-Creation-Date: 2026-08-13 17:52+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -766,7 +766,8 @@ msgstr "Промпт слишком длинный" 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" @@ -1097,17 +1098,21 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/FileService.py:110 +#: ml_model/services/FileService.py:123 #: tools/public_api/views/providers/openai_compatible.py:208 msgid "Voice not found." msgstr "Голос не найден." +#: ml_model/services/FileService.py:227 +msgid "Image is too wide or tall" +msgstr "Изображение слишком широкое или высокое" + #: ml_model/services/chatgpt.py:245 msgid "Image is ready" msgstr "Изображение готово" #: ml_model/services/chatgpt.py:361 ml_model/services/claude.py:277 -#: ml_model/services/grok.py:191 +#: ml_model/services/grok.py:192 msgid "File analysis" msgstr "Анализ файлов" @@ -1134,6 +1139,14 @@ msgstr "1080р разрешение не поддерживается для See msgid "3K output is not supported for this model" msgstr "3К разрешение не поддерживается для этой модели" +#: ml_model/services/flux_3.py:87 +msgid "Draft mode is only available at 720p" +msgstr "Режим draft доступен только при 720p" + +#: ml_model/services/FileService.py +msgid "The attached video must be at most %(max_seconds)d seconds" +msgstr "Прикреплённое видео должно быть не длиннее %(max_seconds)d секунд" + #: ml_model/services/upscaleai.py:124 msgid "No image given for improving" msgstr "Нет изображения для улучшения" @@ -1,3 +1,4 @@ +import json import re import subprocess import zipfile @@ -123,48 +124,48 @@ class FileProcessingService: return voice.file -class ImageFileProcessingService: - ALLOWED_EXTENSIONS = ['PNG', 'JPG', 'JPEG', 'WEBP'] +class MediaFileProcessor: + EXTENSIONS = [] - def __init__(self, image: FieldFile) -> None: - self.image = image + def __init__(self, media_file: FieldFile) -> None: + self._media_file = media_file @staticmethod - def __reset_image(func): + def _reset_media_file(func): @wraps(func) def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) finally: - self.image.seek(0) + if not self._media_file.closed: + self._media_file.seek(0) return wrapper - @__reset_image + @_reset_media_file def get_bytes(self, size: int | None = None) -> bytes: - return self.image.read(size) + return self._media_file.read(size) - def get_kind(self, file_bytes: bytes): + @staticmethod + def get_kind(file_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]: + +class ImageFileProcessor(MediaFileProcessor): + EXTENSIONS = ['PNG', 'JPG', 'JPEG', 'WEBP'] + + @MediaFileProcessor._reset_media_file + def get_dimensions(self) -> tuple[int | None, int | None]: 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) + return get_image_dimensions(self._media_file) + except Image.DecompressionBombError as exc: + if (max_pixels := Image.MAX_IMAGE_PIXELS) is None: + raise + raise ImageTooLargeError(max_pixels) from exc - def get_normalized_image(self, file_bytes: bytes) -> BytesIO: + @staticmethod + def get_normalized_image(file_bytes: bytes) -> BytesIO: normalized_image = BytesIO(file_bytes) with Image.open(normalized_image) as source_image: img = source_image.convert('RGBA') @@ -173,3 +174,66 @@ class ImageFileProcessingService: img.close() normalized_image.seek(0) return normalized_image + + +class VideoFileProcessor(MediaFileProcessor): + EXTENSIONS = ['MP4'] + + def get_duration(self) -> float: + result = subprocess.run( + [ + 'ffprobe', + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'json', + '-', + ], + input=self.get_bytes(), + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise CorruptedFileError + duration = float(json.loads(result.stdout).get('format', {}).get('duration') or 0) + if duration <= 0: + raise CorruptedFileError + return duration + + +class MediaFileValidator: + @staticmethod + def validate_kind(kind, allowed_extensions: list[str]) -> None: + if not kind: + raise CorruptedFileError + if kind.extension.upper() not in allowed_extensions: + raise FileExtensionNotSupported(allowed_extensions) + + +class ImageFileValidator(MediaFileValidator): + @staticmethod + def validate_dimensions(w: int | None, h: int | None, *, max_pixels: int | None) -> None: + if not (w and h): + raise CorruptedFileError + if max_pixels: + if w * h > max_pixels: + raise ImageTooLargeError(max_pixels) + + @staticmethod + def validate_aspect_ratio(w: int | None, h: int | None, *, min_ratio: float, max_ratio: float) -> None: + if not (w and h): + raise CorruptedFileError + if not (min_ratio <= w / h <= max_ratio): + raise InvalidParameterError(_('Image is too wide or tall')) + + +class VideoFileValidator(MediaFileValidator): + @staticmethod + def validate_duration(duration: float, *, max_seconds: int) -> None: + if duration > max_seconds: + raise InvalidParameterError( + _('The attached video must be at most %(max_seconds)d seconds') + % {'max_seconds': max_seconds} + ) @@ -14,6 +14,7 @@ from ml_model.services.elevenlabs_music import Elevenlabs_Music from ml_model.services.epicphotogasm import Epicphotogasm from ml_model.services.flux import Flux from ml_model.services.flux_2 import Flux_2 +from ml_model.services.flux_3 import Flux_3 from ml_model.services.fluxkrea import Fluxkrea from ml_model.services.fluxlorafast import Fluxlorafast from ml_model.services.fluxproultra import Fluxproultra @@ -39,6 +39,7 @@ from ml_model.models import ModelConfiguration, NeuronModel from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from ml_model.services.base import SimpleService +from ml_model.services.serper_mixin import SerperMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector from poller.models import Proxy @@ -47,7 +48,7 @@ from tools.copywrite.models import Copywrite from tools.public_api.models import APIStore -class Chatgpt_4(SimpleService): +class Chatgpt_4(SerperMixin, SimpleService): """ ChatGPT 4 Service contains abstract method make, which makes a generation @@ -167,7 +168,10 @@ class Chatgpt_4(SimpleService): ) output_tokens = 0 self.assert_enough_balance( - input_tokens, image_size, model=self.llm.model_name, embedding_tokens=input_embedding_tokens + input_tokens, + image_size, + model=self.llm.model_name, + embedding_tokens=input_embedding_tokens, ) if model_name == 'gpt-oss-120b': system = chat_history.messages.pop(0) @@ -215,8 +219,9 @@ class Chatgpt_4(SimpleService): (data := response.json()) and data.get('choices') and ( - content := ','.join( - [choice['message']['content'] for choice in data.get('choices')]) + content := ','.join( + [choice['message']['content'] for choice in data.get('choices')] + ) ) ): input_tokens = response.json()['usage']['prompt_tokens'] @@ -242,7 +247,9 @@ class Chatgpt_4(SimpleService): ] elif file: if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -261,15 +268,17 @@ class Chatgpt_4(SimpleService): 'Используй системный промпт. Содержание файла: ' f'{"".join(text_chunks)}. Вопрос: {input_message.content}' ) - json_data = { - 'model': model_name, - 'messages': messages - } - input_tokens, output_tokens, response = self.call_openai_api(proxy=proxy, endpoint='chat/completions',json_data=json_data) + json_data = {'model': model_name, 'messages': messages} + input_tokens, output_tokens, response = self.call_openai_api( + proxy=proxy, endpoint='chat/completions', json_data=json_data + ) elif info.get('web_search', 'Отключено') != 'Отключено': system = chat_history.messages.pop(0) messages = [ - {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} + { + 'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', + 'content': msg.content, + } for msg in chat_history.messages ] messages.insert(0, {'role': 'system', 'content': system.content}) @@ -285,7 +294,9 @@ class Chatgpt_4(SimpleService): ] elif file: if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -313,7 +324,9 @@ class Chatgpt_4(SimpleService): elif file: input_tokens = self.count_text_tokens([*chat_history.messages]) if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -362,18 +375,16 @@ class Chatgpt_4(SimpleService): if output_tokens == 0: output_tokens = self.count_text_tokens([response]) - if ( - image - and normalized_image - and model_name != 'o3-mini' - ): + if image and normalized_image and model_name != 'o3-mini': self.logger.info(f'Input количество токенов БЕЗ картинки {model_name} - {input_tokens}') input_tokens += self.count_image_tokens(normalized_image.size, model_name) self.logger.info(f'Input количество токенов для {model_name} - {input_tokens}') self.logger.info(f'Output количество токенов для {model_name} - {output_tokens}') self.logger.info(f'Embedding количество токенов для {model_name} - {embedding_tokens}') - self.logger.info(f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}') + self.logger.info( + f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}' + ) process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( @@ -382,7 +393,7 @@ class Chatgpt_4(SimpleService): output_tokens, self.llm.model_name, info, - embedding_tokens + embedding_tokens, ) msgs = self.save_results([response], process_time, save) http_client.close() @@ -412,22 +423,25 @@ class Chatgpt_4(SimpleService): for message in air_messages.iterator(5): air_message = [ AIMessage(content=message.content or '') - if message.from_model else - HumanMessage(content=message.content or '') + if message.from_model + else HumanMessage(content=message.content or '') ] if self.count_text_tokens(air_message) + tokens > token_limits[model_name]: break tokens += self.count_text_tokens(air_message) history.append(air_message[0]) memory = InMemoryChatMessageHistory() - memory.add_message(SystemMessage( - content=( - 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' - 'Be thorough and expansive.\n' - f'{self.NO_FILE_GENERATION_POLICY}' + memory.add_message( + SystemMessage( + content=( + 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' + 'Be thorough and expansive.\n' + f'{self.NO_FILE_GENERATION_POLICY}' + ) ) - )) - memory.add_message(SystemMessage( + ) + memory.add_message( + SystemMessage( content=( 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' @@ -435,7 +449,8 @@ class Chatgpt_4(SimpleService): 'Не генерируй файлы и не предоставляй ссылки на скачивание файлов. ' 'Весь контент давай прямо в тексте ответа.' ) - )) + ) + ) memory.add_messages(list(reversed(history))) return memory @@ -454,8 +469,7 @@ class Chatgpt_4(SimpleService): input_cost = self.TOKENS_COST[model]['input'] * total_tokens if embedding_tokens > 0: input_cost += ( - embedding_tokens - * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] + embedding_tokens * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] ) output_cost = self.TOKENS_COST[model]['output'] * output_tokens if input_cost + output_cost > balance: @@ -480,10 +494,7 @@ class Chatgpt_4(SimpleService): if info.get('code_interpreter', False): price += self.TOKENS_COST[model]['code_interpreter'] if embedding_tokens > 0: - price += ( - self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] - * embedding_tokens - ) + price += self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] * embedding_tokens return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def count_image_tokens(self, image_size: tuple, model_version: str = 'gpt-4o') -> int: @@ -591,19 +602,12 @@ class Chatgpt_4(SimpleService): headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, timeout=600, ) as client: - resp = client.post( - endpoint, - json=json_data - ) + resp = client.post(endpoint, json=json_data) if ( endpoint == 'chat/completions' and (data := resp.json()) and data.get('choices') - and ( - content := ','.join( - [choice['message']['content'] for choice in data.get('choices')] - ) - ) + and (content := ','.join([choice['message']['content'] for choice in data.get('choices')])) ): input_tokens = resp.json()['usage']['prompt_tokens'] output_tokens = resp.json()['usage']['completion_tokens'] @@ -819,21 +823,6 @@ class Chatgpt_4(SimpleService): output_tokens = self.count_text_tokens([response]) return input_tokens, output_tokens, response - @staticmethod - def run_serper(query: str, **kwargs): - headers = { - 'X-API-KEY': settings.SERPER_API_KEY, - 'Content-Type': 'application/json', - } - params = { - 'q': query, - **{key: value for key, value in kwargs.items() if value is not None}, - } - response = httpx.post('https://google.serper.dev/search', headers=headers, params=params) - response.raise_for_status() - search_results = response.json() - return search_results - @staticmethod def serper_to_openai_context(serp: dict, max_sources: int = 3) -> str: query = (serp.get('searchParameters') or {}).get('q', '').strip() @@ -0,0 +1,119 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO +from typing import Any + +import requests +from django.core.files import File +from django.utils.translation import gettext as _ + +from messages.models import Message +from ml_model.exceptions import InvalidParameterError +from ml_model.services.FileService import ( + ImageFileProcessor, + MediaFileProcessor, + MediaFileValidator, + VideoFileProcessor, + VideoFileValidator, +) +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Flux_3(SimpleService): + MAX_START_VIDEO_SECONDS = 15 + TOKENS_COST = { + 't2v_i2v': { + '720p': Decimal('51'), # $0.17 / sec + '1080p': Decimal('87'), # $0.29 / sec + 'draft': Decimal('18'), # $0.06 / sec + }, + 'v2v': { + '720p': Decimal('123'), # $0.41 / sec + '1080p': Decimal('159'), # $0.53 / sec + 'draft': Decimal('36'), # $0.12 / sec + }, + } + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + if file_exists: + return None + resolution = info.get('resolution', '720p') + duration = int(info.get('duration', 5)) + draft = bool(info.get('draft', False)) + if resolution not in ('720p', '1080p'): + return None + if draft and resolution != '720p': + return None + key = 'draft' if draft else resolution + return (cls.TOKENS_COST['t2v_i2v'][key] * duration).quantize(Decimal('0.1'), rounding='ROUND_UP') + + def calculate_price(self, variant: str, resolution: str, duration: int, draft: bool) -> Decimal: + key = 'draft' if draft else resolution + return (self.TOKENS_COST[variant][key] * duration).quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + resolution = input_message.info.pop('resolution', '720p') + duration = int(input_message.info.pop('duration', 5)) + draft = bool(input_message.info.get('draft', False)) + input_message.info.pop('safety_tolerance', None) + if draft and resolution != '720p': + raise InvalidParameterError(_('Draft mode is only available at 720p')) + variant = 't2v_i2v' + callback_data = dict( + { + 'prompt': input_message.content, + 'resolution': resolution, + 'duration': str(duration), + **input_message.info, + 'safety_tolerance': 2, + } + ) + if file := input_message.file: + processor = MediaFileProcessor(file) + kind = processor.get_kind(processor.get_bytes(50)) + allowed_extensions = ImageFileProcessor.EXTENSIONS + VideoFileProcessor.EXTENSIONS + MediaFileValidator.validate_kind(kind, allowed_extensions) + if kind.extension.upper() in VideoFileProcessor.EXTENSIONS: + video_processor = VideoFileProcessor(file) + VideoFileValidator.validate_duration( + video_processor.get_duration(), + max_seconds=self.MAX_START_VIDEO_SECONDS, + ) + callback_data.update({'start_video': file.url}) + variant = 'v2v' + else: + callback_data.update({'images': [file.url]}) + file.close() + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + predicted := self.calculate_price(variant, resolution, duration, draft) + ): + raise InsufficientBalance(balance, predicted) + start_time = time.time() + video = replicate_run('black-forest-labs/flux-3', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice( + input_message.content_object.model, + variant=variant, + resolution=resolution, + duration=duration, + draft=draft, + ) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -13,7 +13,12 @@ from django.utils.translation import gettext from messages.models import Message from messages.services.message_service import MessageService from ml_model.adapters.openrouter import OpenrouterAdapter -from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, PaidPlanRequiredError +from ml_model.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + PaidPlanRequiredError, + ModelVersionNotAvailable, +) from ml_model.services.base import StreamSimpleService from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService @@ -67,16 +72,21 @@ class Grok(SerperMixin, StreamSimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version') or 'grok-4.5' + 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) + callback_data = {**input_message.info, 'tools': []} - messages, embedding_tokens = self._prepare_messages(input_message, version, callback_data) + messages, embedding_tokens = self._prepare_messages(input_message, version_slug, callback_data) - result = OpenrouterAdapter.collect_streaming_api(f'x-ai/{version}', messages, callback_data, 'Grok') + result = OpenrouterAdapter.collect_streaming_api( + f'x-ai/{version_slug}', messages, callback_data, 'Grok' + ) process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, - version=version, + version=version_slug, cost=result.cost, input_tokens=result.input_tokens, output_tokens=result.output_tokens, @@ -86,9 +96,11 @@ class Grok(SerperMixin, StreamSimpleService): def make_stream(self, input_message: Message, save: bool = True) -> Iterator[RawSSEChunk]: start_time = time.time() - version = input_message.info.get('version') or 'grok-4.5' + 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) callback_data = {**input_message.info, 'tools': []} - messages, embedding_tokens = self._prepare_messages(input_message, version, callback_data) + messages, embedding_tokens = self._prepare_messages(input_message, version_slug, callback_data) input_tokens = output_tokens = 0 cost = 0 reasoning = '' @@ -96,7 +108,9 @@ class Grok(SerperMixin, StreamSimpleService): result = '' try: - stream = OpenrouterAdapter.run_streaming_api(f'x-ai/{version}', messages, callback_data, 'Grok') + stream = OpenrouterAdapter.run_streaming_api( + f'x-ai/{version_slug}', messages, callback_data, 'Grok' + ) try: while True: chunk = next(stream) @@ -114,7 +128,7 @@ class Grok(SerperMixin, StreamSimpleService): process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, - version=version, + version=version_slug, cost=cost, input_tokens=input_tokens, output_tokens=output_tokens, @@ -194,10 +208,7 @@ class Grok(SerperMixin, StreamSimpleService): if (chunks_length := sum(len(chunk) for chunk in chunks)) > 20_000: predict_embedding_tokens = len(chunks) * 2020 predicted_input_price += ( - ( - Decimal('210') - + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10') - ) + (Decimal('210') + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10')) / Decimal('2.0') * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') @@ -251,21 +262,14 @@ class Grok(SerperMixin, StreamSimpleService): if image: predicted_image_tokens = min((image_width * image_height + 999) // 1000, 2500) predicted_input_price += ( - Decimal(predicted_image_tokens) - * self.TOKENS_COST[version]['input'] - / Decimal('1_000_000') + Decimal(predicted_image_tokens) * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') ) - estimated_input_tokens = ( - Decimal( - sum( - len(message['content']) if isinstance(message['content'], str) else 0 - for message in messages - ) - + (len(input_message.content) if image else 0) + estimated_input_tokens = Decimal( + sum( + len(message['content']) if isinstance(message['content'], str) else 0 for message in messages ) - / Decimal('2.0') - + (150 if is_free_plan else 250) - ) + + (len(input_message.content) if image else 0) + ) / Decimal('2.0') + (150 if is_free_plan else 250) predicted_input_price += ( estimated_input_tokens * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') + predict_embedding_tokens * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] @@ -1,17 +1,15 @@ -import base64 import time from datetime import timedelta from decimal import Decimal from io import BytesIO from typing import Any -import filetype import requests from django.core.files import File - from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException, FileNotProvided +from ml_model.exceptions import FileNotProvided +from ml_model.services.FileService import ImageFileProcessor, ImageFileValidator from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -47,17 +45,24 @@ class Kling(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: mode = input_message.info.pop('mode', 'standard') duration = input_message.info.get('duration', 5) - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.TOKENS_COST[mode] * duration): + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.TOKENS_COST[mode] * duration + ): raise InsufficientBalance(balance, cost) if not input_message.file: raise FileNotProvided('Image') - callback_data = dict({'prompt': self.translate_prompt(input_message.content), 'mode': mode, **input_message.info}) - kind = filetype.guess(input_message.file.read(20)) - mime = kind.mime if kind else 'application/octet-stream' - input_message.file.seek(0) - image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + callback_data = dict( + {'prompt': self.translate_prompt(input_message.content), 'mode': mode, **input_message.info} + ) + + image_processor = ImageFileProcessor(input_message.file) + kind = image_processor.get_kind(image_processor.get_bytes(20)) + ImageFileValidator.validate_kind(kind, image_processor.EXTENSIONS) + width, height = image_processor.get_dimensions() + ImageFileValidator.validate_dimensions(width, height, max_pixels=36_000_000) + ImageFileValidator.validate_aspect_ratio(width, height, min_ratio=0.40, max_ratio=2.50) input_message.file.close() - callback_data.update({'start_image': image}) + callback_data.update({'start_image': input_message.file.url}) start_time = time.time() video = replicate_run('kwaivgi/kling-v2.1', callback_data) process_time = timedelta(seconds=(time.time() - start_time)) @@ -12,7 +12,7 @@ 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.FileService import ImageFileProcessor, ImageFileValidator from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -94,11 +94,12 @@ 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() + image_processor = ImageFileProcessor(image) + kind = image_processor.get_kind(image_processor.get_bytes(20)) + ImageFileValidator.validate_kind(kind, image_processor.EXTENSIONS) + width, height = image_processor.get_dimensions() + ImageFileValidator.validate_dimensions(width, height, max_pixels=36000000) + image.close() callback_data.update({'image': image.url}) images = bytedance_model_ark_run( @@ -3,17 +3,24 @@ from django.conf import settings class SerperMixin: - @staticmethod - def run_serper(query: str, **kwargs): + MAX_SERPER_QUERY_LEN = 1000 + + @classmethod + def run_serper(cls, query: str, **kwargs): + query = (query or '').strip()[: cls.MAX_SERPER_QUERY_LEN] + headers = { 'X-API-KEY': settings.SERPER_API_KEY, 'Content-Type': 'application/json', } - params = { - 'q': query, - **{key: value for key, value in kwargs.items() if value is not None}, - } - response = httpx.post('https://google.serper.dev/search', headers=headers, params=params) + + payload = {'q': query, **{k: v for k, v in kwargs.items() if v is not None}} + + response = httpx.post( + 'https://google.serper.dev/search', + headers=headers, + json=payload, + ) response.raise_for_status() search_results = response.json() return search_results @@ -0,0 +1,66 @@ +# Generated by makemigration_payment_features on 2026-08-13 18:42 + +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') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + + price = Decimal('90') + measurement_unit = 'file' + price_threshold = 0 + 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 + + max_order_by_plan_id = { + row['plan_id']: row['max_order'] + for row in PaymentPlanFeature.objects.filter(model__category=category) + .values('plan_id') + .annotate(max_order=Max('order')) + } + + features = [] + for plan in PaymentPlan.objects.filter(price__gt=price_threshold): + quantity = math.floor(plan.tokens_per_plan / price) + max_order = max_order_by_plan_id.get(plan.pk) + next_order = (max_order if max_order is not None else -1) + 1 + max_order_by_plan_id[plan.pk] = next_order + features.append( + PaymentPlanFeature( + plan=plan, + model=model, + quantity=quantity, + measurement_unit=measurement_unit, + order=next_order, + ) + ) + PaymentPlanFeature.objects.bulk_create( + features, + update_conflicts=True, + update_fields=['quantity', 'measurement_unit'], + unique_fields=['plan', 'model'], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0035_paymentplanuserinfo_recovery_fields'), + ] + + operations = [ + migrations.RunPython(add_flux_3_payment_features, migrations.RunPython.noop), + ] @@ -14,7 +14,24 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer from ml_model.choices import ContentTypes -from ml_model.exceptions import FileNotProvided, InvalidParameterError +from ml_model.exceptions import ( + CorruptedFileError, + ExceededContextLengthError, + FileExtensionNotSupported, + FileNotProvided, + FileTooLargeError, + FileUploadUnsupported, + ImageAnalysisError, + ImageTooLargeError, + InputImageSensitiveContentError, + InvalidParameterError, + ModelVersionNotAvailable, + OutputSensitiveImageContentError, + PaidPlanRequiredError, + PromptLengthExceeded, + RequestBlocked, + UnrecognizedFileError, +) from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.serializers import PublicNeuronModelSerializer @@ -94,13 +111,31 @@ class BaseGenerationView(APIView): # WARNING: output должен быть списком! try: output_message = service(store).make(input_message) + except PaidPlanRequiredError as exc: + return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) + except ( + FileExtensionNotSupported, + ExceededContextLengthError, + RequestBlocked, + PromptLengthExceeded, + CorruptedFileError, + FileTooLargeError, + ImageAnalysisError, + FileUploadUnsupported, + UnrecognizedFileError, + InvalidParameterError, + ModelVersionNotAvailable, + InputImageSensitiveContentError, + OutputSensitiveImageContentError, + ImageTooLargeError, + ValidationError, + ) as exc: + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) except Exception as exc: input_message.is_sent = False input_message.save() if isinstance(exc, InsufficientBalance): return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) - elif isinstance(exc, (InvalidParameterError, ValidationError)): - return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) logger.exception(exc) return Response( {