@@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-14 12:31+0300\n" +"POT-Creation-Date: 2026-05-14 22:35+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -624,7 +624,7 @@ msgstr "" msgid "A %(model)s with fields %(fields)s already exists" msgstr "Уже существует %(model)s с полями %(fields)s" -#: messages/serializers.py:44 ml_model/exceptions.py:78 +#: messages/serializers.py:44 ml_model/exceptions.py:86 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" msgstr "Файл не может быть размером больше %(max_mb_size)d мегабайт" @@ -663,11 +663,16 @@ msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " "%(rw)sx%(rh)s" -#: ml_model/exceptions.py:45 +#: ml_model/exceptions.py:47 +#, python-format +msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." +msgstr "Размер изображения превышает максимально допустимое количество пикселей (%(max_pixels)d)." + +#: ml_model/exceptions.py:53 msgid "The model is not responding" msgstr "Модель не отвечает" -#: ml_model/exceptions.py:54 +#: ml_model/exceptions.py:62 #, python-format msgid "" "The attached file format is not supported. Available formats: " @@ -676,79 +681,79 @@ msgstr "" "Формат вложенного файла не поддерживается. Доступные форматы: " "%(available_extensions)s." -#: ml_model/exceptions.py:60 +#: ml_model/exceptions.py:68 msgid "The file may be corrupted. Please try another one." msgstr "Возможно, файл повреждён. Попробуйте загрузить другой файл." -#: ml_model/exceptions.py:65 +#: ml_model/exceptions.py:73 msgid "File Uploading Not supported" msgstr "Загрузка файлов не поддерживается" -#: ml_model/exceptions.py:70 +#: ml_model/exceptions.py:78 msgid "Unable to recognize the file" msgstr "Не удаётся распознать файл" -#: ml_model/exceptions.py:83 +#: ml_model/exceptions.py:91 msgid "The length of the context has been exceeded." msgstr "Длина контекста превышена." -#: ml_model/exceptions.py:88 +#: ml_model/exceptions.py:96 msgid "Jinja template not found" msgstr "Jinja-шаблон не найден" -#: ml_model/exceptions.py:93 +#: ml_model/exceptions.py:101 msgid "There was an unknown error while rendering a template" msgstr "При рендеринге шаблона произошла неизвестная ошибка" -#: ml_model/exceptions.py:98 +#: ml_model/exceptions.py:106 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/exceptions.py:106 +#: ml_model/exceptions.py:114 #, python-format msgid "The %(file_type)s is not attached" msgstr "Файл (%(file_type)s) не прикреплен" -#: ml_model/exceptions.py:111 +#: ml_model/exceptions.py:119 msgid "No image content found in response. Try a different request" msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" -#: ml_model/exceptions.py:116 +#: ml_model/exceptions.py:124 msgid "" "The model could not analyze your request. Please rephrase it and try again" msgstr "" "Модель не смогла проанализировать ваш запрос. Перефразируйте его и " "попробуйте снова" -#: ml_model/exceptions.py:121 +#: ml_model/exceptions.py:129 msgid "Image analysis error. Please try another image." msgstr "Ошибка анализа изображения. Попробуйте другую картинку." -#: ml_model/exceptions.py:126 +#: ml_model/exceptions.py:134 msgid "Use style type AUTO or GENERAL when a style preset is selected" msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" -#: ml_model/exceptions.py:131 +#: ml_model/exceptions.py:139 msgid "Prediction interrupted. Please retry again" msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" -#: ml_model/exceptions.py:146 +#: ml_model/exceptions.py:154 #, python-format msgid "Prompt is too long. Maximum length is %(max_length)s characters." msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." -#: ml_model/exceptions.py:153 +#: ml_model/exceptions.py:161 msgid "" "Service is currently unavailable due to high demand. Please try again later" msgstr "" "Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " "позже" -#: ml_model/exceptions.py:158 +#: ml_model/exceptions.py:166 msgid "Available only in paid plan" msgstr "Доступно только в платном тарифе" -#: ml_model/exceptions.py:163 +#: ml_model/exceptions.py:171 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." @@ -1047,7 +1052,7 @@ msgstr "Не найдено моделей по этому ID" msgid "No matching version found" msgstr "Соответствующая версия не найдена" -#: ml_model/services/chatgpt_5_4.py:269 +#: ml_model/services/chatgpt_5_4.py:270 msgid "Image is ready" msgstr "Изображение готово" @@ -1086,7 +1091,7 @@ msgstr "3К разрешение не поддерживается для Seedre msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:137 +#: ml_model/tasks.py:188 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1104,7 +1109,7 @@ msgstr "" msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:11 payments/models/payment.py:60 +#: payments/apps.py:12 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" @@ -1,14 +1,18 @@ import base64 +import json import time from datetime import timedelta from decimal import Decimal from io import BytesIO +from pathlib import Path import filetype +import httpx from django.core.files import File from django.utils.translation import gettext_lazy -from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, BaseMessage +from backend import settings from messages.models import Message from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError, PaidPlanRequiredError from ml_model.models import NeuronModel @@ -32,8 +36,8 @@ class Chatgpt_5_4(Chatgpt): 'generated_image': Decimal('10.2'), }, 'gpt-5.4-pro': { - 'input': Decimal('0.015'), - 'output': Decimal('0.09'), + 'input': Decimal('0.0075'), + 'output': Decimal('0.045'), 'web_search': { 'low': Decimal('5'), # 1 call 'medium': Decimal('5'), # 1 call @@ -48,6 +52,10 @@ class Chatgpt_5_4(Chatgpt): 'gpt-5.4-pro': 1_050_000 // 2, } + API_MODEL_ALIASES = { + 'gpt-5.4-pro': 'gpt-5.5', + } + @property def neuron_model(self): return NeuronModel.objects.get(slug='chatgpt_5_4') @@ -87,7 +95,7 @@ class Chatgpt_5_4(Chatgpt): input_tokens * self.TOKENS_COST[model]['input'] + output_tokens * self.TOKENS_COST[model]['output'] ) - if info.get('web_search', 'Отключено') != 'Отключено': + if info.get('web_search', 'Выключен') != 'Выключен': price += self.TOKENS_COST[model]['web_search'].get(info.get('web_search', 'medium')) if info.get('code_interpreter', False): price += self.TOKENS_COST[model]['code_interpreter'] @@ -123,10 +131,13 @@ class Chatgpt_5_4(Chatgpt): chunks = [] text_chunks = [] if file: + supported_extensions = ['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP'] file_service = FileProcessingService file_bytes = input_message.file.read() kind = filetype.guess(file_bytes[:550]) if not kind: + if Path(input_message.file.name).suffix[1:].upper() not in supported_extensions: + raise FileExtensionNotSupported(supported_extensions) raise CorruptedFileError raw_file_extension = kind.extension file_extension = file_service.get_file_extension(raw_file_extension, file_bytes) @@ -138,7 +149,7 @@ class Chatgpt_5_4(Chatgpt): image = file _, image_size, image_data = self._get_image_data(file_bytes, file_extension) else: - raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP']) + raise FileExtensionNotSupported(supported_extensions) chat_history = self.get_chat_history(model_name=model_name) chat_history.add_message(HumanMessage(content=input_message.content)) llm_input = [SystemMessage(content=user_system_prompt), HumanMessage(content=input_content)] @@ -159,6 +170,41 @@ class Chatgpt_5_4(Chatgpt): for msg in chat_history.messages ] messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': ''' + You are a senior analytical assistant optimized for GPT-5.4 Pro, with strong emphasis on factual accuracy, grounded reasoning, and high-quality web retrieval. + Your goal is to produce correct, evidence-based, and practical answers using reasoning, provided context, and web search when necessary. + Core principles: + - Prioritize correctness and evidence over speed or completeness + - Never fabricate facts, sources, statistics, or citations + - Clearly separate: facts, assumptions, and interpretations + - Prefer primary, official, and high-authority sources over secondary or SEO content + - If evidence is weak or conflicting, explicitly state uncertainty instead of guessing + Web retrieval behavior: + - Use web search only when it materially improves accuracy, freshness, or completeness + - Prefer fewer, higher-quality sources over many low-quality ones + - Actively filter out SEO content, reposts, unverified blogs, and low-authority pages + - Cross-check important facts across multiple reliable sources when possible + - Stop searching once sufficient high-confidence evidence is collected (do not over-search) + Anti-hallucination rules: + - Never guess missing facts + - Never infer specific numbers, dates, names, or capabilities without evidence + - If sources conflict, explicitly report the conflict and prefer the most authoritative source + - If no reliable evidence is found, say so clearly instead of filling gaps + Reasoning behavior: + - Use internal structured reasoning, but respond in a concise and direct way + - Focus on outcome and actionable insight, not process explanation + - Avoid over-analysis once the answer is sufficiently supported + Source selection priority: + 1. Official documentation, standards, and primary publications + 2. Reputable technical or academic sources + 3. Established industry publications + 4. Secondary summaries only if no better sources exist (clearly labeled as such) + Output rules: + - Be high-signal, concise, and structured only when it improves clarity + - Do not include unsupported claims or speculative additions + - Clearly label uncertainty when applicable + - Stop reasoning immediately once the answer is sufficiently supported by evidence + '''}) messages.insert(0, {'role': 'system', 'content': user_system_prompt}) if image: messages[-1]['content'] = [ @@ -198,8 +244,9 @@ class Chatgpt_5_4(Chatgpt): 'model': 'gpt-image-1.5', } ) + api_model_name = self.API_MODEL_ALIASES.get(model_name, model_name) json_data = { - 'model': model_name, + 'model': api_model_name, 'input': messages, 'tools': tools, 'instructions': ( @@ -226,32 +273,26 @@ class Chatgpt_5_4(Chatgpt): json_data['reasoning'] = {'effort': 'none', 'summary': 'auto'} elif reasoning := info.get('reasoning'): reasoning_data = { - 'Минимальный': 'minimal', - 'Низкий': 'low', - 'Средний': 'medium', - 'Высокий': 'high', - 'Сверхвысокий': 'xhigh', + 'Средний': 'low', + 'Высокий': 'medium', } json_data['reasoning'] = {'effort': reasoning_data[reasoning], 'summary': 'auto'} - if reasoning == 'Минимальный': - info.pop('web_search', None) - info.pop('code_interpreter', None) - if model_name == 'gpt-5.4' and (verbosity := info.get('verbosity', 'Отключено')) != 'Отключено': - verbosity_data = { - 'Низкий': 'low', - 'Средний': 'medium', - 'Высокий': 'high', - } - json_data['text'] = {'verbosity': verbosity_data[verbosity]} - if (web_search := info.get('web_search', 'Отключено')) != 'Отключено': - search_context_sizes = { - 'Малый контекст': 'low', - 'Средний контекст': 'medium', - 'Большой контекст': 'high', + web_search = info.get('web_search', 'Выключен') + verbosity = info.get('verbosity', False) + if verbosity: + json_data['text'] = { + 'verbosity': 'none' if web_search == 'Средний' else 'low', } + search_context_sizes = { + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'medium', + 'Сверхвысокий': 'medium', + } + if web_search != 'Выключен': json_data['tools'].append( { - 'type': 'web_search_preview', + 'type': 'web_search', 'search_context_size': search_context_sizes[web_search], 'user_location': {'type': 'approximate', 'country': 'RU'}, } @@ -260,8 +301,10 @@ class Chatgpt_5_4(Chatgpt): if info.get('code_interpreter') and model_name == 'gpt-5.4': json_data['tools'].append({'type': 'code_interpreter', 'container': {'type': 'auto'}}) messages[-1]['content'] += ' the python tool ' - input_tokens, output_tokens, response = self.call_openai_api( - proxy=proxy, endpoint='responses', json_data=json_data + input_tokens, output_tokens, response = self._stream_openai_responses( + proxy=proxy, + json_data=json_data, + model_name=model_name, ) generated_image = None if isinstance(response.content, list): @@ -285,3 +328,60 @@ class Chatgpt_5_4(Chatgpt): ) msgs = self.save_results([response], process_time, generated_image, save) return msgs + + def _stream_openai_responses( + self, + proxy: Proxy, + json_data: dict, + model_name: str, + ) -> tuple[int, int, AIMessage]: + payload = {**json_data, 'stream': True} + text_parts: list[str] = [] + image_b64: str | None = None + input_tokens = 0 + output_tokens = 0 + try: + with httpx.Client( + base_url='https://api.openai.com/v1', + proxy=f'{proxy.protocol}://{proxy.address}', + headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, + timeout=httpx.Timeout(connect=30, read=600, write=60, pool=30), + ) as client: + with client.stream('POST', 'responses', json=payload) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if not line or not line.startswith('data: '): + continue + data = line[6:] + if data == '[DONE]': + break + try: + event = json.loads(data) + except ValueError: + continue + event_type = event.get('type') + if event_type == 'response.output_text.delta': + text_parts.append(event.get('delta', '')) + elif event_type == 'response.completed': + response_obj = event.get('response') or {} + usage = response_obj.get('usage') or {} + input_tokens = usage.get('input_tokens', input_tokens) + output_tokens = usage.get('output_tokens', output_tokens) + for item in response_obj.get('output') or []: + if item.get('result'): + image_b64 = item['result'] + except Exception as exc: + self.logger.exception( + f'{model_name} stream прерван ({exc!r}); сохраняем накопленный ответ' + ) + + content = ''.join(text_parts).replace('\\n', '\n') + if not content and not image_b64: + raise Exception('GPT not answer correctly, please retry later') + if image_b64 and not content: + response = AIMessage(content=[{'generate_image': True, 'image': image_b64}]) + else: + response = AIMessage(content=content) + if output_tokens == 0 and isinstance(response.content, str): + output_tokens = self.count_text_tokens([response]) + return input_tokens, output_tokens, response @@ -27,6 +27,11 @@ class Flux(SimpleService): }, } + OPTIMIZATION_PROMPT = """ + Clean composition with clear subject hierarchy. Soft natural lighting, accurate proportions, and coherent geometry. + Sharp key details with minimal visual noise and artifacts. + """ + def calculate_price(self, input_message: Message, version: str) -> Decimal: price_map = self.TOKENS_COST[version] price = price_map['input_imgs'] @@ -70,17 +75,9 @@ class Flux(SimpleService): start_time = time.time() version = input_message.info.get('version') user_prompt = self.translate_prompt(input_message.content) - prompt = f""" - {user_prompt}. - Clear main subject and coherent scene context. - Balanced composition with readable foreground, midground, and background. - Natural, consistent lighting with clean shadows and good tonal balance. - Accurate proportions, clean geometry, and stable spatial relationships. - Crisp important details, clear textures, and minimal visual artifacts. - """ callback_data = dict( { - 'prompt': prompt, + 'prompt': f"{user_prompt}\n{self.OPTIMIZATION_PROMPT}", 'go_fast': False, 'output_quality': 100, **input_message.info, @@ -19,6 +19,10 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Fluxpulid(SimpleService): TOKEN_COST = Decimal('15.0') ENDPOINT = 'bytedance/flux-pulid:8baa7ef2255075b46f4d91cd238c21d31181b3e6a864463f967960bb0112525b' + OPTIMIZATION_PROMPT = """ + Faithful identity match with stable face anatomy. + Soft contrast, clear expression, and realistic skin detail. Artifact-free render and coherent composition. + """ @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: @@ -61,7 +65,7 @@ class Fluxpulid(SimpleService): translated_prompt = self.translate_prompt(input_message.content) callback_data = dict( { - 'prompt': translated_prompt, + 'prompt': f"{translated_prompt}\n{self.OPTIMIZATION_PROMPT}", 'main_face_image': BytesIO(input_message.file.read()), 'output_quality': 100, 'output_format': 'png', @@ -9,6 +9,7 @@ from io import BytesIO import filetype import httpx import tiktoken +from PIL import Image from django.core.files import File from django.core.files.images import get_image_dimensions @@ -52,6 +53,12 @@ class Gptimage(SimpleService): '1024x1536': 1372, }, } + + OPTIMIZATION_PROMPT = """ + Clean composition and consistent perspective. Natural color harmony, realistic texture detail, and sharp subject focus. + Low visual noise with reliable final quality. + """ + def __init__(self, store: BaseStore) -> None: super().__init__(store) self.logger = logging.getLogger(self.__class__.__name__) @@ -95,7 +102,13 @@ class Gptimage(SimpleService): w, h = get_image_dimensions(file) if not w or not h: raise CorruptedFileError - files = {'image': ('image.png', BytesIO(file_bytes), 'image/png')} + normalized_image = BytesIO(file_bytes) + if kind.extension.upper() != 'PNG': + img = Image.open(normalized_image).convert('RGBA') + normalized_image = BytesIO() + img.save(normalized_image, format='PNG') + normalized_image.seek(0) + files = {'image': ('image.png', normalized_image, 'image/png')} if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.calculate_price( *self.count_predict_tokens(input_message.content, w, h, size, quality) @@ -106,7 +119,7 @@ class Gptimage(SimpleService): for proxy in Proxy.objects.all(): moderation = 'low' if self.store.user.account_type == 'regular' else 'auto' json_data = { - 'prompt': input_message.content, + 'prompt': f"{input_message.content}\n{self.OPTIMIZATION_PROMPT}", 'model': 'gpt-image-2', 'n': 1, 'quality': quality, @@ -4,10 +4,19 @@ from datetime import timedelta from decimal import Decimal from io import BytesIO from typing import Any, Dict, Iterator +from pathlib import Path import filetype from django.db.models.fields.files import FieldFile from PIL import Image +from ml_model.services.FileService import FileProcessingService +from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError +from ml_model.services.EmbeddingService import EmbeddingService +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +from poller.models import Proxy from messages.models import Message from ml_model.services.base import SimpleService @@ -35,8 +44,13 @@ class Grok(SimpleService): }, # 1M tokens } + TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} + + SUPPORTED_EXTENSIONS = ['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP'] + + def calculate_price( - self, version: str, input_tokens: int, output_tokens: int, image: FieldFile + self, version: str, input_tokens: int, output_tokens: int, image: FieldFile, embedding_tokens: int ) -> Decimal: price_map = self.TOKENS_COST[version.split('/')[1]] price = ( @@ -44,6 +58,8 @@ class Grok(SimpleService): ) if image: price += price_map['input_imgs'] / 1_000 + if embedding_tokens: + price += embedding_tokens * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def save_results(self, content: Iterator[Any], t: timedelta, save: bool = True) -> list[Message]: @@ -64,31 +80,89 @@ class Grok(SimpleService): callback_data = {**input_message.info} messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) - image = input_message.file - if image: - kind = filetype.guess(image.read(20)) - mime = kind.mime if kind else 'application/octet-stream' - normalized_image = Image.open(image) - format = 'jpeg' if kind.extension == 'jpg' else kind.extension - buf = BytesIO() - normalized_image.save(buf, format=format) - image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' - buf.close() - messages[-1]['content'] = [ - {'type': 'text', 'text': input_message.content}, - {'type': 'image_url', 'image_url': {'url': image_url}}, - ] - result = openrouter_run(version, messages, callback_data, 'Grok') - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, - version=version, - input_tokens=result[1], - output_tokens=result[2], - image=image, - ) - msgs = self.save_results(result[0], process_time) - return msgs + embedding_tokens = 0 + + image = None + if input_message.file: + file_service = FileProcessingService + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:550]) + + if not kind: + if Path(input_message.file.name).suffix[1:].upper() not in self.SUPPORTED_EXTENSIONS: + raise FileExtensionNotSupported(self.SUPPORTED_EXTENSIONS) + raise CorruptedFileError + + raw_file_extension = kind.extension + file_extension = file_service.get_file_extension(raw_file_extension, file_bytes) + if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): + text = file_service.get_file_data(file_extension, file_bytes) + chunks = EmbeddingService.split_text_to_chunks(text) + approx_tokens = sum([len(message['content']) for message in messages]) / 3 + predict_price = ( + Decimal(approx_tokens) + * self.TOKENS_COST[version.split('/')[1]]['input'] + / Decimal('1000000') + + len(chunks) * 2100 * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] + ).quantize(Decimal('0.1'), rounding='ROUND_UP') + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < predict_price: + raise InsufficientBalance(balance, predict_price) + + if len(text) > 20_000: + for proxy in Proxy.objects.all(): + document_name = chunks[0].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, + chunks, + proxy, + input_message.content, + model='text-embedding-3-small', + index_name='ml_model-index-1536', + ) + messages[-1]['content'] = EmbeddingService.make_embeddings_prompt( + document_name=document_name, + section_texts=file_data, + question=input_message.content, + ) + else: + messages[-1]['content'] = ( + f'Используй системный промпт. Содержание файла: ' + f'{chunks}. Вопрос: {input_message.content}' + ) + + + elif file_extension in ('jpg', 'jpeg', 'png', 'webp'): + image = input_message.file + + mime = kind.mime if kind else 'application/octet-stream' + normalized_image = Image.open(input_message.file) + format = 'jpeg' if kind.extension == 'jpg' else kind.extension + buf = BytesIO() + normalized_image.save(buf, format=format) + image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + buf.close() + messages[-1]['content'] = [ + {'type': 'text', 'text': input_message.content}, + {'type': 'image_url', 'image_url': {'url': image_url}}, + ] + else: + raise FileExtensionNotSupported(self.SUPPORTED_EXTENSIONS) + + result = openrouter_run(version, messages, callback_data, 'Grok') + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice( + input_message.content_object.model, + version=version, + input_tokens=result[1], + output_tokens=result[2], + embedding_tokens=embedding_tokens, + image=image, + ) + msgs = self.save_results(result[0], process_time) + return msgs + + + def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): @@ -7,9 +7,10 @@ from io import BytesIO import filetype from PIL import Image +from PIL.Image import DecompressionBombError from messages.models import Message -from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError +from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError, ImageTooLargeError from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from ml_model.services.base import SimpleService @@ -27,6 +28,9 @@ class Grok_4_1_Fast(SimpleService): TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} + MAX_PIXELS = 178956970 + + def calculate_price(self, input_tokens: int, output_tokens: int, embedding_tokens: int) -> Decimal: price = ( input_tokens * self.TOKENS_COST['input'] / 1_000_000 @@ -57,6 +61,7 @@ class Grok_4_1_Fast(SimpleService): if input_message.file: file_service = FileProcessingService file_bytes = input_message.file.read() + input_message.file.close() kind = filetype.guess(file_bytes[:550]) if not kind: raise CorruptedFileError @@ -100,14 +105,20 @@ class Grok_4_1_Fast(SimpleService): f'{chunks}. Вопрос: {input_message.content}' ) elif file_extension in ('jpg', 'jpeg', 'png', 'webp'): - kind = filetype.guess(file_bytes[:20]) + try: + with Image.open(BytesIO(file_bytes)) as normalized_image: + current_pixels = normalized_image.width * normalized_image.height + if current_pixels > self.MAX_PIXELS: + raise ImageTooLargeError(self.MAX_PIXELS) + except DecompressionBombError: + raise ImageTooLargeError(self.MAX_PIXELS) + mime = kind.mime if kind else 'application/octet-stream' - normalized_image = Image.open(input_message.file) - format = 'jpeg' if kind.extension == 'jpg' else kind.extension - buf = BytesIO() - normalized_image.save(buf, format=format) - image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' - buf.close() + image_url = ( + f'data:{mime};base64,' + f'{base64.b64encode(file_bytes).decode("utf-8")}' + ) + messages[-1]['content'] = [ {'type': 'text', 'text': input_message.content}, {'type': 'image_url', 'image_url': {'url': image_url}}, @@ -23,6 +23,11 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Grok_Image(SimpleService): TOKENS_COST = Decimal('4') + OPTIMIZATION_PROMPT = """ + Balanced scene layout and clear subject separation. Realistic color balance, soft depth cues, and clean edges. + Consistent details without excessive stylization. + """ + def calculate_price(self) -> Decimal: return self.TOKENS_COST @@ -46,7 +51,7 @@ class Grok_Image(SimpleService): raise InsufficientBalance(balance, self.TOKENS_COST) callback_data = dict( { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': f"{self.translate_prompt(input_message.content)}\n{self.OPTIMIZATION_PROMPT}", **input_message.info, } ) @@ -36,10 +36,10 @@ class Seedream(SimpleService): "seedream-4.5": "seedream-4-5-251128", } - ENHANCEMENT_PROMPT = """[Speed-first — keep the user's idea] - Subject: one clear focal subject; same mood as the scene below. - Scene: simple background; avoid crowds, clutter, tiny props, dense patterns. - Light: one coherent light setup. No text, logos, or watermark in the image. + OPTIMIZATION_PROMPT = """ + Subject: one clear focal subject; same mood as the scene below. + Scene: simple background; avoid crowds, clutter, tiny props, dense patterns. + Light: one coherent light setup. No text, logos, or watermark in the image. """ @classmethod @@ -87,7 +87,7 @@ class Seedream(SimpleService): raise InvalidParameterError(_('3K output is not supported for Seedream 4.5')) callback_data = { - 'prompt': f'{input_message.content}\n{self.ENHANCEMENT_PROMPT}', + 'prompt': f"{input_message.content}\n{self.OPTIMIZATION_PROMPT}", 'watermark': False, **input_message.info, } @@ -39,6 +39,14 @@ class UnsupportedSize(Exception): self.current_size | self.required_size ) +class ImageTooLargeError(Exception): + def __init__(self, max_pixels: int) -> None: + self.max_pixels = max_pixels + + def __str__(self) -> str: + return _('Image exceeds the maximum allowed pixel count (%(max_pixels)d).') % { + 'max_pixels': self.max_pixels + } class ModelTimeoutError(Exception): def __str__(self):