@@ -1,7 +1,7 @@ +from ml_model.services.chatgpt_4 import Chatgpt_4 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,457 +1,202 @@ import base64 -from datetime import timedelta -from decimal import Decimal -from io import BufferedReader, BytesIO -import itertools -import json -import logging -from math import ceil -import time -from typing import Any, Dict, Generator, List, Optional, Tuple +from typing import Any, Iterator -from django.utils.translation import gettext_lazy as _ import filetype import httpx -from langchain.chains import ConversationChain -from langchain_core.chat_history import InMemoryChatMessageHistory -from langchain_core.messages import ( - AIMessage, - BaseMessage, - HumanMessage, - SystemMessage, -) -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 +import time from backend import settings -from messages.models import BaseStore, Message -from ml_model.constants import TEMPORARY_TEST_TEXT +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, - FileUploadUnsupported, + InvalidParameterError, ModelVersionNotAvailable, + PaidPlanRequiredError, ) -from ml_model.models import ModelConfiguration, NeuronModel +from ml_model.services.chatgpt_4 import Chatgpt_4 from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService -from ml_model.services.base import SimpleService + +from pathlib import Path + +from ml_model.services.base import StreamSimpleService +from ml_model.services.openai_stream_mixin import OpenAIStreamMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector -from poller.models import Proxy -from tools.chats.models import Chat -from tools.copywrite.models import Copywrite -from tools.public_api.models import APIStore +from poller.models import Proxy -class Chatgpt(SimpleService): - """ - ChatGPT Service - contains abstract method make, which makes a generation - """ +class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): TOKENS_COST = { - 'o3-mini': { - 'input': Decimal('0.0022'), - 'output': Decimal('0.0022'), + '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'), }, - 'gpt-4o-mini': { - 'input': Decimal('0.0003'), - 'output': Decimal('0.0003'), + 'gpt-5.6-sol': { + 'input': Decimal('0.0025'), # $5 / 1M tokens + 'output': Decimal('0.015'), # $30 / 1M tokens 'web_search': { - 'low': Decimal('12.5'), # 1 call - 'medium': Decimal('13.75'), # 1 call - 'high': Decimal('15'), # 1 call + '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-4o': { - 'input': Decimal('0.005'), - 'output': Decimal('0.005'), + 'gpt-5.6-luna': { + 'input': Decimal('0.0005'), # $1 / 1M tokens + 'output': Decimal('0.003'), # $6 / 1M tokens 'web_search': { - 'low': Decimal('15'), # 1 call - 'medium': Decimal('17.5'), # 1 call - 'high': Decimal('25'), # 1 call + '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.6-terra': { + 'input': Decimal('0.00125'), # $2.5 / 1M tokens + 'output': Decimal('0.0075'), # $15 / 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'), }, - 'gpt-oss-120b': {'input': Decimal('0.0002'), 'output': Decimal('0.0002')}, - } - - TOOLS_TOKEN_COSTS = { - 'text-embedding-3-small': {'output': Decimal('0.00001')}, - 'text-embedding-3-large': {'output': Decimal('0.000065')}, } - EMBEDDING_MODEL_FOR_BILLING = 'text-embedding-3-small' + BASE_VERSION = 'gpt-5.6-luna' TOKEN_LIMITS = { - 'o3-mini': 100_000, - 'gpt-4o-mini': 64_000, - 'gpt-4o': 64_000, - 'gpt-4.5-preview': 64_000, - 'gpt-oss-120b': 65_500, + 'gpt-5.5': 1_050_000 // 2, + 'gpt-5.6-sol': 1_050_000 // 2, + 'gpt-5.6-luna': 1_050_000 // 2, + 'gpt-5.6-terra': 1_050_000 // 2, } - def __init__(self, store: BaseStore) -> None: - super().__init__(store) - self.logger = logging.getLogger(self.__class__.__name__) + 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_5_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.\n\n' + 'File output policy:\n' + '- Do not generate files, attachments, or download links\n' + '- Never offer to save, export, or provide files\n' + '- Include all content directly in your text response' + ) + + BASE_6_SYSTEM = ( + 'You are an analytical assistant optimized for GPT-5.6.\n' + 'Provide accurate, evidence-based, and practical answers.\n\n' + 'Guidelines:\n' + '- Never fabricate facts or technical behavior\n' + '- Distinguish facts from assumptions and uncertainty\n' + '- Use available retrieved evidence when relevant\n' + '- Prefer official documentation and primary sources\n' + '- Lead with the conclusion, then include supporting evidence and material caveats\n' + '- Mention limitations only when they affect the answer\n' + '- Avoid unnecessary repetition or speculative discussion\n\n' + 'File output policy:\n' + '- Do not generate files, attachments, or download links\n' + '- Never offer to save, export, or provide files\n' + '- Include all content directly in your text response' + ) - def make( + def save_results( self, - input_message: Message, + results: list[BaseMessage], + elapsed_time: timedelta, + generated_image: bytes | None, save: bool = True, ) -> list[Message]: - start_time = time.time() - info = input_message.info.copy() - model_name = info.pop('version', None) - if model_name is None or model_name not in self.TOKENS_COST: - raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) - 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 - normalized_image = None - embedding_tokens = 0 - chunks = [] - text_chunks: list[str] = [] - if file: - if model_name == 'gpt-oss-120b': - raise FileUploadUnsupported - file_service = FileProcessingService - file_bytes = input_message.file.read() - kind = filetype.guess(file_bytes[:550]) - 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 - normalized_image, image_size, image_data = self._get_image_data(file_bytes, file_extension) - input_content.append(image_data) - else: - raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP']) - for proxy in Proxy.objects.all(): - http_client = httpx.Client(proxy=f'{proxy.protocol}://{proxy.address}') - try: - self.llm = ChatOpenAI( - model=model_name, - http_client=http_client, - ) - self.llm.temperature = info.pop('temperature', 0.5) - self.llm.model_kwargs = { - 'presence_penalty': info.pop('presence', 0), - 'top_p': info.pop('top_p', 0.5), - } - self.llm.tiktoken_model_name = 'gpt-4' - chat_history = self.get_chat_history(model_name=model_name) - chat_history.add_message(HumanMessage(content=input_message.content)) - conversation = RunnableWithMessageHistory( - runnable=self.llm, - get_session_history=lambda _: chat_history, - ) - 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, model_name - ) - output_tokens = 0 - self.assert_enough_balance( - 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) - 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 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}' - ) - json_data = {'model': f'openai/{model_name}', 'messages': messages} - response = httpx.post( - url='https://openrouter.ai/api/v1/chat/completions', - proxy=f'{proxy.protocol}://{proxy.address}', - headers={'Authorization': f'Bearer {settings.OPENROUTER_API_KEY}'}, - timeout=600, - json=json_data, - ) - if ( - (data := response.json()) - and data.get('choices') - and ( - content := ','.join( - [choice['message']['content'] for choice in data.get('choices')]) - ) - ): - input_tokens = response.json()['usage']['prompt_tokens'] - output_tokens = response.json()['usage']['completion_tokens'] - response = AIMessage(content=content.replace('\\n', '\n')) - else: - raise Exception('GPT not answer correctly, please retry later') - elif model_name == 'o3-mini': - 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': 'text', 'text': input_message.content}, - image_data, - ] - 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, - '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} - for msg in chat_history.messages - ] - messages.insert(0, {'role': 'system', 'content': system.content}) - messages.insert(0, {'role': 'system', 'content': user_system_prompt}) - search_context_size, json_data = self.get_web_search_data( - info.get('web_search', 'Средний контекст'), model_name, messages - ) - info['web_search'] = search_context_size - 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}' - ) - input_tokens, output_tokens, response = self.call_openai_api( - proxy=proxy, endpoint='responses', json_data=json_data - ) - elif image: - response = self.llm.invoke(llm_input) - chat_history.add_ai_message(response) - 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] - 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', - ) - user_input = [ - SystemMessage(content=user_system_prompt), - HumanMessage( - EmbeddingService.make_embeddings_prompt( - document_name=document_name, - section_texts=file_data, - question=input_message.content, - ) - ), - ] - input_tokens += self.count_text_tokens(user_input) - response = conversation.invoke( - {'input': user_input}, - config={'configurable': {'session_id': 'default'}}, - ) - else: - input = [ - SystemMessage(content=user_system_prompt), - HumanMessage( - content=( - 'Используй системный промпт. Содержание файла: ' - f'{"".join(text_chunks)}. Вопрос: {input_message.content}' - ) - ), - ] - input_tokens += self.count_text_tokens(input) - response = conversation.invoke( - {'input': input}, - config={'configurable': {'session_id': 'default'}}, - ) - else: - response = conversation.invoke( - {'input': llm_input}, - config={'configurable': {'session_id': 'default'}}, - ) - chat_history.add_ai_message(response) - - if output_tokens == 0: - output_tokens = self.count_text_tokens([response]) - - 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}') - - process_time = timedelta(seconds=time.time() - start_time) - self.handle_invoice( - input_message.content_object.model, - input_tokens, - output_tokens, - self.llm.model_name, - info, - embedding_tokens - ) - msgs = self.save_results([response], process_time, save) - http_client.close() - return msgs - finally: - http_client.close() - - def get_chat_history(self, model_name: str) -> InMemoryChatMessageHistory: - if isinstance(self.store, Chat): - air_messages = Message.objects.filter( - chats_chats_messages=self.store, - is_deleted=False, - is_sent=True, - ).order_by('-created_at')[1:] - elif isinstance(self.store, APIStore): - air_messages = Message.objects.none() - elif isinstance(self.store, Copywrite): - air_messages = Message.objects.filter( - copywrite_copywrites_messages=self.store, - is_deleted=False, - is_sent=True, - ).order_by('-created_at')[1:] - - token_limits = self.TOKEN_LIMITS - tokens = 0 - history: List[BaseMessage] = [] - 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 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.' - ) - )) - memory.add_message(SystemMessage( - content=( - 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' - 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' - 'Любая информация должна быть преобразована в простой строковый текст (str).' - ) - )) - memory.add_messages(list(reversed(history))) - return memory - - def assert_enough_balance( - self, - input_tokens: int, - image_size: tuple | None, - model: str = 'gpt-3.5-turbo', - embedding_tokens: int = 0, - output_tokens: int = 0, - ): - balance = PaymentPlanSelector(self.store.user).get_current_balance() - total_tokens = input_tokens - if image_size: - total_tokens += self.count_image_tokens(image_size) - 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'] + messages = [ + Message( + content=result.content, + elapsed_time=elapsed_time, + content_object=self.store, + file=File(BytesIO(generated_image), '.png') if generated_image else None, ) - output_cost = self.TOKENS_COST[model]['output'] * output_tokens - if input_cost + output_cost > balance: - raise InsufficientBalance(balance, input_cost + output_cost) + for result in results + ] + if save: + return Message.objects.bulk_create(messages) + return messages def calculate_price( self, @@ -460,6 +205,7 @@ class Chatgpt(SimpleService): model: str, info: dict, embedding_tokens: int = 0, + image: bool = False, *args, **kwargs, ) -> Decimal: @@ -467,415 +213,355 @@ class Chatgpt(SimpleService): 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'] 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 + if image: + price += self.TOKENS_COST[model]['generated_image'] return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - def count_image_tokens(self, image_size: tuple, model_version: str = 'gpt-4o') -> int: - extra_tokens = { - 'gpt-4o': { - 'tile_tokens': 170, - 'base_tokens': 85, - }, - 'gpt-4o-mini': { - 'tile_tokens': 5667, - 'base_tokens': 2833, - }, - } - width, height = image_size - - if max(width, height) > 2048: - a_ratio = width / height - width, height = (2048, int(2048 / a_ratio)) if a_ratio > 1 else (int(2048 * a_ratio), 2048) - if width >= height and height > 768: - width, height = int((768 / height) * width), 768 - elif height > width and width > 768: - width, height = 768, int((768 / width) * height) - tiles_size = ceil(width / 512) * ceil(height / 512) - - return ( - extra_tokens[model_version]['base_tokens'] - + extra_tokens[model_version]['tile_tokens'] * tiles_size - ) - - def count_text_tokens(self, messages: list[BaseMessage]) -> int: - encoding = tiktoken.get_encoding('o200k_base') - total_tokens = 0 - for message in messages: - if isinstance(message.content, str): - total_tokens += len(encoding.encode(message.content)) - elif any(isinstance(item, dict) for item in message.content): - total_tokens += len( - encoding.encode(''.join([input_data.get('text', '') for input_data in message.content])) + def make(self, input_message: Message, save: bool = True) -> list[Message]: + ctx: dict[str, Any] = {} + for proxy in Proxy.objects.all(): + json_data, predicted_input_tokens = self._build_payload(proxy, input_message, ctx) + model_name = ctx['model_name'] + 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} - {ctx["embedding_tokens"]}') + if generated_image: + self.logger.info( + f'Фиксированная цена за генерацию картинки - ' + f'{self.TOKENS_COST[model_name]["generated_image"]}' ) - else: - total_tokens += len(encoding.encode(''.join(message.content))) + self.logger.info( + f'Общее количество токенов для {model_name} - ' + f'{input_tokens + output_tokens + ctx["embedding_tokens"]}' + ) + process_time = timedelta(seconds=time.time() - ctx['start_time']) + self.handle_invoice( + input_message.content_object.model, + input_tokens, + output_tokens, + model_name, + ctx['info'], + ctx['embedding_tokens'], + generated_image, + ) + return self.save_results([response], process_time, generated_image, save) - return total_tokens + def make_stream(self, input_message: Message, save: bool = True) -> Iterator[str]: + ctx: dict[str, Any] = {} + content_parts: list[str] = [] + input_tokens = output_tokens = 0 + result = '' - def _get_image_data(self, file_bytes: bytes, file_extension: str) -> Tuple: - with Image.open(BytesIO(file_bytes)) as source_image: - normalized_image = source_image.convert('RGB') - format = 'jpeg' if file_extension not in ('png', 'jpeg', 'webp') else file_extension - with BytesIO() as buf: - normalized_image.save(buf, format=format) - image_url = f'data:image/{format};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' - image_size = normalized_image.size - normalized_image.close() - image_data = {'type': 'image_url', 'image_url': {'url': image_url}} - return normalized_image, image_size, image_data + try: + for proxy in Proxy.objects.all(): + json_data, predicted_input_tokens = self._build_payload(proxy, input_message, ctx, include_image_tool=False) + model_name = ctx['model_name'] + self.logger.info( + f'Predicted input tokens (responses/input_tokens) для {model_name} - {predicted_input_tokens}' + ) + stream = self.run_stream(json_data, proxy, model_name) + try: + while True: + chunk = next(stream) + if chunk: + content_parts.append(chunk) + yield chunk + except StopIteration as exc: + input_tokens, output_tokens = exc.value or (0, 0) + break + finally: + if content_parts and ctx: + result = ''.join(content_parts).replace('\\n', '\n') + response = AIMessage(content=result) + if output_tokens == 0: + output_tokens = self.count_text_tokens([response]) + model_name = ctx['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} - {ctx["embedding_tokens"]}' + ) + self.logger.info( + f'Общее количество токенов для {model_name} - ' + f'{input_tokens + output_tokens + ctx["embedding_tokens"]}' + ) + process_time = timedelta(seconds=time.time() - ctx['start_time']) + self.handle_invoice( + input_message.content_object.model, + input_tokens, + output_tokens, + model_name, + ctx['info'], + ctx['embedding_tokens'], + None, + ) + self.save_results([response], process_time, None, save) + if result: + return result - def _get_input_tokens(self, file, image, chunks, chat_history, llm_input, model_name=None): - input_embedding_tokens = 0 - if file and not image: - if sum([len(chunk.content) for chunk in chunks]) > 20_000: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks[:10]]) - input_embedding_tokens = len(chunks) * 600 - else: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks]) - elif image and model_name in ('gpt-4o', 'gpt-4o-mini'): - input_tokens = self.count_text_tokens(llm_input) - else: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input]) - return input_tokens, input_embedding_tokens + def _build_payload( + self, + proxy: Proxy, + input_message: Message, + ctx: dict[str, Any], + *, + include_image_tool: bool = True + ) -> tuple[dict[str, Any], int]: + if not ctx: + info = input_message.info.copy() + model_name = info.get('version', self.BASE_VERSION) + if model_name is None or model_name not in self.TOKENS_COST: + raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) + user_system_prompt = info.pop('system_prompt', '') + file = input_message.file + image = None + image_data = None + predict_embedding_tokens = 0 + predicted_input_price = Decimal(0) + chunks: list[HumanMessage] = [] + text_chunks: list[str] = [] + is_free_plan = self.store.user.plan.price <= 0 + if is_free_plan: + info.pop('code_interpreter', None) + info.pop('verbosity', None) + 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) + if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): + if is_free_plan: + raise PaidPlanRequiredError(gettext('File analysis')) + 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') + ctx.update( + { + 'start_time': time.time(), + 'info': info, + 'model_name': model_name, + 'user_system_prompt': user_system_prompt, + 'file': file, + 'image': image, + 'image_data': image_data, + 'embedding_tokens': 0, + 'predict_embedding_tokens': predict_embedding_tokens, + 'predicted_input_price': predicted_input_price, + 'chunks': chunks, + 'text_chunks': text_chunks, + 'chat_history': chat_history, + 'current_user_balance': current_user_balance, + 'is_free_plan': is_free_plan, + 'is_low_balance': is_low_balance, + 'has_full_access': not is_free_plan and not is_low_balance, + 'base_system': SystemMessage( + content=( + self.BASE_6_SYSTEM if model_name.startswith('gpt-5.6') else self.BASE_5_SYSTEM + ) + ), + } + ) - def get_web_search_data(self, search_size: str, model_name: str, messages: List[Dict[str, any]]): - search_context_sizes = { - 'Малый контекст': 'low', - 'Средний контекст': 'medium', - 'Большой контекст': 'high', - } - search_context_size = search_context_sizes.get(search_size) + model_name = ctx['model_name'] + info = ctx['info'] + history_messages = list(ctx['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': ctx['base_system'].content}) + messages.insert(0, {'role': 'system', 'content': ctx['user_system_prompt']}) json_data = { 'model': model_name, 'input': messages, - 'tools': [ + 'instructions': self.FORMATION_INSTRUCTIONS, + 'tools': [], + } + if ctx['has_full_access'] and include_image_tool: + json_data['tools'].append( { - 'type': 'web_search_preview', - 'search_context_size': search_context_size, - 'user_location': {'type': 'approximate', 'country': 'RU'}, + 'type': 'image_generation', + 'size': '1024x1024', + 'quality': 'medium', + 'model': 'gpt-image-1.5', } - ], - } - return search_context_size, json_data - - def call_openai_api( - self, proxy: Proxy, endpoint: str, json_data: Dict[str, Any] - ) -> Tuple[Any, Any, AIMessage] | Tuple[List[float], int]: - """ - A method for sending a request to official openai API - :param proxy: Proxy settings object with protocol and address. - :param endpoint: Str URL part for the OpenAI API request - :param json_data: Payload for the OpenAI API request - :return: Tuple of (input_tokens, output_tokens, AIMessage instance with response content) - :raises: Exception: If the response is invalid or incomplete - """ - 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=600, - ) as client: - 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')] - ) - ) - ): - input_tokens = resp.json()['usage']['prompt_tokens'] - output_tokens = resp.json()['usage']['completion_tokens'] - response = AIMessage(content=content) - return input_tokens, output_tokens, response - elif ( - endpoint == 'responses' - and (data := resp.json()) - and data.get('output') - 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 := next( - (item['result'] for item in data['output'] if item.get('result')), - None, - ) + if reasoning := info.get('reasoning'): + reasoning_data = { + 'Средний': 'low', + 'Высокий': 'medium', + } + effort = ( + 'none' + if ctx['is_free_plan'] + else 'low' + if ctx['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 + predicted_input_price = ctx['predicted_input_price'] + if web_search != 'Выключен': + search_context_sizes = { + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'medium', + 'Сверхвысокий': 'medium', + } + if ctx['has_full_access']: + json_data['tools'].append( + { + 'type': 'web_search', + 'search_context_size': search_context_sizes[web_search], + 'user_location': {'type': 'approximate', 'country': 'RU'}, + } ) - ): - 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 + info['web_search'] = search_context_sizes[web_search] + predicted_input_price += self.TOKENS_COST[model_name]['web_search'][ + search_context_sizes[web_search] + ] else: - raise Exception('GPT not answer correctly, please retry later') - - def save_results( - self, - results: list[BaseMessage], - elapsed_time: timedelta, - save: bool = True, - ) -> list[Message]: - messages = [ - Message( - content=result.content, - elapsed_time=elapsed_time, - content_object=self.store, - ) - for result in results - ] - if save: - return Message.objects.bulk_create(messages) - return messages - - def stream(self, input_message: Message, save: bool = True): - start_time = time.time() - self.llm = ChatOpenAI( - model=input_message.info.pop('version', 'gpt-3.5-turbo'), - temperature=input_message.info.pop('temperature', 0.5), - model_kwargs={ - 'presence_penalty': input_message.info.pop('presence', 0), - 'top_p': input_message.info.pop('top_p', 0.5), - }, + 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 ctx['image']: + messages[-1]['content'] = [ + {'type': 'input_text', 'text': input_message.content}, + {'type': 'input_image', 'image_url': ctx['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'] + + ctx['predict_embedding_tokens'] + * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] ) - chat_history = self.get_chat_history(model_name=input_message.info.pop('version', 'gpt-3.5-turbo')) - conversation = ConversationChain( - llm=self.llm, - memory=chat_history, - prompt=PromptTemplate( - input_variables=['history', 'input'], - template='System: Продолжи отвечать, используя историю диалога. История диалога:{history}.' - 'Human: {input}' - 'AI:', + if ctx['has_full_access'] and include_image_tool: + predicted_input_price += self.TOKENS_COST[model_name]['generated_image'] + max_output_tokens = max( + int( + (ctx['current_user_balance'] - predicted_input_price - Decimal('0.1')) + / self.TOKENS_COST[model_name]['output'] ), + 0, ) - self.assert_enough_balance(chat_history.messages) - - for chunk in conversation.stream(input=input_message.content): - if chunk: - content = chunk.get('content') - yield content - process_time = timedelta(seconds=time.time() - start_time) - self.handle_invoice( - 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, - content: str, - image: Optional[BufferedReader] = None, - context_messages: List[str] = [], - *, - configuration: Optional[ModelConfiguration] = None, - stream: bool = False, - ) -> str | Generator[str, None, None]: - # with httpx.Client() as client: - # data = {} - # with client.stream( - # 'POST', - # 'https://api.openai.com/v1/chat/completions', - # headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, - # json={ - # 'model': 'gpt-4o-mini', - # 'messages': [ - # { - # 'role': 'user', - # 'content': 'Hello! Generate a tale for 1500 symbols', - # } - # ], - # 'stream': True, - # }, - # ) as resp: - # ... + min_response_tokens = 300 if not ctx['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(ctx['current_user_balance'], cost) + json_data['max_output_tokens'] = max_output_tokens + if ctx['file'] and not ctx['image']: + if sum(len(chunk.content) for chunk in ctx['chunks']) > 20_000: + document_name = ( + ctx['chunks'][0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) + ctx['embedding_tokens'], file_data = EmbeddingService.get_large_file_data( + self.store.messages.first().pk, + ctx['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(ctx["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) + return json_data, predicted_input_tokens - for chunk in itertools.batched(TEMPORARY_TEST_TEXT, 10): - yield ''.join(chunk) + 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']) @@ -0,0 +1,889 @@ +import base64 +from datetime import timedelta +from decimal import Decimal +from io import BufferedReader, BytesIO +import itertools +import json +import logging +from math import ceil +import time +from typing import Any, Dict, Generator, List, Optional, Tuple + +from django.utils.translation import gettext_lazy as _ +import filetype +import httpx +from langchain.chains import ConversationChain +from langchain_core.chat_history import InMemoryChatMessageHistory +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, +) +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 +from ml_model.constants import TEMPORARY_TEST_TEXT +from ml_model.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + FileUploadUnsupported, + ModelVersionNotAvailable, +) +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 payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector +from poller.models import Proxy +from tools.chats.models import Chat +from tools.copywrite.models import Copywrite +from tools.public_api.models import APIStore + + +class Chatgpt_4(SimpleService): + """ + ChatGPT 4 Service + contains abstract method make, which makes a generation + """ + + NO_FILE_GENERATION_POLICY = ( + 'Do not generate files, attachments, or download links. ' + 'Never offer to save, export, or provide files — include all content directly in your text response.' + ) + + TOKENS_COST = { + 'o3-mini': { + 'input': Decimal('0.0022'), + 'output': Decimal('0.0022'), + }, + 'gpt-4o-mini': { + '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 + }, + }, + '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 + }, + }, + 'gpt-oss-120b': {'input': Decimal('0.0002'), 'output': Decimal('0.0002')}, + } + + TOOLS_TOKEN_COSTS = { + 'text-embedding-3-small': {'output': Decimal('0.00001')}, + 'text-embedding-3-large': {'output': Decimal('0.000065')}, + } + + EMBEDDING_MODEL_FOR_BILLING = 'text-embedding-3-small' + + TOKEN_LIMITS = { + 'o3-mini': 100_000, + 'gpt-4o-mini': 64_000, + 'gpt-4o': 64_000, + 'gpt-4.5-preview': 64_000, + 'gpt-oss-120b': 65_500, + } + + def __init__(self, store: BaseStore) -> None: + super().__init__(store) + self.logger = logging.getLogger(self.__class__.__name__) + + 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', None) + if model_name is None or model_name not in self.TOKENS_COST: + raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) + 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 + normalized_image = None + embedding_tokens = 0 + chunks = [] + text_chunks: list[str] = [] + if file: + if model_name == 'gpt-oss-120b': + raise FileUploadUnsupported + file_service = FileProcessingService + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:550]) + 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 + normalized_image, image_size, image_data = self._get_image_data(file_bytes, file_extension) + input_content.append(image_data) + else: + raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP']) + for proxy in Proxy.objects.all(): + http_client = httpx.Client(proxy=f'{proxy.protocol}://{proxy.address}') + try: + self.llm = ChatOpenAI( + model=model_name, + http_client=http_client, + ) + self.llm.temperature = info.pop('temperature', 0.5) + self.llm.model_kwargs = { + 'presence_penalty': info.pop('presence', 0), + 'top_p': info.pop('top_p', 0.5), + } + self.llm.tiktoken_model_name = 'gpt-4' + chat_history = self.get_chat_history(model_name=model_name) + chat_history.add_message(HumanMessage(content=input_message.content)) + conversation = RunnableWithMessageHistory( + runnable=self.llm, + get_session_history=lambda _: chat_history, + ) + 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, model_name + ) + output_tokens = 0 + self.assert_enough_balance( + 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) + 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 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}' + ) + json_data = {'model': f'openai/{model_name}', 'messages': messages} + response = httpx.post( + url='https://openrouter.ai/api/v1/chat/completions', + proxy=f'{proxy.protocol}://{proxy.address}', + headers={'Authorization': f'Bearer {settings.OPENROUTER_API_KEY}'}, + timeout=600, + json=json_data, + ) + if ( + (data := response.json()) + and data.get('choices') + and ( + content := ','.join( + [choice['message']['content'] for choice in data.get('choices')]) + ) + ): + input_tokens = response.json()['usage']['prompt_tokens'] + output_tokens = response.json()['usage']['completion_tokens'] + response = AIMessage(content=content.replace('\\n', '\n')) + else: + raise Exception('GPT not answer correctly, please retry later') + elif model_name == 'o3-mini': + 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': 'text', 'text': input_message.content}, + image_data, + ] + 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, + '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} + for msg in chat_history.messages + ] + messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) + search_context_size, json_data = self.get_web_search_data( + info.get('web_search', 'Средний контекст'), model_name, messages + ) + info['web_search'] = search_context_size + 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}' + ) + input_tokens, output_tokens, response = self.call_openai_api( + proxy=proxy, endpoint='responses', json_data=json_data + ) + elif image: + response = self.llm.invoke(llm_input) + chat_history.add_ai_message(response) + 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] + 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', + ) + user_input = [ + SystemMessage(content=user_system_prompt), + HumanMessage( + EmbeddingService.make_embeddings_prompt( + document_name=document_name, + section_texts=file_data, + question=input_message.content, + ) + ), + ] + input_tokens += self.count_text_tokens(user_input) + response = conversation.invoke( + {'input': user_input}, + config={'configurable': {'session_id': 'default'}}, + ) + else: + input = [ + SystemMessage(content=user_system_prompt), + HumanMessage( + content=( + 'Используй системный промпт. Содержание файла: ' + f'{"".join(text_chunks)}. Вопрос: {input_message.content}' + ) + ), + ] + input_tokens += self.count_text_tokens(input) + response = conversation.invoke( + {'input': input}, + config={'configurable': {'session_id': 'default'}}, + ) + else: + response = conversation.invoke( + {'input': llm_input}, + config={'configurable': {'session_id': 'default'}}, + ) + chat_history.add_ai_message(response) + + if output_tokens == 0: + output_tokens = self.count_text_tokens([response]) + + 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}') + + process_time = timedelta(seconds=time.time() - start_time) + self.handle_invoice( + input_message.content_object.model, + input_tokens, + output_tokens, + self.llm.model_name, + info, + embedding_tokens + ) + msgs = self.save_results([response], process_time, save) + http_client.close() + return msgs + finally: + http_client.close() + + def get_chat_history(self, model_name: str) -> InMemoryChatMessageHistory: + if isinstance(self.store, Chat): + air_messages = Message.objects.filter( + chats_chats_messages=self.store, + is_deleted=False, + is_sent=True, + ).order_by('-created_at')[1:] + elif isinstance(self.store, APIStore): + air_messages = Message.objects.none() + elif isinstance(self.store, Copywrite): + air_messages = Message.objects.filter( + copywrite_copywrites_messages=self.store, + is_deleted=False, + is_sent=True, + ).order_by('-created_at')[1:] + + token_limits = self.TOKEN_LIMITS + tokens = 0 + history: List[BaseMessage] = [] + 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 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=( + 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' + 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' + 'Любая информация должна быть преобразована в простой строковый текст (str).\n' + 'Не генерируй файлы и не предоставляй ссылки на скачивание файлов. ' + 'Весь контент давай прямо в тексте ответа.' + ) + )) + memory.add_messages(list(reversed(history))) + return memory + + def assert_enough_balance( + self, + input_tokens: int, + image_size: tuple | None, + model: str = 'gpt-3.5-turbo', + embedding_tokens: int = 0, + output_tokens: int = 0, + ): + balance = PaymentPlanSelector(self.store.user).get_current_balance() + total_tokens = input_tokens + if image_size: + total_tokens += self.count_image_tokens(image_size) + 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'] + ) + output_cost = self.TOKENS_COST[model]['output'] * output_tokens + if input_cost + output_cost > balance: + raise InsufficientBalance(balance, input_cost + output_cost) + + def calculate_price( + self, + input_tokens: int, + output_tokens: int, + model: str, + info: dict, + embedding_tokens: int = 0, + *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 + ) + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def count_image_tokens(self, image_size: tuple, model_version: str = 'gpt-4o') -> int: + extra_tokens = { + 'gpt-4o': { + 'tile_tokens': 170, + 'base_tokens': 85, + }, + 'gpt-4o-mini': { + 'tile_tokens': 5667, + 'base_tokens': 2833, + }, + } + width, height = image_size + + if max(width, height) > 2048: + a_ratio = width / height + width, height = (2048, int(2048 / a_ratio)) if a_ratio > 1 else (int(2048 * a_ratio), 2048) + if width >= height and height > 768: + width, height = int((768 / height) * width), 768 + elif height > width and width > 768: + width, height = 768, int((768 / width) * height) + tiles_size = ceil(width / 512) * ceil(height / 512) + + return ( + extra_tokens[model_version]['base_tokens'] + + extra_tokens[model_version]['tile_tokens'] * tiles_size + ) + + def count_text_tokens(self, messages: list[BaseMessage]) -> int: + encoding = tiktoken.get_encoding('o200k_base') + total_tokens = 0 + for message in messages: + if isinstance(message.content, str): + total_tokens += len(encoding.encode(message.content)) + elif any(isinstance(item, dict) for item in message.content): + total_tokens += len( + encoding.encode(''.join([input_data.get('text', '') for input_data in message.content])) + ) + else: + total_tokens += len(encoding.encode(''.join(message.content))) + + return total_tokens + + def _get_image_data(self, file_bytes: bytes, file_extension: str) -> Tuple: + with Image.open(BytesIO(file_bytes)) as source_image: + normalized_image = source_image.convert('RGB') + format = 'jpeg' if file_extension not in ('png', 'jpeg', 'webp') else file_extension + with BytesIO() as buf: + normalized_image.save(buf, format=format) + image_url = f'data:image/{format};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + image_size = normalized_image.size + normalized_image.close() + image_data = {'type': 'image_url', 'image_url': {'url': image_url}} + return normalized_image, image_size, image_data + + def _get_input_tokens(self, file, image, chunks, chat_history, llm_input, model_name=None): + input_embedding_tokens = 0 + if file and not image: + if sum([len(chunk.content) for chunk in chunks]) > 20_000: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks[:10]]) + input_embedding_tokens = len(chunks) * 600 + else: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks]) + elif image and model_name in ('gpt-4o', 'gpt-4o-mini'): + input_tokens = self.count_text_tokens(llm_input) + else: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input]) + return input_tokens, input_embedding_tokens + + def get_web_search_data(self, search_size: str, model_name: str, messages: List[Dict[str, any]]): + search_context_sizes = { + 'Малый контекст': 'low', + 'Средний контекст': 'medium', + 'Большой контекст': 'high', + } + search_context_size = search_context_sizes.get(search_size) + json_data = { + 'model': model_name, + 'input': messages, + 'tools': [ + { + 'type': 'web_search_preview', + 'search_context_size': search_context_size, + 'user_location': {'type': 'approximate', 'country': 'RU'}, + } + ], + } + return search_context_size, json_data + + def call_openai_api( + self, proxy: Proxy, endpoint: str, json_data: Dict[str, Any] + ) -> Tuple[Any, Any, AIMessage] | Tuple[List[float], int]: + """ + A method for sending a request to official openai API + :param proxy: Proxy settings object with protocol and address. + :param endpoint: Str URL part for the OpenAI API request + :param json_data: Payload for the OpenAI API request + :return: Tuple of (input_tokens, output_tokens, AIMessage instance with response content) + :raises: Exception: If the response is invalid or incomplete + """ + 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=600, + ) as client: + 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')] + ) + ) + ): + input_tokens = resp.json()['usage']['prompt_tokens'] + output_tokens = resp.json()['usage']['completion_tokens'] + response = AIMessage(content=content) + return input_tokens, output_tokens, response + elif ( + endpoint == 'responses' + and (data := resp.json()) + and data.get('output') + 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 := next( + (item['result'] for item in data['output'] if item.get('result')), + None, + ) + ) + ): + 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') + + def save_results( + self, + results: list[BaseMessage], + elapsed_time: timedelta, + save: bool = True, + ) -> list[Message]: + messages = [ + Message( + content=result.content, + elapsed_time=elapsed_time, + content_object=self.store, + ) + for result in results + ] + if save: + return Message.objects.bulk_create(messages) + return messages + + def stream(self, input_message: Message, save: bool = True): + start_time = time.time() + self.llm = ChatOpenAI( + model=input_message.info.pop('version', 'gpt-3.5-turbo'), + temperature=input_message.info.pop('temperature', 0.5), + model_kwargs={ + 'presence_penalty': input_message.info.pop('presence', 0), + 'top_p': input_message.info.pop('top_p', 0.5), + }, + ) + chat_history = self.get_chat_history(model_name=input_message.info.pop('version', 'gpt-3.5-turbo')) + conversation = ConversationChain( + llm=self.llm, + memory=chat_history, + prompt=PromptTemplate( + input_variables=['history', 'input'], + template='System: Продолжи отвечать, используя историю диалога. История диалога:{history}.' + 'Human: {input}' + 'AI:', + ), + ) + self.assert_enough_balance(chat_history.messages) + + for chunk in conversation.stream(input=input_message.content): + if chunk: + content = chunk.get('content') + yield content + process_time = timedelta(seconds=time.time() - start_time) + self.handle_invoice( + 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, + content: str, + image: Optional[BufferedReader] = None, + context_messages: List[str] = [], + *, + configuration: Optional[ModelConfiguration] = None, + stream: bool = False, + ) -> str | Generator[str, None, None]: + # with httpx.Client() as client: + # data = {} + # with client.stream( + # 'POST', + # 'https://api.openai.com/v1/chat/completions', + # headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, + # json={ + # 'model': 'gpt-4o-mini', + # 'messages': [ + # { + # 'role': 'user', + # 'content': 'Hello! Generate a tale for 1500 symbols', + # } + # ], + # 'stream': True, + # }, + # ) as resp: + # ... + + for chunk in itertools.batched(TEMPORARY_TEST_TEXT, 10): + yield ''.join(chunk) @@ -13,14 +13,14 @@ from ml_model.exceptions import ( InvalidParameterError, ) from ml_model.models import NeuronModel -from ml_model.services import Chatgpt +from ml_model.services.chatgpt_4 import Chatgpt_4 from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from poller.models import Proxy -class Chatgpt_5(Chatgpt): +class Chatgpt_5(Chatgpt_4): TOKENS_COST = { 'gpt-5': { 'input': Decimal('0.000625'), @@ -21,14 +21,14 @@ from ml_model.exceptions import ( PaidPlanRequiredError, ) from ml_model.models import NeuronModel -from ml_model.services import Chatgpt +from ml_model.services.chatgpt_4 import Chatgpt_4 from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from poller.models import Proxy -class Chatgpt_5_4(Chatgpt): +class Chatgpt_5_4(Chatgpt_4): TOKENS_COST = { 'gpt-5.4': { 'input': Decimal('0.00125'), @@ -1,559 +0,0 @@ -import base64 -from typing import Any, Iterator - -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, - ModelVersionNotAvailable, - PaidPlanRequiredError, -) -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 ml_model.services.base import StreamSimpleService -from ml_model.services.openai_stream_mixin import OpenAIStreamMixin -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, StreamSimpleService, OpenAIStreamMixin): - 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'), - }, - 'gpt-5.6-sol': { - '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'), - }, - 'gpt-5.6-luna': { - 'input': Decimal('0.0005'), # $1 / 1M tokens - 'output': Decimal('0.003'), # $6 / 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'), - }, - 'gpt-5.6-terra': { - 'input': Decimal('0.00125'), # $2.5 / 1M tokens - 'output': Decimal('0.0075'), # $15 / 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'), - }, - } - - BASE_VERSION = 'gpt-5.6-luna' - - TOKEN_LIMITS = { - 'gpt-5.5': 1_050_000 // 2, - 'gpt-5.6-sol': 1_050_000 // 2, - 'gpt-5.6-luna': 1_050_000 // 2, - 'gpt-5.6-terra': 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_5_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.' - ) - - BASE_6_SYSTEM = ( - 'You are an analytical assistant optimized for GPT-5.6.\n' - 'Provide accurate, evidence-based, and practical answers.\n\n' - 'Guidelines:\n' - '- Never fabricate facts or technical behavior\n' - '- Distinguish facts from assumptions and uncertainty\n' - '- Use available retrieved evidence when relevant\n' - '- Prefer official documentation and primary sources\n' - '- Lead with the conclusion, then include supporting evidence and material caveats\n' - '- Mention limitations only when they affect the answer\n' - '- Avoid unnecessary repetition or speculative discussion' - ) - - 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]: - ctx: dict[str, Any] = {} - for proxy in Proxy.objects.all(): - json_data, predicted_input_tokens = self._build_payload(proxy, input_message, ctx) - model_name = ctx['model_name'] - 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} - {ctx["embedding_tokens"]}') - if generated_image: - self.logger.info( - f'Фиксированная цена за генерацию картинки - ' - f'{self.TOKENS_COST[model_name]["generated_image"]}' - ) - self.logger.info( - f'Общее количество токенов для {model_name} - ' - f'{input_tokens + output_tokens + ctx["embedding_tokens"]}' - ) - process_time = timedelta(seconds=time.time() - ctx['start_time']) - self.handle_invoice( - input_message.content_object.model, - input_tokens, - output_tokens, - model_name, - ctx['info'], - ctx['embedding_tokens'], - generated_image, - ) - return self.save_results([response], process_time, generated_image, save) - - def make_stream(self, input_message: Message, save: bool = True) -> Iterator[str]: - ctx: dict[str, Any] = {} - content_parts: list[str] = [] - input_tokens = output_tokens = 0 - result = '' - - try: - for proxy in Proxy.objects.all(): - json_data, predicted_input_tokens = self._build_payload(proxy, input_message, ctx, include_image_tool=False) - model_name = ctx['model_name'] - self.logger.info( - f'Predicted input tokens (responses/input_tokens) для {model_name} - {predicted_input_tokens}' - ) - stream = self.run_stream(json_data, proxy, model_name) - try: - while True: - chunk = next(stream) - if chunk: - content_parts.append(chunk) - yield chunk - except StopIteration as exc: - input_tokens, output_tokens = exc.value or (0, 0) - break - finally: - if content_parts and ctx: - result = ''.join(content_parts).replace('\\n', '\n') - response = AIMessage(content=result) - if output_tokens == 0: - output_tokens = self.count_text_tokens([response]) - model_name = ctx['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} - {ctx["embedding_tokens"]}' - ) - self.logger.info( - f'Общее количество токенов для {model_name} - ' - f'{input_tokens + output_tokens + ctx["embedding_tokens"]}' - ) - process_time = timedelta(seconds=time.time() - ctx['start_time']) - self.handle_invoice( - input_message.content_object.model, - input_tokens, - output_tokens, - model_name, - ctx['info'], - ctx['embedding_tokens'], - None, - ) - self.save_results([response], process_time, None, save) - if result: - return result - - def _build_payload( - self, - proxy: Proxy, - input_message: Message, - ctx: dict[str, Any], - *, - include_image_tool: bool = True - ) -> tuple[dict[str, Any], int]: - if not ctx: - info = input_message.info.copy() - model_name = info.get('version', self.BASE_VERSION) - if model_name is None or model_name not in self.TOKENS_COST: - raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) - user_system_prompt = info.pop('system_prompt', '') - file = input_message.file - image = None - image_data = None - predict_embedding_tokens = 0 - predicted_input_price = Decimal(0) - chunks: list[HumanMessage] = [] - text_chunks: list[str] = [] - is_free_plan = self.store.user.plan.price <= 0 - if is_free_plan: - info.pop('code_interpreter', None) - info.pop('verbosity', None) - 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) - if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): - if is_free_plan: - raise PaidPlanRequiredError(gettext('File analysis')) - 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') - ctx.update( - { - 'start_time': time.time(), - 'info': info, - 'model_name': model_name, - 'user_system_prompt': user_system_prompt, - 'file': file, - 'image': image, - 'image_data': image_data, - 'embedding_tokens': 0, - 'predict_embedding_tokens': predict_embedding_tokens, - 'predicted_input_price': predicted_input_price, - 'chunks': chunks, - 'text_chunks': text_chunks, - 'chat_history': chat_history, - 'current_user_balance': current_user_balance, - 'is_free_plan': is_free_plan, - 'is_low_balance': is_low_balance, - 'has_full_access': not is_free_plan and not is_low_balance, - 'base_system': SystemMessage( - content=( - self.BASE_6_SYSTEM if model_name.startswith('gpt-5.6') else self.BASE_5_SYSTEM - ) - ), - } - ) - - model_name = ctx['model_name'] - info = ctx['info'] - history_messages = list(ctx['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': ctx['base_system'].content}) - messages.insert(0, {'role': 'system', 'content': ctx['user_system_prompt']}) - json_data = { - 'model': model_name, - 'input': messages, - 'instructions': self.FORMATION_INSTRUCTIONS, - 'tools': [], - } - if ctx['has_full_access'] and include_image_tool: - 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 ctx['is_free_plan'] - else 'low' - if ctx['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 - predicted_input_price = ctx['predicted_input_price'] - if web_search != 'Выключен': - search_context_sizes = { - 'Низкий': 'low', - 'Средний': 'medium', - 'Высокий': 'medium', - 'Сверхвысокий': 'medium', - } - if ctx['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 ctx['image']: - messages[-1]['content'] = [ - {'type': 'input_text', 'text': input_message.content}, - {'type': 'input_image', 'image_url': ctx['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'] - + ctx['predict_embedding_tokens'] - * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] - ) - if ctx['has_full_access'] and include_image_tool: - predicted_input_price += self.TOKENS_COST[model_name]['generated_image'] - max_output_tokens = max( - int( - (ctx['current_user_balance'] - predicted_input_price - Decimal('0.1')) - / self.TOKENS_COST[model_name]['output'] - ), - 0, - ) - min_response_tokens = 300 if not ctx['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(ctx['current_user_balance'], cost) - json_data['max_output_tokens'] = max_output_tokens - if ctx['file'] and not ctx['image']: - if sum(len(chunk.content) for chunk in ctx['chunks']) > 20_000: - document_name = ( - ctx['chunks'][0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] - ) - ctx['embedding_tokens'], file_data = EmbeddingService.get_large_file_data( - self.store.messages.first().pk, - ctx['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(ctx["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) - return json_data, predicted_input_tokens - - 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']) @@ -31,7 +31,7 @@ from ml_model.exceptions import ( CorruptedFileError, ) from ml_model.models import NeuronModel -from ml_model.services import Chatgpt +from ml_model.services.chatgpt_4 import Chatgpt_4 from django.core.files.uploadedfile import UploadedFile @@ -55,7 +55,7 @@ from ml_model.tasks import drop_redis_vectors from ml_model.constants import ANCHORS -class Raifgpt(Chatgpt): +class Raifgpt(Chatgpt_4): EMBEDDING_MODEL_FOR_BILLING = 'text-embedding-3-large' def make( @@ -6,13 +6,13 @@ from django.utils.translation import gettext_lazy as _ from authentication.models import CustomUserModel from messages.models import Message from ml_model.models import NeuronModel -from ml_model.services.chatgpt import Chatgpt +from ml_model.services.chatgpt_4 import Chatgpt_4 from ml_model.services.dalle import Dalle class ModelPaymentSelector: MODEL_NAMES: Dict[str, Tuple[Type[Any], Type[Any]]] = { - 'ChatGPT': (Message, Chatgpt), + 'ChatGPT': (Message, Chatgpt_4), 'Dalle': (Message, Dalle), } @@ -7,7 +7,7 @@ from django.template import Context, Template from authentication.models.user import CustomUserModel from core.typing import PrimitiveType -from ml_model.services.chatgpt import Chatgpt +from ml_model.services.chatgpt_4 import Chatgpt_4 from tools.copywrite.models import ( Copywrite, OverridenVariable, @@ -106,7 +106,7 @@ class CopywriteService: content = str(blueprint.render(Context(ctx))) else: raise NotImplementedError - for chunk in Chatgpt.evaluate(content=content, stream=True): + for chunk in Chatgpt_4.evaluate(content=content, stream=True): yield chunk @classmethod