@@ -1028,6 +1028,9 @@ msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" +msgid "Image is ready" +msgstr "Изображение готово" + #: payments/admin.py:40 payments/admin.py:100 msgid "Missing" msgstr "Отсутствующий" @@ -1,5 +1,6 @@ from ml_model.services.chatgpt import Chatgpt from ml_model.services.chatgpt_5 import Chatgpt_5 +from ml_model.services.chatgpt_5_4 import Chatgpt_5_4 from ml_model.services.claude import Claude from ml_model.services.codellama import Codellama from ml_model.services.dalle import Dalle @@ -593,14 +593,22 @@ class Chatgpt(SimpleService): endpoint == 'responses' and (data := resp.json()) and data.get('output') - and ( - content := data['output'][-1]['content'][0]['text'] - ) + and (content := data['output'][-1]['content'][0]['text']) ): input_tokens = resp.json()['usage']['input_tokens'] output_tokens = resp.json()['usage']['output_tokens'] response = AIMessage(content=content.replace('\\n', '\n')) return input_tokens, output_tokens, response + elif ( + endpoint == 'responses' + and (data := resp.json()) + and data.get('output') + and (image := data['output'][0]['result']) + ): + input_tokens = resp.json()['usage']['input_tokens'] + output_tokens = resp.json()['usage']['output_tokens'] + response = AIMessage(content=[{'generate_image': True, 'image': image}]) + return input_tokens, output_tokens, response else: raise Exception('GPT not answer correctly, please retry later') @@ -0,0 +1,268 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +from django.core.files import File +from django.utils.translation import gettext_lazy +from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage + +from messages.models import Message +from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError +from ml_model.models import NeuronModel +from ml_model.services import Chatgpt +from ml_model.services.EmbeddingService import EmbeddingService +from ml_model.services.FileService import FileProcessingService +from poller.models import Proxy + + +class Chatgpt_5_4(Chatgpt): + TOKENS_COST = { + 'gpt-5.4': { + 'input': Decimal('0.00125'), + 'output': Decimal('0.0075'), + 'web_search': { + 'low': Decimal('5'), # 1 call + 'medium': Decimal('5'), # 1 call + 'high': Decimal('5'), # 1 call + }, + 'code_interpreter': Decimal('15'), # 1 call + 'generated_image': Decimal('10.2'), + }, + 'gpt-5.4-pro': { + 'input': Decimal('0.015'), + 'output': Decimal('0.09'), + 'web_search': { + 'low': Decimal('5'), # 1 call + 'medium': Decimal('5'), # 1 call + 'high': Decimal('5'), # 1 call + }, + 'generated_image': Decimal('10.2'), + }, + } + + TOKEN_LIMITS = { + 'gpt-5.4': 1_050_000 // 2, + 'gpt-5.4-pro': 1_050_000 // 2, + } + + @property + def neuron_model(self): + return NeuronModel.objects.get(slug='chatgpt_5_4') + + def save_results( + self, + results: list[BaseMessage], + elapsed_time: timedelta, + generated_image: bytes | None, + save: bool = True, + ) -> list[Message]: + messages = [ + Message( + content=result.content, + elapsed_time=elapsed_time, + content_object=self.store, + file=File(BytesIO(generated_image), '.png') if generated_image else None, + ) + for result in results + ] + if save: + return Message.objects.bulk_create(messages) + return messages + + def calculate_price( + self, + input_tokens: int, + output_tokens: int, + model: str, + info: dict, + embedding_tokens: int = 0, + image: bool = False, + *args, + **kwargs, + ) -> Decimal: + price = ( + input_tokens * self.TOKENS_COST[model]['input'] + + output_tokens * self.TOKENS_COST[model]['output'] + ) + 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'] + if embedding_tokens > 0: + price += self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] * embedding_tokens + if image: + price += self.TOKENS_COST[model]['generated_image'] + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def make( + self, + input_message: Message, + save: bool = True, + ) -> list[Message]: + start_time = time.time() + info = input_message.info.copy() + model_name = info.pop('version', 'gpt-5.4') + user_system_prompt = info.pop('system_prompt', '') + input_content = [{'type': 'text', 'text': input_message.content or ''}] + file = input_message.file + image = None + image_size = None + embedding_tokens = 0 + chunks = [] + text_chunks = [] + if file: + file_service = FileProcessingService + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:20]) + if not kind: + 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) + text_chunks = EmbeddingService.split_text_to_chunks(text) + chunks = [HumanMessage(content=chunk_text) for chunk_text in text_chunks] + elif file_extension in ('jpg', 'jpeg', 'png', 'webp'): + 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']) + 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)] + input_tokens, input_embedding_tokens = self._get_input_tokens( + file, image, chunks, chat_history, llm_input + ) + self.assert_enough_balance( + input_tokens, image_size, model=model_name, embedding_tokens=input_embedding_tokens + ) + for proxy in Proxy.objects.all(): + system = chat_history.messages.pop(0) + messages = [ + {'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}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) + if image: + messages[-1]['content'] = [ + {'type': 'input_text', 'text': input_message.content}, + {'type': 'input_image', 'image_url': image_data['image_url']['url']}, + ] + 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] + ) + embedding_tokens, file_data = EmbeddingService.get_large_file_data( + self.store.messages.first().pk, + text_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'{"".join(text_chunks)}. Вопрос: {input_message.content}' + ) + json_data = { + 'model': model_name, + 'input': messages, + 'tools': [ + { + 'type': 'image_generation', + 'size': '1024x1024', + 'quality': 'medium', + 'model': 'gpt-image-1.5', + } + ], + 'instructions': ( + 'Форматирование — обязательное требование. Выполняй строго по правилам:\n\n' + "1) Используй реальные символы новой строки, не выводи '\\n' как текст — вставляй переносы.\n\n" + '2) Абзацы: между абзацами ставь две пустые строки (два символа новой строки подряд).\n\n' + '3) Нумерованные и маркированные списки: каждый пункт на отдельной строке;\n' + ' между списком и текстом оставляй две пустые строки.\n\n' + '4) Блоки кода: любые фрагменты кода выделяй тройными бэктиками (```) с указанием языка программирования;\n' + ' перед и после блока оставляй две пустые строки.\n\n' + "5) Заголовки абзацев: делай крупным, используя Markdown '####' (например, '### Заголовок');\n" + ' выделяй жирным (**Заголовок**); оставляй две пустые строки перед и после заголовка.\n\n' + '6) Используй Markdown для всего форматирования, не используй HTML.\n\n' + '7) Исправление формата: если формат неверный, перепиши ответ и верни исправленный вариант.\n\n' + 'Строго разделяй текст на абзацы с жирными заголовками;\n' + 'нумерованные и маркированные списки выводи с переносами строк;\n' + 'блоки кода — с тройными бэктиками и указанием языка;\n' + "не выводи '\\n' как текст, используйте реальные переносы строк;\n" + 'добавляй две пустые строки между абзацами и блоками для улучшения читаемости.' + ), + } + if reasoning := info.get('reasoning'): + reasoning_data = { + 'Минимальный': 'minimal', + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'high', + 'Сверхвысокий': 'xhigh', + } + 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', + } + json_data['tools'].append( + { + 'type': 'web_search_preview', + 'search_context_size': search_context_sizes[web_search], + 'user_location': {'type': 'approximate', 'country': 'RU'}, + } + ) + info['web_search'] = search_context_sizes[web_search] + 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 + ) + generated_image = None + if isinstance(response.content, list): + if isinstance(response.content[0], dict) and response.content[0].get('generate_image'): + generated_image = base64.b64decode(response.content[0]['image']) + response.content = gettext_lazy('Image is ready') + 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}') + if generated_image: + self.logger.info( + f'Фиксированная цена за генерацию картинки - ' + f'{self.TOKENS_COST[model_name]["generated_image"]}' + ) + self.logger.info( + f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}' + ) + process_time = timedelta(seconds=time.time() - start_time) + self.handle_invoice( + self.neuron_model, input_tokens, output_tokens, model_name, info, embedding_tokens, generated_image + ) + msgs = self.save_results([response], process_time, generated_image, save) + return msgs