diff --git a/blivedm b/blivedm index 100ed8f..b204c17 160000 --- a/blivedm +++ b/blivedm @@ -1 +1 @@ -Subproject commit 100ed8fdf970bb105a69df52d63ffd49a64b7cf6 +Subproject commit b204c17cc2e20370ea4f08f5e9c07db72a1671cd diff --git a/config.py b/config.py index 19b40d0..5b07941 100644 --- a/config.py +++ b/config.py @@ -174,7 +174,6 @@ class AppConfig: '%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3' ) logger.warning('%s is deprecated, please see %s', type_, doc_url) - continue elif type_ == 'TencentTranslate': translator_config['source_language'] = section['source_language'] translator_config['target_language'] = section['target_language'] @@ -187,11 +186,16 @@ class AppConfig: translator_config['app_id'] = section['app_id'] translator_config['secret'] = section['secret'] elif type_ == 'GeminiTranslate': - translator_config['proxy'] = section['proxy'] + logger.warning('%s is deprecated, please migrate to OpenAiApi', type_) + elif type_ == 'OpenAiApi': translator_config['api_key'] = section['api_key'] - translator_config['model_code'] = section['model_code'] + translator_config['base_url'] = section['base_url'] + translator_config['proxy'] = section['proxy'] + translator_config['model'] = section['model'] translator_config['prompt'] = section['prompt'].replace('\n', ' ').replace('\\n', '\n') + translator_config['max_tokens'] = section.getint('max_tokens') translator_config['temperature'] = section.getfloat('temperature') + translator_config['top_p'] = section.getfloat('top_p') else: raise ValueError(f'Invalid translator type: {type_}') except Exception: # noqa diff --git a/data/config.example.ini b/data/config.example.ini index 39f11cd..3b84ad0 100644 --- a/data/config.example.ini +++ b/data/config.example.ini @@ -138,48 +138,36 @@ app_id = secret = -[gemini_translate] -# 文档:https://ai.google.dev/docs -# 定价:https://ai.google.dev/pricing -# * 目前只有免费版,最高QPS为1 -# * 收费版 输入 $0.000125 / 1000 个字符;输出 $0.000375 / 1000 个字符 -# 模型规格:https://ai.google.dev/models/gemini -# * Gemini Pro 最高QPS为1 +[openai_api] +# 大语言模型对话,支持任何兼容OpenAI API的平台,参考各平台的文档吧 -# 类型:Gemini翻译 -type = GeminiTranslate +# 类型:OpenAI API +type = OpenAiApi # 请求间隔时间(秒),等于 1 / QPS query_interval = 1.1 -# 代理地址,如果为空,不使用代理。注意必须要在指定地区才能使用:https://ai.google.dev/available_regions -proxy = http://127.0.0.1:7890 - # API密钥 api_key = +# API端点,“/chat/completions”之前的部分 +base_url = https://api.deepseek.com/v1 +# 代理地址,如果为空,不使用代理 +# Example: proxy = http://127.0.0.1:7890 +proxy = -# 模型代码 -model_code = models/gemini-1.0-pro -# 提示词 -prompt = As an advanced translation software, your role is to provide accurate translations that respect - the original content's meaning and tone. - Ensure that texts between square brackets "[]" are kept untranslated. - Aim to deliver the translated text in a polite and gentle tone, as per the preferences of the audience.\n\n - Below are some examples to guide you:\n\n - Input1:\n来拉(莱拉)设置一下吧\nOutput1:\nライラちゃん、設定してみましょうか\n\n - Input2:\n我来拉一下各位进队伍\nOutput2:\n皆さんをチームに入れさせていただきます\n\n - Input3:\n死了w\nOutput3:\n死んじゃったわw\n\n - Input4:\n每天来宝这里打个卡[比心]\nOutput4:\n毎日、ここに来て宝さんにチェックインしま[比心]\n\n - \n - Please keep in mind that accurate translation is not just about linguistic conversion; - it is about conveying the same sentiment and meaning as the original input. - Pay special attention to internet vernaculars and colloquial expressions to maintain the nuances of the - source language.\n\n - Now, proceed with the translation of the following Chinese comment text into Japanese, ensuring accuracy - and preservation of the original message:\n - Input:\n{original_text}\nOutput ONLY:\n -# 温度,用于控制令牌选择的随机性,较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果 -temperature = 0.4 +# 模型 +model = deepseek-chat +# 提示词,换行符会替换为空格,“\n”会替换为换行符 +prompt = 你是一个日本主播的翻译助手,精通中日文翻译。 + 用户会输入直播观众的聊天内容,请翻译成日文,并确保符合日文语言习惯。 + 你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。 + 只回答翻译结果,不需要解释。 +# 最大输出token数 +max_tokens = 200 +# 采样温度,用于控制令牌选择的随机性。较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果 +temperature = 1.3 +# 核采样概率阈值,温度采样的替代方案。模型会考虑概率质量在top_p内的token结果 +top_p = 1.0 # 傻逼B站,获取表情都要登录,开放平台也不发文本表情的URL,我服了 diff --git a/services/translate.py b/services/translate.py index bce0d0c..b3d28db 100644 --- a/services/translate.py +++ b/services/translate.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import asyncio +import copy import dataclasses import datetime import enum @@ -15,6 +16,7 @@ import Crypto.Cipher.AES as cry_aes # noqa import Crypto.Util.Padding as cry_pad # noqa import aiohttp import cachetools +import circuitbreaker import config import utils.async_io @@ -79,10 +81,10 @@ def create_translate_provider(cfg): cfg['query_interval'], cfg['source_language'], cfg['target_language'], cfg['app_id'], cfg['secret'] ) - elif type_ == 'GeminiTranslate': - return GeminiTranslate( - cfg['query_interval'], cfg['proxy'], cfg['api_key'], cfg['model_code'], - cfg['prompt'], cfg['temperature'] + elif type_ == 'OpenAiApi': + return OpenAiApi( + cfg['query_interval'], cfg['api_key'], cfg['base_url'], cfg['proxy'], cfg['model'], + cfg['prompt'], cfg['max_tokens'], cfg['temperature'], cfg['top_p'] ) return None @@ -255,8 +257,10 @@ class TranslateProvider: logger.info('%s became available', cls_name) task = await _pop_task() - # 为了简化代码,约定只会在_translate_wrapper里变成不可用,所以获取task之后这里还是可用的 - assert self.is_available + if not self.is_available: + if not _push_task(task): + task.future.set_result(None) + continue start_time = datetime.datetime.now() await self._translate_wrapper(task) @@ -267,7 +271,7 @@ class TranslateProvider: except Exception: # noqa logger.exception('%s error:', cls_name) - async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]: + async def _translate_wrapper(self, task: TranslateTask): try: exc = None task.remain_retry_count -= 1 @@ -277,7 +281,7 @@ class TranslateProvider: res = None if res is not None: task.future.set_result(res) - return res + return if task.remain_retry_count > 0: # 还可以重试则放回队列 @@ -289,7 +293,6 @@ class TranslateProvider: task.future.set_exception(exc) else: task.future.set_result(None) - return None async def _do_translate(self, text) -> Optional[str]: raise NotImplementedError @@ -345,8 +348,9 @@ class TencentTranslate(TranslateProvider): hashed_request_payload = hashlib.sha256(body_bytes).hexdigest() canonical_request = f'POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_request_payload}' - request_timestamp = int(datetime.datetime.now().timestamp()) - date = datetime.datetime.utcfromtimestamp(request_timestamp).strftime('%Y-%m-%d') + cur_time_utc = datetime.datetime.now(datetime.UTC) + request_timestamp = int(cur_time_utc.timestamp()) + date = cur_time_utc.strftime('%Y-%m-%d') credential_scope = f'{date}/tmt/tc3_request' hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest() string_to_sign = f'TC3-HMAC-SHA256\n{request_timestamp}\n{credential_scope}\n{hashed_canonical_request}' @@ -470,96 +474,96 @@ class BaiduTranslate(TranslateProvider): self._on_availability_change() -class GeminiTranslate(TranslateProvider): - def __init__(self, query_interval, proxy, api_key, model_code, prompt, temperature): +class _OpenAiApiExpectedError(Exception): + pass + + +class OpenAiApi(TranslateProvider): + def __init__(self, query_interval, api_key, base_url, proxy, model, prompt, max_tokens, temperature, top_p): super().__init__(query_interval) + self._url = base_url + '/chat/completions' self._proxy = proxy or None - self._api_key = api_key - self._url = f'https://generativelanguage.googleapis.com/v1beta/{model_code}:generateContent' - self._prompt = prompt + self._headers = { + 'Authorization': 'Bearer ' + api_key + } self._body = { - 'contents': [ - { - # 'role': 'user', - 'parts': [{'text': ''}] - } + 'model': model, + 'messages': [ + {'role': 'system', 'content': prompt}, + {'role': 'user', 'content': ''}, ], - 'safetySettings': [ - { - 'category': 'HARM_CATEGORY_SEXUALLY_EXPLICIT', - 'threshold': 'BLOCK_NONE' - }, - { - 'category': 'HARM_CATEGORY_HATE_SPEECH', - 'threshold': 'BLOCK_NONE' - }, - { - 'category': 'HARM_CATEGORY_HARASSMENT', - 'threshold': 'BLOCK_NONE' - }, - { - 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', - 'threshold': 'BLOCK_NONE' - } - ], - 'generationConfig': { - 'temperature': temperature, - 'topP': 1, - 'topK': 32, - 'candidateCount': 1, - 'maxOutputTokens': 8192 - } + 'stream': False, + 'max_tokens': max_tokens, + 'temperature': temperature, + 'top_p': top_p, } + self._breaker = circuitbreaker.CircuitBreaker( + failure_threshold=5, + recovery_timeout=60, + ) self._cool_down_timer_handle = None @property def is_available(self): - return self._cool_down_timer_handle is None and super().is_available + return self._breaker.state != circuitbreaker.STATE_OPEN and super().is_available + + async def _translate_wrapper(self, task: TranslateTask): + # 并发请求,不然太慢了 + utils.async_io.create_task_with_ref(super()._translate_wrapper(task)) async def _do_translate(self, text) -> Optional[str]: - input_text = self._prompt.format(original_text=text) - self._body['contents'][0]['parts'][0]['text'] = input_text + data = None try: - async with utils.request.http_session.post( - self._url, - params={'key': self._api_key}, - json=self._body, - proxy=self._proxy, - ) as r: - if r.status != 200: - logger.warning('GeminiTranslate request failed: status=%d %s', r.status, r.reason) - self._on_fail(r.status) - return None - data = await r.json() + with self._breaker: + body = copy.deepcopy(self._body) + body['messages'][-1]['content'] = text + async with utils.request.http_session.post( + self._url, + headers=self._headers, + json=body, + proxy=self._proxy, + timeout=aiohttp.ClientTimeout(total=30), + ) as r: + if r.status != 200: + rsp_body = await r.text() + logger.warning( + 'OpenAiApi request failed: status=%d %s, body=%s', r.status, r.reason, rsp_body + ) + raise _OpenAiApiExpectedError('OpenAiApi request failed') + data = await r.json() + return data['choices'][0]['message']['content'] + + except (_OpenAiApiExpectedError, circuitbreaker.CircuitBreakerError): + pass except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as e: - logger.warning('GeminiTranslate request failed, %s: %s', type(e).__name__, e) - return None - - try: - candidates = data['candidates'] - if not candidates: - block_reason = data['promptFeedback'].get('blockReason', '') - logger.warning('GeminiTranslate no candidate, block_reason=%s, text=%s', block_reason, text) - return None - - first_content_parts = candidates[0]['content']['parts'] - return ''.join(part.get('text', '') for part in first_content_parts) + logger.warning('OpenAiApi request failed, %s: %s', type(e).__name__, e) except (KeyError, IndexError): - logger.warning('GeminiTranslate failed to parse response: %s', data) - return None + if data is not None: + logger.warning('OpenAiApi failed to parse response: %s', data) + else: + logger.exception('OpenAiApi unknown exception:') - def _on_fail(self, code): + if self._breaker.state == circuitbreaker.STATE_OPEN: + self._on_breaker_open() + + return None + + def _on_breaker_open(self): if self._cool_down_timer_handle is not None: return - if code in (401, 403): - # API密钥无效,没有权限,或者不在有效地区。需要手动处理,等5分钟 - self._cool_down_timer_handle = asyncio.get_running_loop().call_later( - 5 * 60, self._on_cool_down_timeout - ) - self._on_availability_change() + self._cool_down_timer_handle = asyncio.get_running_loop().call_later( + self._breaker.open_remaining, self._on_cool_down_timeout + ) + self._on_availability_change() def _on_cool_down_timeout(self): self._cool_down_timer_handle = None + if self._breaker.state == circuitbreaker.STATE_OPEN: + self._cool_down_timer_handle = asyncio.get_running_loop().call_later( + self._breaker.open_remaining, self._on_cool_down_timeout + ) + return + self._on_availability_change()