@@ -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,53 +124,158 @@ 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'] + + _PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n' + _PNG_STRIPPED_CHUNKS = { + b'bKGD', + b'cHRM', + b'gAMA', + b'hIST', + b'iCCP', + b'iTXt', + b'pHYs', + b'sBIT', + b'sPLT', + b'sRGB', + b'tEXt', + b'tIME', + b'zTXt', + } + + @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: - normalized_image = BytesIO(file_bytes) - with Image.open(normalized_image) as source_image: + @staticmethod + def get_normalized_image(file_bytes: bytes) -> BytesIO: + with Image.open(BytesIO(file_bytes)) 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 + + @staticmethod + def strip_png_metadata(data: bytes) -> bytes: + if not data.startswith(ImageFileProcessor._PNG_SIGNATURE): + return data + + result = bytearray(ImageFileProcessor._PNG_SIGNATURE) + position = len(ImageFileProcessor._PNG_SIGNATURE) + + while position < len(data): + length = int.from_bytes(data[position : position + 4], 'big') + chunk_type = data[position + 4 : position + 8] + chunk_end = position + 12 + length + + if chunk_end > len(data): + return data + + if chunk_type not in ImageFileProcessor._PNG_STRIPPED_CHUNKS: + result.extend(data[position:chunk_end]) + + position = chunk_end + + if chunk_type == b'IEND': + break + + return bytes(result) + + +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 @@ -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'), @@ -493,7 +493,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']: @@ -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 @@ -67,18 +68,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')}, @@ -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: @@ -566,7 +577,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'}, } @@ -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() @@ -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 }, @@ -239,7 +239,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'), }, @@ -227,7 +227,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}, @@ -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 @@ -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}) @@ -79,8 +79,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) @@ -103,8 +103,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 @@ -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}) @@ -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'] @@ -53,8 +53,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}) @@ -0,0 +1,74 @@ +from io import BytesIO +from pathlib import PurePosixPath + +from PIL import Image, UnidentifiedImageError +from django.core.files import File +from django.core.files.storage import Storage +from django.db.models.fields.files import FieldFile + +from ml_model.exceptions import CorruptedFileError +from ml_model.services.FileService import ImageFileProcessor + + +class ImageInputService: + @staticmethod + def prepare_for_replicate(image: FieldFile) -> tuple[str, tuple[Storage, str] | None]: + suffix = PurePosixPath(image.name).suffix.lower() + match suffix: + case '.png': + return ImageInputService._prepare_png(image) + case '.jfif': + return ImageInputService._prepare_jfif(image) + case _: + return image.url, None + + @staticmethod + def _prepare_jfif(image: FieldFile) -> tuple[str, tuple[Storage, str] | None]: + image.open('rb') + image.seek(0) + jpeg_name = str(PurePosixPath(image.name).with_suffix('.jpg')) + try: + saved_name = image.storage.save(jpeg_name, image) + finally: + image.close() + + try: + image_url = image.storage.url(saved_name) + except Exception: + image.storage.delete(saved_name) + + raise + + return image_url, (image.storage, saved_name) + + @staticmethod + def _prepare_png( + image: FieldFile, + ) -> tuple[str, tuple[Storage, str] | None]: + image.open('rb') + image.seek(0) + file_bytes = image.read() + image.close() + + cleaned = ImageFileProcessor.strip_png_metadata(file_bytes) + + try: + with Image.open(BytesIO(cleaned)) as png: + png.load() + except (UnidentifiedImageError, OSError, SyntaxError) as exc: + raise CorruptedFileError from exc + + if cleaned == file_bytes: + return image.url, None + + path = PurePosixPath(image.name) + png_name = str(path.with_stem(f'{path.stem}_clean')) + + saved_name = image.storage.save(png_name, File(BytesIO(cleaned), name=PurePosixPath(png_name).name)) + + try: + return image.storage.url(saved_name), (image.storage, saved_name) + except Exception: + image.storage.delete(saved_name) + raise + @@ -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)) @@ -66,7 +66,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 @@ -69,7 +69,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,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, @@ -58,7 +58,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, @@ -26,8 +26,8 @@ class Qwen_3_7(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.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 @@ -38,7 +38,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'), @@ -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 @@ -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,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 @@ -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 @@ -0,0 +1,115 @@ +from io import BytesIO + +from django.test import SimpleTestCase +from PIL import Image, PngImagePlugin +from PIL.PngImagePlugin import PngInfo + +from ml_model.services.FileService import ImageFileProcessor + + +class ImageFileProcessorTests(SimpleTestCase): + def test_strip_png_metadata_removes_text_chunks(self) -> None: + png_bytes = self._png_with_metadata( + zipped='x' * (PngImagePlugin.MAX_TEXT_CHUNK + 1), + plain='hello', + international='hello', + ) + + self.assertIn(b'zTXt', png_bytes) + self.assertIn(b'tEXt', png_bytes) + self.assertIn(b'iTXt', png_bytes) + + old_max_text_chunk = PngImagePlugin.MAX_TEXT_CHUNK + old_max_text_memory = PngImagePlugin.MAX_TEXT_MEMORY + + stripped = ImageFileProcessor.strip_png_metadata(png_bytes) + + self.assertEqual( + PngImagePlugin.MAX_TEXT_CHUNK, + old_max_text_chunk, + ) + self.assertEqual( + PngImagePlugin.MAX_TEXT_MEMORY, + old_max_text_memory, + ) + + self.assertNotEqual(stripped, png_bytes) + + self.assertNotIn(b'zTXt', stripped) + self.assertNotIn(b'tEXt', stripped) + self.assertNotIn(b'iTXt', stripped) + + with Image.open(BytesIO(stripped)) as image: + image.load() + + self.assertEqual(image.size, (1, 1)) + self.assertEqual(image.getpixel((0, 0)), (255, 0, 0)) + self.assertFalse(image.text) + + def test_strip_png_metadata_without_metadata_is_noop(self) -> None: + png_bytes = self._png() + + stripped = ImageFileProcessor.strip_png_metadata(png_bytes) + + self.assertEqual(stripped, png_bytes) + + def test_strip_png_metadata_leaves_non_png_unchanged(self) -> None: + data = b'not-a-png' + + stripped = ImageFileProcessor.strip_png_metadata(data) + + self.assertEqual(stripped, data) + + def test_strip_png_metadata_preserves_image(self) -> None: + png_bytes = self._png_with_metadata(plain='hello') + + stripped = ImageFileProcessor.strip_png_metadata(png_bytes) + + self.assertIn(b'IHDR', stripped) + self.assertIn(b'IDAT', stripped) + self.assertIn(b'IEND', stripped) + + with Image.open(BytesIO(stripped)) as image: + image.load() + + self.assertEqual(image.size, (1, 1)) + self.assertEqual(image.getpixel((0, 0)), (255, 0, 0)) + + @staticmethod + def _png() -> bytes: + buffer = BytesIO() + + Image.new('RGB', (1, 1), color='red').save( + buffer, + format='PNG', + ) + + return buffer.getvalue() + + @staticmethod + def _png_with_metadata( + *, + zipped: str | None = None, + plain: str | None = None, + international: str | None = None, + ) -> bytes: + info = PngInfo() + + if zipped is not None: + info.add_text('Comment', zipped, zip=True) + + if plain is not None: + info.add_text('Author', plain) + + if international is not None: + info.add_itxt('Description', international) + + buffer = BytesIO() + + Image.new('RGB', (1, 1), color='red').save( + buffer, + format='PNG', + pnginfo=info, + ) + + return buffer.getvalue() @@ -6,7 +6,6 @@ import time # import uuid from io import BytesIO -from pathlib import PurePosixPath from typing import IO, Any, Dict import deepl @@ -16,7 +15,6 @@ import replicate import requests from celery import shared_task from deepl.translator import TextResult -from django.core.files.storage import Storage from django.db.models.fields.files import FieldFile from django.utils.translation import gettext as _ from replicate.exceptions import ModelError @@ -42,6 +40,7 @@ from ml_model.exceptions import ( RequestBlocked, ServiceHighDemandError, ) +from ml_model.services.image_input_service import ImageInputService from poller.models import Proxy logger = logging.getLogger(__name__) @@ -120,36 +119,13 @@ def transcript_audio(payload: dict[str, Any]): ) -def _prepare_replicate_image(image: FieldFile) -> tuple[str, tuple[Storage, str] | None]: - if PurePosixPath(image.name).suffix.lower() != '.jfif': - return image.url, None - - # JFIF already contains JPEG data, so copy it without decoding or re-encoding. - image.open('rb') - image.seek(0) - jpeg_name = str(PurePosixPath(image.name).with_suffix('.jpg')) - try: - saved_name = image.storage.save(jpeg_name, image) - finally: - image.close() - - try: - image_url = image.storage.url(saved_name) - except Exception: - image.storage.delete(saved_name) - - raise - - return image_url, (image.storage, saved_name) - - @shared_task def replicate_run(callback_url: str, payload: dict[str, Any]): replicate_client = replicate.Client(settings.REPLICATE_API_KEY) temporary_file = None try: if isinstance(image := payload.get('image'), FieldFile): - payload['image'], temporary_file = _prepare_replicate_image(image) + payload['image'], temporary_file = ImageInputService.prepare_for_replicate(image) return replicate_client.run( ref=callback_url, @@ -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( {