@@ -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