@@ -9,11 +9,13 @@ import filetype from PIL import Image from messages.models import Message -from ml_model.exceptions import FileExtensionNotSupported, ModelVersionNotAvailable +from ml_model.exceptions import FileExtensionNotSupported, FileUploadUnsupported, ModelVersionNotAvailable from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run +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 @@ -35,8 +37,22 @@ class Claude(SimpleService): 'input': Decimal('1500'), 'output': Decimal('7500'), }, # 1M tokens + 'claude-fable-5': { + 'input': Decimal('3000'), + 'output': Decimal('15000'), + }, # 1M tokens } + FABLE_SYSTEM_PROMPT = """ + You are a helpful assistant. + Answer the user’s question directly and accurately. + If the request is simple, respond briefly. + If the request is complex, provide a structured and clear explanation. + If context is missing, make reasonable assumptions and continue. + Use tools when they are available and clearly useful. + Keep responses natural, grounded, and consistent with the user’s intent. + """ + TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} def calculate_price( @@ -70,12 +86,33 @@ class Claude(SimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'anthropic/{version_slug}' system_prompt = input_message.info.pop('system_prompt', '') - callback_data = {'provider': {'order': ['Anthropic']}, **input_message.info} + callback_data = {'provider': {'order': ['anthropic']}, **input_message.info, 'tools': []} messages = [ {'role': 'system', 'content': system_prompt}, - *self.get_chat_history(), {'role': 'user', 'content': input_message.content}, ] + if version_slug == 'claude-fable-5': + current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() + if current_user_balance < (cost := Decimal('100')) and input_message.file: + raise InsufficientBalance(current_user_balance, cost) + messages.insert(0, {'role': 'system', 'content': self.FABLE_SYSTEM_PROMPT}) + if reasoning := input_message.info.get('reasoning'): + reasoning_data = { + 'Средний': 'low', + 'Высокий': 'medium', + } + callback_data['reasoning'] = {'effort': reasoning_data[reasoning]} + callback_data['tools'].append( + { + 'type': 'openrouter:web_search', + 'parameters': { + 'engine': 'parallel', + 'max_results': 1, + 'max_total_results': 3, + 'search_context_size': 'low', + }, + } + ) file = input_message.file embedding_tokens = 0 if file: @@ -118,14 +155,16 @@ class Claude(SimpleService): with Image.open(file) as normalized_image: with BytesIO() as buf: normalized_image.save(buf, format=format) - image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + image_url = ( + f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + ) messages[-1]['content'] = [ {'type': 'text', 'text': input_message.content}, {'type': 'image_url', 'image_url': {'url': image_url}}, ] except Exception: raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG']) - result = openrouter_run(f"{version}:online", messages, callback_data, 'Claude') + result = openrouter_run(f'{version}', messages, callback_data, 'Claude') process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, @@ -1,135 +1,33 @@ -import time -import requests - -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -from django.core.files import File -from django.utils.translation import gettext as _ - from messages.models import Message -from ml_model.exceptions import InvalidParameterError -from ml_model.services.base import SimpleService -from ml_model.tasks import replicate_run - -from payments.exceptions.insufficient_balance import InsufficientBalance -from payments.selectors.payment_plan_selector import PaymentPlanSelector +from ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.seedance_2_dreamina import Seedance_2_Dreamina -class Hunyuan(SimpleService): - UNIT_PRICE = Decimal('3') - MOTION_MODES = { - 'Плавное движение': 'smooth', - 'Обычное движение': 'normal', - } - STYLES = { - 'Без стиля': 'None', - 'Аниме': 'anime', - '3D анимация': '3d_animation', - 'Пластилин': 'clay', - 'Киберпанк': 'cyberpunk', - 'Комикс': 'comic', - } - PRICING_UNITS = { - '540p': { - 5: { - 'normal': 30, - 'smooth': 60, - }, - 8: { - 'normal': 60, - }, - }, - '720p': { - 5: { - 'normal': 40, - 'smooth': 80, - }, - 8: { - 'normal': 80, - }, - }, - '1080p': { - 5: { - 'normal': 80, - }, - }, +class Hunyuan(Seedance_2_Dreamina): + PROXY_VERSION_MAPPING = { + 'hunyuan-video': 'dreamina-seedance-2-0-fast', + 'hunyuan-video-pro': 'dreamina-seedance-2-0', } @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - quality = info['quality'] - duration = info['duration'] - motion_mode_ru = info['motion_mode'] - sound_effect_switch = info['sound_effect_switch'] + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() + version = info.get('version', 'hunyuan-video') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): try: - price = cls.UNIT_PRICE * cls.PRICING_UNITS[quality][duration][cls.MOTION_MODES[motion_mode_ru]] - except KeyError: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: return None - if sound_effect_switch: - price += 10 * cls.UNIT_PRICE - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - - def calculate_price( - self, quality: str, duration: int, motion_mode: str, sound_effect_switch: bool - ) -> Decimal: - price = self.UNIT_PRICE * self.PRICING_UNITS[quality][duration][motion_mode] - if sound_effect_switch: - price += 10 * self.UNIT_PRICE - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - - def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: - msg = Message( - content=content, - content_object=self.store, - elapsed_time=t, - file=File(BytesIO(requests.get(video).content), '.mp4'), - ) - if save: - return Message.objects.bulk_create([msg]) - return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: - quality = input_message.info.get('quality') - duration = input_message.info.get('duration') - motion_mode_ru = input_message.info.pop('motion_mode') - motion_mode = self.MOTION_MODES.get(motion_mode_ru, 'normal') - sound_effect_switch = input_message.info.get('sound_effect_switch', False) - modes_for_duration = self.PRICING_UNITS[quality].get(duration) - if not modes_for_duration: - raise InvalidParameterError( - _('This video duration is not allowed for %(quality)s quality.') % {'quality': quality} - ) - units = modes_for_duration.get(motion_mode, {}) - if not units: - raise InvalidParameterError( - _('Smooth motion mode is available only for 5-second videos at 540p and 720p quality') - ) - elif sound_effect_switch: - units += 10 - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( - cost := units * self.UNIT_PRICE - ): - raise InsufficientBalance(balance, cost) - callback_data = { - 'prompt': input_message.content, - 'motion_mode': motion_mode, - 'style': self.STYLES.get(input_message.info.pop('style', None), 'None'), - **input_message.info, - } - if image := input_message.file: - callback_data.update({'image': image.url}) - start_time = time.time() - video = replicate_run('pixverse/pixverse-v4', callback_data) - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, - quality=quality, - duration=duration, - motion_mode=motion_mode, - sound_effect_switch=sound_effect_switch, - ) - msgs = self.save_results(input_message.content, process_time, video, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save) @@ -1,88 +1,33 @@ -import time -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -import requests -from django.core.files import File - from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException -from ml_model.services.base import SimpleService -from ml_model.tasks import replicate_run +from ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.seedream import Seedream -class Leonardo(SimpleService): - TOKENS_COST = { - 'lucid-origin': { - 'input_units': Decimal('450'), - } +class Leonardo(Seedream): + PROXY_VERSION_MAPPING = { + 'lucid-origin': 'seedream-boosted', + 'lucid-pro': 'seedream-4.5', } - _CALLBACK_BASE = 'leonardoai/' - - def calculate_price(self, version: str, num_images: int, generation_mode: str) -> Decimal: - image_prices = {'standard': 18, 'ultra': 51} - price = ( - Decimal(f'{self.TOKENS_COST[version]["input_units"] / 1000 * image_prices[generation_mode]}') - * num_images - ) - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() version = info.get('version', 'lucid-origin') - num_images = info.get('num_images', 1) - generation_mode = info.get('generation_mode', 'standard') - image_prices = {'standard': 18, 'ultra': 51} - input_units = cls.TOKENS_COST[version]['input_units'] - price = (input_units / 1000 * image_prices[generation_mode]) * num_images - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info - def save_results( - self, - prompt: str, - images: list, - time: timedelta, - save: bool = True, - ) -> list[Message]: - messages: list[Message] = [] - for image in images: - messages.append( - Message( - content_object=self.store, - elapsed_time=time, - content=prompt, - file=File(BytesIO(requests.get(image).content), '.png'), - ) - ) - if save: - return Message.objects.bulk_create(messages) - return messages + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): + try: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: + return None def make(self, input_message: Message, save: bool = True) -> list[Message]: - start_time = time.time() - version = 'lucid-origin' - generation_mode = input_message.info.get('generation_mode', 'standard') - num_images = input_message.info.get('num_images', 1) - callback_data = dict( - { - 'prompt': self.translate_prompt(input_message.content), - **input_message.info, - } - ) - runner = replicate_run( - f'{self._CALLBACK_BASE}{version}', - callback_data, - ) - images = runner if isinstance(runner, list) else [runner] - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, - version=version, - num_images=num_images, - generation_mode=generation_mode, - ) - msgs = self.save_results(input_message.content, images, process_time, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save) @@ -1,104 +1,33 @@ -import time -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -import filetype -import requests -from django.core.files import File - from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException, FileExtensionNotSupported -from ml_model.services.FileService import FileProcessingService from ml_model.exceptions import ModelVersionNotAvailable -from ml_model.services.base import SimpleService -from ml_model.tasks import replicate_run - -from payments.exceptions.insufficient_balance import InsufficientBalance -from payments.selectors.payment_plan_selector import PaymentPlanSelector +from ml_model.services.seedance_2_dreamina import Seedance_2_Dreamina -class Seedance(SimpleService): - TOKENS_COST = { - 'seedance-2.0': { - 'non_video_in': { - '480p': Decimal('21'), - '720p': Decimal('51') - } - }, - 'seedance-2.0-fast': { - 'non_video_in': { - '480p': Decimal('18'), - '720p': Decimal('39') - }, - 'video_in': { - '480p': Decimal('33'), - '720p': Decimal('66') - } # 1 second - } +class Seedance(Seedance_2_Dreamina): + PROXY_VERSION_MAPPING = { + 'seedance-2.0-fast': 'dreamina-seedance-2-0-fast', + 'seedance-2.0': 'dreamina-seedance-2-0', } @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - resolution = info['resolution'] - duration = info['duration'] - version = info['version'] - generation_type = 'video_in' if file_exists and version == 'seedance-2.0-fast' else 'non_video_in' - price = cls.TOKENS_COST[version][generation_type][resolution] * duration - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() + version = info.get('version', 'seedance-2.0-fast') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info - def calculate_price(self, resolution: str, duration: int, version: str, generation_type: str) -> Decimal: - price = self.TOKENS_COST[version][generation_type][resolution] * duration - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - - def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: - msg = Message( - content=content, - content_object=self.store, - elapsed_time=t, - file=File(BytesIO(requests.get(video).content), '.mp4'), - ) - if save: - return Message.objects.bulk_create([msg]) - return [msg] + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): + try: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: + return None def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', None) - if version is None or version not in self.TOKENS_COST: - raise ModelVersionNotAvailable(version, self.TOKENS_COST) - resolution = input_message.info.get('resolution', '720p') - duration = input_message.info.get('duration', 5) - file = input_message.file or None - file_extension = None - generation_type = 'non_video_in' - if file: - file_bytes = file.read() - kind = filetype.guess(file_bytes[:50]) - file_extension = FileProcessingService.get_file_extension(kind.extension, file_bytes) if kind else None - available_extensions = ('JPG', 'JPEG', 'PNG', 'WEBP', 'MP4') - if not file_extension or file_extension.upper() not in available_extensions: - raise FileExtensionNotSupported(available_extensions) - elif file_extension.upper() == 'MP4': - generation_type = 'video_in' - if version == 'seedance-2.0' and generation_type == 'video_in': - available_extensions = ('JPG', 'JPEG', 'PNG', 'WEBP') - raise FileExtensionNotSupported(available_extensions) - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( - cost := self.TOKENS_COST[version][generation_type][resolution] * duration): - raise InsufficientBalance(balance, cost) - callback_data = dict({'prompt': input_message.content, **input_message.info}) - if file: - reference_type = ( - 'videos' if file_extension.upper() == 'MP4' - else 'images' - ) - callback_data.update({f'reference_{reference_type}': [file.url]}) - start_time = time.time() - video = replicate_run( - f'bytedance/{version}', callback_data - ) - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model, resolution=resolution, duration=duration, version=version, generation_type=generation_type) - msgs = self.save_results(input_message.content, process_time, video, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save)