diff --git a/backend/app/ai/tools/qa_tool.py b/backend/app/ai/tools/qa_tool.py index 258ccb9..f034694 100644 --- a/backend/app/ai/tools/qa_tool.py +++ b/backend/app/ai/tools/qa_tool.py @@ -2,12 +2,15 @@ import asyncio from typing import Dict, Any, List import json import os +import time +import hashlib from dashscope import MultiModalConversation from backend.app.admin.service.file_service import file_service from langchain_core.messages import SystemMessage, HumanMessage from backend.core.llm import LLMFactory, AuditLogCallbackHandler from backend.core.conf import settings from backend.core.prompts.scene_variation import get_scene_variation_prompt +from backend.database.redis import redis_client class SceneVariationGenerator: """ @@ -86,6 +89,59 @@ class SceneVariationGenerator: except Exception as e: return {"success": False, "error": str(e)} + +class QwenImageKeyManager: + RATE_LIMIT_PER_SECOND = 2 + WINDOW_SECONDS = 1 + REDIS_PREFIX = "qwen:image_edit:rate" + + @classmethod + def _get_keys(cls) -> List[str]: + raw = settings.QWEN_API_KEY or "" + if not raw: + raw = os.getenv("DASHSCOPE_API_KEY") or "" + parts = [p.strip() for p in raw.split(";") if p.strip()] + if parts: + return parts + if raw: + return [raw] + return [] + + @classmethod + def _key_id(cls, key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest()[:16] + + @classmethod + async def acquire_key(cls) -> str: + keys = cls._get_keys() + if not keys: + raise Exception("Qwen API key not configured") + now = int(time.time()) + n = len(keys) + start = now % n + for i in range(n): + key = keys[(start + i) % n] + kid = cls._key_id(key) + redis_key = f"{cls.REDIS_PREFIX}:{kid}:{now}" + count = await redis_client.incr(redis_key) + if count == 1: + await redis_client.expire(redis_key, cls.WINDOW_SECONDS + 1) + if count <= cls.RATE_LIMIT_PER_SECOND: + return key + await asyncio.sleep(1.0 / cls.RATE_LIMIT_PER_SECOND) + now2 = int(time.time()) + for i in range(n): + key = keys[(start + i) % n] + kid = cls._key_id(key) + redis_key = f"{cls.REDIS_PREFIX}:{kid}:{now2}" + count = await redis_client.incr(redis_key) + if count == 1: + await redis_client.expire(redis_key, cls.WINDOW_SECONDS + 1) + if count <= cls.RATE_LIMIT_PER_SECOND: + return key + raise Exception("Qwen image edit rate limited") + + class Illustrator: """ Component for generating edited images based on text descriptions (Step 2 of the advanced workflow). @@ -116,11 +172,14 @@ class Illustrator: ] try: - # Wrap the blocking SDK call in asyncio.to_thread + if api_key: + final_api_key = api_key + else: + final_api_key = await QwenImageKeyManager.acquire_key() response = await asyncio.to_thread( MultiModalConversation.call, - api_key=api_key or os.getenv("DASHSCOPE_API_KEY") or settings.QWEN_API_KEY, - model="qwen-image-edit-plus", # Assuming this is the model name for image editing + api_key=final_api_key, + model="qwen-image-edit-plus", messages=messages, stream=False, n=1, diff --git a/backend/core/llm.py b/backend/core/llm.py index f934959..3f92ead 100644 --- a/backend/core/llm.py +++ b/backend/core/llm.py @@ -12,6 +12,15 @@ from backend.app.admin.service.audit_log_service import audit_log_service from backend.core.conf import settings from backend.common.log import log as logger + +def _get_primary_qwen_api_key() -> str: + raw = settings.QWEN_API_KEY or "" + parts = [p.strip() for p in raw.split(";") if p.strip()] + if parts: + return parts[0] + return raw + + class AuditLogCallbackHandler(BaseCallbackHandler): def __init__(self, metadata: Optional[Dict[str, Any]] = None): super().__init__() @@ -99,7 +108,7 @@ class LLMFactory: if model_type == 'qwen': return ChatTongyi( - api_key=settings.QWEN_API_KEY, + api_key=_get_primary_qwen_api_key(), model_name=settings.QWEN_TEXT_MODEL, **kwargs ) @@ -110,10 +119,9 @@ class LLMFactory: **kwargs ) else: - # Default to Qwen if unknown logger.warning(f"Unknown model type {model_type}, defaulting to Qwen") return ChatTongyi( - api_key=settings.QWEN_API_KEY, + api_key=_get_primary_qwen_api_key(), model_name=settings.QWEN_TEXT_MODEL, **kwargs ) diff --git a/backend/middleware/qwen.py b/backend/middleware/qwen.py index ac6bfe9..dbb4305 100755 --- a/backend/middleware/qwen.py +++ b/backend/middleware/qwen.py @@ -23,6 +23,14 @@ from sqlalchemy.exc import IntegrityError from asyncio import sleep +def _get_primary_qwen_api_key() -> str: + raw = settings.QWEN_API_KEY or "" + parts = [p.strip() for p in raw.split(";") if p.strip()] + if parts: + return parts[0] + return raw + + class Qwen: # 创建一个类级别的线程池执行器 _executor = ThreadPoolExecutor(max_workers=10) @@ -33,7 +41,7 @@ class Qwen: @staticmethod async def text_to_speak(content: str, image_text_id: int | None = None, image_id: int | None = None, user_id: int | None = None, ref_type: str | None = None, ref_id: int | None = None) -> Dict[str, Any]: - api_key = settings.QWEN_API_KEY + api_key = _get_primary_qwen_api_key() model_name = "qwen3-tts-flash" voice = "Jennifer" language_type = "English" @@ -185,7 +193,7 @@ class Qwen: @staticmethod async def chat(messages: List[Dict[str, str]], image_id: int = 0, user_id: int = 0, api_type: str = "chat") -> Dict[str, Any]: - api_key = settings.QWEN_API_KEY + api_key = _get_primary_qwen_api_key() model_name = settings.QWEN_TEXT_MODEL start_time = time.time() start_at = datetime.now() @@ -314,7 +322,7 @@ class Qwen: """通用API调用方法""" model_name = "" - api_key = settings.QWEN_API_KEY + api_key = _get_primary_qwen_api_key() response = None start_time = time.time() start_at = datetime.now() @@ -481,7 +489,7 @@ class Qwen: # 轮询任务状态 start_time = time.time() headers = { - "Authorization": f"Bearer {settings.QWEN_API_KEY}", + "Authorization": f"Bearer {_get_primary_qwen_api_key()}", "Content-Type": "application/json" }