@@ -2,13 +2,13 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-14 22:35+0300\n" +"POT-Creation-Date: 2026-05-22 15:59+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -207,7 +207,7 @@ msgstr "Бизнес Группы" #: authentication/models/user.py:224 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 #: payments/admin.py:95 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: payments/models/payment.py:26 payments/models/payment_plan.py:43 #: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -666,7 +666,9 @@ msgstr "" #: ml_model/exceptions.py:47 #, python-format msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." -msgstr "Размер изображения превышает максимально допустимое количество пикселей (%(max_pixels)d)." +msgstr "" +"Размер изображения превышает максимально допустимое количество пикселей " +"(%(max_pixels)d)." #: ml_model/exceptions.py:53 msgid "The model is not responding" @@ -754,10 +756,14 @@ msgid "Available only in paid plan" msgstr "Доступно только в платном тарифе" #: ml_model/exceptions.py:171 +msgid "File upload is available only in paid plan." +msgstr "Загрузка файлов доступна только в платном тарифном плане." + +#: ml_model/exceptions.py:176 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." -#: ml_model/exceptions.py:168 +#: ml_model/exceptions.py:181 msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." @@ -1048,11 +1054,18 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:154 +#: ml_model/services/chatgpt.py:149 msgid "No matching version found" msgstr "Соответствующая версия не найдена" -#: ml_model/services/chatgpt_5_4.py:270 +#: ml_model/services/chatgpt_5.py:130 ml_model/services/chatgpt_5_4.py:156 +#: ml_model/services/chatgpt_5_5.py:210 +msgid "The \"Use code\" option cannot be used together with an attached image." +msgstr "" +"Нельзя одновременно использовать параметр «Использовать код» вместе с " +"прикреплённым изображением." + +#: ml_model/services/chatgpt_5_4.py:315 ml_model/services/chatgpt_5_5.py:362 msgid "Image is ready" msgstr "Изображение готово" @@ -1083,7 +1096,7 @@ msgstr "Неизвестный бакет для загрузки" msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:78 +#: ml_model/services/seedream.py:87 msgid "3K output is not supported for Seedream 4.5" msgstr "3К разрешение не поддерживается для Seedream 4.5" @@ -1091,7 +1104,7 @@ msgstr "3К разрешение не поддерживается для Seedre msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:188 +#: ml_model/tasks.py:137 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1109,7 +1122,7 @@ msgstr "" msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:12 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" @@ -1178,36 +1191,36 @@ msgstr "Индивидуальный" msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 +#: payments/models/payment_plan.py:34 payments/models/payment_plan.py:49 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:42 +#: payments/models/payment_plan.py:35 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:58 +#: payments/models/payment_plan.py:51 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:59 +#: payments/models/payment_plan.py:52 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:25 +#: payments/models/payment_plan.py:56 payments/models/user_payment_method.py:25 msgid "Payment Method" msgstr "Платежный метод" -#: payments/models/payment_plan.py:69 +#: payments/models/payment_plan.py:62 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:75 +#: payments/models/payment_plan.py:68 msgid "Referral balance" msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:96 payments/models/payment_plan.py:97 +#: payments/models/payment_plan.py:89 payments/models/payment_plan.py:90 msgid "User Balance" msgstr "Баланс пользователя" @@ -1347,13 +1360,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:194 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 +#: payments/tests/test_plans.py:193 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:192 -#: payments/tests/test_plans.py:197 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 +#: payments/tests/test_plans.py:196 msgid "Images" msgstr "Изображения" @@ -1393,7 +1406,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:201 tools/media/apis.py:209 +#: tools/chats/apis.py:207 tools/media/apis.py:209 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1402,7 +1415,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:257 +#: tools/chats/apis.py:263 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1,6 +1,7 @@ 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.chatgpt_5_5 import Chatgpt_5_5 from ml_model.services.claude import Claude from ml_model.services.codellama import Codellama from ml_model.services.dalle import Dalle @@ -1,19 +1,17 @@ import base64 -import itertools -import logging -import time - - -from django.utils.translation import gettext_lazy as _ from datetime import timedelta from decimal import Decimal from io import BufferedReader, BytesIO +import itertools +import json +import logging from math import ceil -from typing import Generator, List, Optional, Dict, Any, Tuple +import time +from typing import Any, Dict, Generator, List, Optional, Tuple +from django.utils.translation import gettext_lazy as _ import filetype import httpx -import tiktoken from langchain.chains import ConversationChain from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.messages import ( @@ -26,6 +24,7 @@ from langchain_core.prompts.prompt import PromptTemplate from langchain_core.runnables import RunnableWithMessageHistory from langchain_openai.chat_models import ChatOpenAI from PIL import Image +import tiktoken from backend import settings from messages.models import BaseStore, Message @@ -98,10 +97,6 @@ class Chatgpt(SimpleService): super().__init__(store) self.logger = logging.getLogger(self.__class__.__name__) - @property - def neuron_model(self): - return NeuronModel.objects.get(title='ChatGPT 4') - def make( self, input_message: Message, @@ -374,7 +369,7 @@ class Chatgpt(SimpleService): process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( - self.neuron_model, + input_message.content_object.model, input_tokens, output_tokens, self.llm.model_name, @@ -674,13 +669,178 @@ class Chatgpt(SimpleService): yield content process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( - self.neuron_model, + input_message.content_object.model, self.llm.get_num_tokens_from_messages(chat_history.messages), self.llm.model_name, ) msgs = self.save_results([chat_history.messages[-1]], process_time, save) return msgs + @staticmethod + def _usage_from_response_event(event: dict) -> tuple[int, int]: + usage = event.get('usage') or (event.get('response') or {}).get('usage') or {} + return int(usage.get('input_tokens') or 0), int(usage.get('output_tokens') or 0) + + @staticmethod + def _image_b64_from_output_item(item: dict) -> str | None: + if item.get('type') == 'image_generation_call' and item.get('result'): + return item['result'] + if item.get('result'): + return item['result'] + return None + + @staticmethod + def _text_from_response_output(response_obj: dict) -> str: + parts: list[str] = [] + for item in response_obj.get('output') or []: + if item.get('type') != 'message': + continue + for content in item.get('content') or []: + if content.get('type') == 'output_text' and (text := content.get('text')): + parts.append(text) + return ''.join(parts) + + @classmethod + def _apply_responses_stream_event( + cls, + event: dict, + *, + text_parts: list[str], + image_b64: str | None, + input_tokens: int, + output_tokens: int, + completed: bool, + ) -> tuple[list[str], str | None, int, int, bool]: + event_type = event.get('type') + if event_type == 'response.output_text.delta': + if delta := event.get('delta'): + text_parts.append(delta) + elif event_type == 'response.output_text.done': + if text := event.get('text'): + text_parts = [text] + elif event_type == 'response.output_item.done': + item = event.get('item') or {} + if img := cls._image_b64_from_output_item(item): + image_b64 = img + elif event_type == 'response.completed': + completed = True + input_tokens, output_tokens = cls._usage_from_response_event(event) + response_obj = event.get('response') or {} + if not text_parts and (text := cls._text_from_response_output(response_obj)): + text_parts = [text] + for item in response_obj.get('output') or []: + if img := cls._image_b64_from_output_item(item): + image_b64 = img + elif event_type in ('response.incomplete', 'response.failed'): + response_obj = event.get('response') or {} + usage = event.get('usage') or response_obj.get('usage') + if usage and not completed: + input_tokens, output_tokens = cls._usage_from_response_event(event) + if not text_parts and (text := cls._text_from_response_output(response_obj)): + text_parts = [text] + for item in response_obj.get('output') or []: + if img := cls._image_b64_from_output_item(item): + image_b64 = img + return text_parts, image_b64, input_tokens, output_tokens, completed + + 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 + completed = False + 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: + continue + if isinstance(line, bytes): + line = line.decode('utf-8') + if not line.startswith('data:'): + continue + data = line[5:].lstrip() + if data == '[DONE]': + if completed: + break + continue + try: + event = json.loads(data) + except ValueError: + continue + text_parts, image_b64, input_tokens, output_tokens, completed = ( + self._apply_responses_stream_event( + event, + text_parts=text_parts, + image_b64=image_b64, + input_tokens=input_tokens, + output_tokens=output_tokens, + completed=completed, + ) + ) + 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 + + @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() + organic = (serp.get('organic') or [])[:max_sources] + + lines = [f'Результаты веб-поиска по запросу: {query}' if query else 'Результаты веб-поиска:'] + if not organic: + lines.append('(Совпадений не найдено.)') + return '\n'.join(lines) + + for index, item in enumerate(organic, start=1): + block = f'[{index}] {(item.get("title") or "").strip() or "Без названия"}' + if link := (item.get('link') or '').strip(): + block += f'\nURL: {link}' + if date := (item.get('date') or '').strip(): + block += f'\nДата: {date}' + if snippet := (item.get('snippet') or '').strip(): + block += f'\nОписание: {snippet}' + lines.append(block[:250]) + + return '\n'.join(lines).strip() + @classmethod def evaluate( cls, @@ -3,10 +3,15 @@ from datetime import timedelta from decimal import Decimal import filetype +from django.utils.translation import gettext from langchain_core.messages import HumanMessage, SystemMessage from messages.models import Message -from ml_model.exceptions import FileExtensionNotSupported, CorruptedFileError +from ml_model.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + InvalidParameterError, +) from ml_model.models import NeuronModel from ml_model.services import Chatgpt from ml_model.services.EmbeddingService import EmbeddingService @@ -86,9 +91,6 @@ class Chatgpt_5(Chatgpt): TOKEN_LIMITS = {key: 200_000 for key in TOKENS_COST.keys()} - @property - def neuron_model(self): - return NeuronModel.objects.get(slug='chatgpt_5') def make( self, @@ -123,6 +125,10 @@ class Chatgpt_5(Chatgpt): _, image_size, image_data = self._get_image_data(file_bytes, file_extension) else: raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP']) + if image and info.get('code_interpreter'): + raise InvalidParameterError( + gettext('The "Use code" option cannot be used together with an attached image.') + ) 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)] @@ -205,7 +211,12 @@ class Chatgpt_5(Chatgpt): if info.get('reasoning') == 'Минимальный': info.pop('web_search', None) info.pop('code_interpreter', None) - if model_name == 'gpt-5.2' and info.get('verbosity', 'Отключено') != 'Отключено': + web_search = info.get('web_search', 'Отключено') + if ( + model_name == 'gpt-5.2' + and info.get('verbosity', 'Отключено') != 'Отключено' + and web_search in ('Отключено', 'Малый контекст') + ): verbosity_data = { 'Низкий': 'low', 'Средний': 'medium', @@ -238,7 +249,12 @@ class Chatgpt_5(Chatgpt): ) process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( - self.neuron_model, input_tokens, output_tokens, model_name, info, embedding_tokens + input_message.content_object.model, + input_tokens, + output_tokens, + model_name, + info, + embedding_tokens, ) msgs = self.save_results([response], process_time, save) return msgs @@ -9,12 +9,17 @@ from pathlib import Path import filetype import httpx from django.core.files import File -from django.utils.translation import gettext_lazy +from django.utils.translation import gettext, gettext_lazy 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.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + InvalidParameterError, + PaidPlanRequiredError, +) from ml_model.models import NeuronModel from ml_model.services import Chatgpt from ml_model.services.EmbeddingService import EmbeddingService @@ -56,10 +61,6 @@ class Chatgpt_5_4(Chatgpt): 'gpt-5.4-pro': 'gpt-5.5', } - @property - def neuron_model(self): - return NeuronModel.objects.get(slug='chatgpt_5_4') - def save_results( self, results: list[BaseMessage], @@ -150,6 +151,10 @@ class Chatgpt_5_4(Chatgpt): _, image_size, image_data = self._get_image_data(file_bytes, file_extension) else: raise FileExtensionNotSupported(supported_extensions) + if image and info.get('code_interpreter'): + raise InvalidParameterError( + gettext('The "Use code" option cannot be used together with an attached image.') + ) 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)] @@ -278,11 +283,8 @@ class Chatgpt_5_4(Chatgpt): } json_data['reasoning'] = {'effort': reasoning_data[reasoning], 'summary': 'auto'} web_search = info.get('web_search', 'Выключен') - verbosity = info.get('verbosity', False) - if verbosity: - json_data['text'] = { - 'verbosity': 'none' if web_search == 'Средний' else 'low', - } + if info.get('verbosity', False) and web_search in ('Выключен', 'Низкий'): + json_data['text'] = {'verbosity': 'low'} search_context_sizes = { 'Низкий': 'low', 'Средний': 'medium', @@ -324,64 +326,13 @@ class Chatgpt_5_4(Chatgpt): ) 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 + input_message.content_object.model, + input_tokens, + output_tokens, + model_name, + info, + embedding_tokens, + generated_image, ) 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 @@ -0,0 +1,397 @@ +import base64 +import filetype +import httpx +import time + +from backend import settings +from datetime import timedelta +from decimal import Decimal + +from django.core.files import File +from django.utils.translation import gettext, gettext_lazy + +from io import BytesIO + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage + +from messages.models import Message + +from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, InvalidParameterError, FileUploadPaidPlanRequiredError +from ml_model.services import Chatgpt +from ml_model.services.EmbeddingService import EmbeddingService +from ml_model.services.FileService import FileProcessingService + +from pathlib import Path + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + +from poller.models import Proxy + + +class Chatgpt_5_5(Chatgpt): + TOKENS_COST = { + 'gpt-5.5': { + '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 + }, + 'code_interpreter': Decimal('15'), # 1 call + 'generated_image': Decimal('10.2'), + }, + } + + TOKEN_LIMITS = {'gpt-5.5': 1_050_000 // 2} + + FORMATION_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" + 'добавляй две пустые строки между абзацами и блоками для улучшения читаемости.' + ) + + BASE_SYSTEM = ( + 'You are an advanced analytical assistant optimized for GPT-5.5 with strong reasoning, ' + 'factual accuracy, and adaptive web retrieval.\n' + 'Your primary goal is to provide correct, evidence-based, and practical answers with minimal hallucinations.\n\n' + 'Core principles:\n' + '- Prioritize correctness over fluency or verbosity\n' + '- Never fabricate facts, sources, APIs, citations, statistics, or technical behavior\n' + '- Clearly distinguish facts, assumptions, interpretations, and uncertainty\n' + '- If evidence is weak or conflicting, explicitly say so\n' + '- Do not guess missing information to make answers feel complete\n\n' + 'Reasoning behavior:\n' + '- Use deep internal reasoning before answering\n' + '- Validate conclusions against available evidence\n' + '- Prefer concise, high-signal responses\n' + '- Avoid unnecessary over-analysis\n' + '- Stop reasoning once sufficient confidence is achieved\n\n' + 'Web retrieval policy:\n' + '- If web search results appear in the current turn or earlier messages, use them when they are relevant\n' + '- If the user asks to use search results, prior web context, or information from earlier in the chat, ' + 'retrieve and apply that material from history; do not answer from memory alone in that case\n' + '- Ground factual claims in provided search results; cite with markdown links using the source title as the ' + 'link text: [Source title](full URL). Prefer the resource name over bare numbers like [1] or [2]\n' + '- Use numbered citations only if the title is missing or unclear; never cite without a URL when one is given\n' + '- Do not ignore provided search results in favor of general knowledge when they are relevant to the question\n' + '- Prefer fewer high-quality sources over many weak ones\n' + '- Prioritize:\n' + ' 1. Official documentation and primary sources\n' + ' 2. Reputable technical or academic sources\n' + ' 3. Established industry publications\n' + '- If provided results are irrelevant or insufficient, say so explicitly instead of inventing facts\n\n' + 'Technical response rules:\n' + '- Prefer production-safe and version-aware solutions\n' + '- Mention important limitations or edge cases when relevant\n' + '- Distinguish official behavior from community assumptions\n' + '- Never invent undocumented features or capabilities\n\n' + 'Communication style:\n' + '- Be concise, direct, and information-dense\n' + '- Use structure only when it improves clarity\n' + '- Avoid filler, repetition, and exaggerated certainty\n\n' + 'If reliable evidence is unavailable, clearly state the uncertainty instead of hallucinating details.' + ) + + 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.5') + user_system_prompt = info.pop('system_prompt', '') + file = input_message.file + image = None + embedding_tokens = 0 + predict_embedding_tokens = 0 + chunks = [] + text_chunks = [] + predicted_input_price = 0 + is_free_plan = ( + self.store.user.account_type == 'regular' + and self.store.user.payment_plan + and self.store.user.payment_plan.plan.price <= 0 + ) + if is_free_plan: + info.pop('code_interpreter', None) + info.pop('verbosity', None) + if file: + if is_free_plan: + raise FileUploadPaidPlanRequiredError() + 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) + 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] + if (chunks_length := sum(len(chunk) for chunk in text_chunks)) > 20_000: + predict_embedding_tokens = len(text_chunks) * 2020 + predicted_input_price += ( + Decimal((210 + (chunks_length / len(text_chunks) * 10)) / 2.7) + * self.TOKENS_COST[model_name]['input'] + ) + else: + predicted_input_price += ( + Decimal((55 + chunks_length + len(input_message.content)) / 2.2) + * self.TOKENS_COST[model_name]['input'] + ) + elif file_extension in ('jpg', 'jpeg', 'png', 'webp'): + image = file + _, _, image_data = self._get_image_data(file_bytes, file_extension) + else: + raise FileExtensionNotSupported(supported_extensions) + if image and info.get('code_interpreter'): + raise InvalidParameterError( + gettext('The "Use code" option cannot be used together with an attached image.') + ) + chat_history = self.get_chat_history(model_name=model_name) + chat_history.add_message(HumanMessage(content=input_message.content)) + current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() + is_low_balance = current_user_balance < Decimal('100') + has_full_access = not is_free_plan and not is_low_balance + gpt_5_5_system = SystemMessage(content=self.BASE_SYSTEM) + for proxy in Proxy.objects.all(): + history_messages = list(chat_history.messages) + system = history_messages[0] + messages = [ + { + 'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', + 'content': msg.content, + } + for msg in history_messages[1:] + ] + messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': gpt_5_5_system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) + json_data = { + 'model': model_name, + 'input': messages, + 'instructions': self.FORMATION_INSTRUCTIONS, + 'tools': [], + } + if has_full_access: + json_data['tools'].append( + { + 'type': 'image_generation', + 'size': '1024x1024', + 'quality': 'medium', + 'model': 'gpt-image-1.5', + } + ) + if reasoning := info.get('reasoning'): + reasoning_data = { + 'Средний': 'low', + 'Высокий': 'medium', + } + effort = 'none' if is_free_plan else 'low' if is_low_balance else reasoning_data[reasoning] + json_data['reasoning'] = {'effort': effort, 'summary': 'auto'} + else: + json_data['reasoning'] = {'effort': 'none', 'summary': 'auto'} + web_search = info.get('web_search', 'Выключен') + if info.get('verbosity', False) and web_search in ('Выключен', 'Низкий'): + json_data['text'] = {'verbosity': 'low'} + serper_sources = 0 + if web_search != 'Выключен': + search_context_sizes = { + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'medium', + 'Сверхвысокий': 'medium', + } + if has_full_access: + json_data['tools'].append( + { + 'type': 'web_search', + 'search_context_size': search_context_sizes[web_search], + 'user_location': {'type': 'approximate', 'country': 'RU'}, + } + ) + info['web_search'] = search_context_sizes[web_search] + predicted_input_price += self.TOKENS_COST[model_name]['web_search'][ + search_context_sizes[web_search] + ] + else: + max_sources = { + 'Низкий': 3, + 'Средний': 5, + 'Высокий': 7, + 'Сверхвысокий': 10, + } + serper_sources = max_sources[web_search] + predicted_input_price += ( + 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'}}) + messages[-1]['content'] += ' the python tool ' + predicted_input_price += self.TOKENS_COST[model_name]['code_interpreter'] + if image: + messages[-1]['content'] = [ + {'type': 'input_text', 'text': input_message.content}, + {'type': 'input_image', 'image_url': image_data['image_url']['url']}, + ] + predicted_input_tokens = self._count_responses_input_tokens(proxy, json_data) + predicted_input_price += ( + predicted_input_tokens * self.TOKENS_COST[model_name]['input'] + + predict_embedding_tokens + * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] + ) + if has_full_access: + predicted_input_price += self.TOKENS_COST[model_name]['generated_image'] + max_output_tokens = max( + int((current_user_balance - predicted_input_price - Decimal('0.1')) / self.TOKENS_COST[model_name]['output']), + 0, + ) + min_response_tokens = 300 if not is_free_plan else 150 + if max_output_tokens < min_response_tokens: + cost = predicted_input_price + min_response_tokens * self.TOKENS_COST[model_name]['output'] + raise InsufficientBalance(current_user_balance, cost) + json_data['max_output_tokens'] = max_output_tokens + if file and not image: + 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}' + ) + if serper_sources: + serp = self.run_serper(input_message.content) + messages.append( + { + 'role': 'user', + 'content': self.serper_to_openai_context(serp, max_sources=serper_sources), + } + ) + json_data['input'] = messages + info.pop('web_search', None) + self.logger.info( + f'Predicted input tokens (responses/input_tokens) для {model_name} - {predicted_input_tokens}' + ) + 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): + 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( + input_message.content_object.model, + input_tokens, + output_tokens, + model_name, + info, + embedding_tokens, + generated_image, + ) + msgs = self.save_results([response], process_time, generated_image, save) + return msgs + + def _count_responses_input_tokens(self, proxy: Proxy, json_data: dict) -> int: + payload = {k: v for k, v in json_data.items() if k not in ('stream', 'max_output_tokens')} + 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=60, write=30, pool=30), + ) as client: + resp = client.post('responses/input_tokens', json=payload) + resp.raise_for_status() + return int(resp.json()['input_tokens']) @@ -58,10 +58,6 @@ from ml_model.constants import ANCHORS class Raifgpt(Chatgpt): EMBEDDING_MODEL_FOR_BILLING = 'text-embedding-3-large' - @property - def neuron_model(self): - return NeuronModel.objects.get(slug='raifgpt') - def make( self, input_message: Message, @@ -248,7 +244,12 @@ class Raifgpt(Chatgpt): process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( - self.neuron_model, input_tokens, output_tokens, self.llm.model_name, {}, embedding_tokens + input_message.content_object.model, + input_tokens, + output_tokens, + self.llm.model_name, + {}, + embedding_tokens, ) msgs = self.save_results([response], process_time, save) return msgs @@ -166,6 +166,11 @@ class PaidPlanRequiredError(Exception): return _('Available only in paid plan') +class FileUploadPaidPlanRequiredError(Exception): + def __str__(self) -> str: + return _('File upload is available only in paid plan.') + + class FaceNotFoundError(Exception): def __str__(self) -> str: return _('Face not found in the image. Please try another image with a face.') @@ -33,6 +33,8 @@ from ml_model.exceptions import ( RequestBlocked, TemplateNotFound, TemplateUnknownException, + InvalidParameterError, + FileUploadPaidPlanRequiredError ) from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance @@ -170,7 +172,10 @@ class MessagesAPIView(APIView): {'detail': f'{exc}'}, status=HTTP_503_SERVICE_UNAVAILABLE, ) - except PaidPlanRequiredError as exc: + except ( + PaidPlanRequiredError, + FileUploadPaidPlanRequiredError + ) as exc: return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) except ( FileExtensionNotSupported, @@ -182,6 +187,7 @@ class MessagesAPIView(APIView): ImageAnalysisError, FileUploadUnsupported, UnrecognizedFileError, + InvalidParameterError, ) as exc: return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) except TemplateNotFound as exc: