""" 阿里通义千问提供商适配器 """ import logging from typing import AsyncGenerator, Dict, List, Any from .base import BaseLLMProvider, LLMConfig, LLMMessage, LLMResponse, LLMStreamChunk, ToolCall from .registry import ProviderRegistry logger = logging.getLogger(__name__) @ProviderRegistry.register class QwenProvider(BaseLLMProvider): """ 阿里通义千问提供商适配器 使用 OpenAI 兼容接口 """ provider_type = 'qwen' provider_name = '通义千问' supported_model_types = ['chat', 'embedding'] DEFAULT_API_BASE = 'https://dashscope.aliyuncs.com/compatible-mode/v1' def __init__(self, api_key: str = '', api_base: str = '', **kwargs): super().__init__(api_key, api_base, **kwargs) self.api_base = api_base or self.DEFAULT_API_BASE self._client = None self._async_client = None def _get_client(self): """获取同步客户端""" if self._client is None: try: from openai import OpenAI self._client = OpenAI( api_key=self.api_key, base_url=self.api_base, ) except ImportError: raise ImportError('请安装 openai 库: pip install openai') return self._client def _get_async_client(self): """获取异步客户端""" if self._async_client is None: try: from openai import AsyncOpenAI self._async_client = AsyncOpenAI( api_key=self.api_key, base_url=self.api_base, ) except ImportError: raise ImportError('请安装 openai 库: pip install openai') return self._async_client def chat( self, messages: List[LLMMessage], config: LLMConfig, ) -> LLMResponse: """同步对话""" client = self._get_client() # 构建请求参数 kwargs = { 'model': config.model, 'messages': [m.to_dict() for m in messages], 'temperature': config.temperature, 'top_p': config.top_p, 'max_tokens': config.max_tokens, 'stop': config.stop, **config.extra_params, } # 添加 Function Calling 参数 if config.tools: kwargs['tools'] = [t.to_openai_format() for t in config.tools] if config.tool_choice == 'required': kwargs['tool_choice'] = 'required' elif config.tool_choice == 'none': kwargs['tool_choice'] = 'none' elif config.tool_choice != 'auto': kwargs['tool_choice'] = {'type': 'function', 'function': {'name': config.tool_choice}} else: kwargs['tool_choice'] = 'auto' response = client.chat.completions.create(**kwargs) choice = response.choices[0] usage = response.usage # 解析工具调用 tool_calls = None if choice.message.tool_calls: tool_calls = self._parse_tool_calls(choice.message.tool_calls) return LLMResponse( content=choice.message.content or '', model=response.model, prompt_tokens=usage.prompt_tokens if usage else 0, completion_tokens=usage.completion_tokens if usage else 0, total_tokens=usage.total_tokens if usage else 0, finish_reason=choice.finish_reason or '', raw_response=response.model_dump(), tool_calls=tool_calls, ) def _parse_tool_calls(self, tool_calls) -> List[ToolCall]: """解析工具调用""" import json result = [] for tc in tool_calls: try: arguments = json.loads(tc.function.arguments) if tc.function.arguments else {} except json.JSONDecodeError: arguments = {'raw': tc.function.arguments} result.append(ToolCall( id=tc.id, name=tc.function.name, arguments=arguments, )) return result async def chat_async( self, messages: List[LLMMessage], config: LLMConfig, ) -> LLMResponse: """异步对话""" client = self._get_async_client() # 构建请求参数 kwargs = { 'model': config.model, 'messages': [m.to_dict() for m in messages], 'temperature': config.temperature, 'top_p': config.top_p, 'max_tokens': config.max_tokens, 'stop': config.stop, **config.extra_params, } # 添加 Function Calling 参数 if config.tools: kwargs['tools'] = [t.to_openai_format() for t in config.tools] if config.tool_choice == 'required': kwargs['tool_choice'] = 'required' elif config.tool_choice == 'none': kwargs['tool_choice'] = 'none' elif config.tool_choice != 'auto': kwargs['tool_choice'] = {'type': 'function', 'function': {'name': config.tool_choice}} else: kwargs['tool_choice'] = 'auto' response = await client.chat.completions.create(**kwargs) choice = response.choices[0] usage = response.usage # 解析工具调用 tool_calls = None if choice.message.tool_calls: tool_calls = self._parse_tool_calls(choice.message.tool_calls) return LLMResponse( content=choice.message.content or '', model=response.model, prompt_tokens=usage.prompt_tokens if usage else 0, completion_tokens=usage.completion_tokens if usage else 0, total_tokens=usage.total_tokens if usage else 0, finish_reason=choice.finish_reason or '', raw_response=response.model_dump(), tool_calls=tool_calls, ) async def chat_stream( self, messages: List[LLMMessage], config: LLMConfig, ) -> AsyncGenerator[LLMStreamChunk, None]: """流式对话""" client = self._get_async_client() # 构建请求参数 kwargs = { 'model': config.model, 'messages': [m.to_dict() for m in messages], 'temperature': config.temperature, 'top_p': config.top_p, 'max_tokens': config.max_tokens, 'stop': config.stop, 'stream': True, 'stream_options': {"include_usage": True}, **config.extra_params, } # 添加 Function Calling 参数 if config.tools: kwargs['tools'] = [t.to_openai_format() for t in config.tools] if config.tool_choice == 'required': kwargs['tool_choice'] = 'required' elif config.tool_choice == 'none': kwargs['tool_choice'] = 'none' elif config.tool_choice != 'auto': kwargs['tool_choice'] = {'type': 'function', 'function': {'name': config.tool_choice}} else: kwargs['tool_choice'] = 'auto' stream = await client.chat.completions.create(**kwargs) # 用于累积工具调用 tool_call_accumulator = {} async for chunk in stream: if chunk.choices: choice = chunk.choices[0] delta = choice.delta # 处理工具调用增量 tool_call_delta = None if hasattr(delta, 'tool_calls') and delta.tool_calls: for tc_delta in delta.tool_calls: tc_index = tc_delta.index if tc_index not in tool_call_accumulator: tool_call_accumulator[tc_index] = { 'id': tc_delta.id or '', 'name': '', 'arguments': '', } if tc_delta.id: tool_call_accumulator[tc_index]['id'] = tc_delta.id if tc_delta.function: if tc_delta.function.name: tool_call_accumulator[tc_index]['name'] = tc_delta.function.name if tc_delta.function.arguments: tool_call_accumulator[tc_index]['arguments'] += tc_delta.function.arguments tool_call_delta = tool_call_accumulator[tc_index] # 检查是否完成 tool_calls = None if choice.finish_reason == 'tool_calls' or (choice.finish_reason and tool_call_accumulator): tool_calls = self._parse_accumulated_tool_calls(tool_call_accumulator) yield LLMStreamChunk( content=delta.content or '', is_finished=choice.finish_reason is not None, finish_reason=choice.finish_reason or '', tool_calls=tool_calls, tool_call_delta=tool_call_delta, ) if chunk.usage: yield LLMStreamChunk( content='', is_finished=True, prompt_tokens=chunk.usage.prompt_tokens, completion_tokens=chunk.usage.completion_tokens, total_tokens=chunk.usage.total_tokens, ) def _parse_accumulated_tool_calls(self, accumulator: Dict) -> List[ToolCall]: """解析累积的工具调用""" import json result = [] for idx in sorted(accumulator.keys()): tc = accumulator[idx] try: arguments = json.loads(tc['arguments']) if tc['arguments'] else {} except json.JSONDecodeError: arguments = {'raw': tc['arguments']} result.append(ToolCall( id=tc['id'], name=tc['name'], arguments=arguments, )) return result def chat_stream_sync( self, messages: List[LLMMessage], config: LLMConfig, ): """同步流式对话""" client = self._get_client() # 构建请求参数 kwargs = { 'model': config.model, 'messages': [m.to_dict() for m in messages], 'temperature': config.temperature, 'top_p': config.top_p, 'max_tokens': config.max_tokens, 'stop': config.stop, 'stream': True, 'stream_options': {"include_usage": True}, **config.extra_params, } # 添加 Function Calling 参数 if config.tools: kwargs['tools'] = [t.to_openai_format() for t in config.tools] if config.tool_choice == 'required': kwargs['tool_choice'] = 'required' elif config.tool_choice == 'none': kwargs['tool_choice'] = 'none' elif config.tool_choice != 'auto': kwargs['tool_choice'] = {'type': 'function', 'function': {'name': config.tool_choice}} else: kwargs['tool_choice'] = 'auto' stream = client.chat.completions.create(**kwargs) # 用于累积工具调用 tool_call_accumulator = {} for chunk in stream: if chunk.choices: choice = chunk.choices[0] delta = choice.delta # 处理工具调用增量 tool_call_delta = None if hasattr(delta, 'tool_calls') and delta.tool_calls: for tc_delta in delta.tool_calls: tc_index = tc_delta.index if tc_index not in tool_call_accumulator: tool_call_accumulator[tc_index] = { 'id': tc_delta.id or '', 'name': '', 'arguments': '', } if tc_delta.id: tool_call_accumulator[tc_index]['id'] = tc_delta.id if tc_delta.function: if tc_delta.function.name: tool_call_accumulator[tc_index]['name'] = tc_delta.function.name if tc_delta.function.arguments: tool_call_accumulator[tc_index]['arguments'] += tc_delta.function.arguments tool_call_delta = tool_call_accumulator[tc_index] # 检查是否完成 tool_calls = None if choice.finish_reason == 'tool_calls' or (choice.finish_reason and tool_call_accumulator): tool_calls = self._parse_accumulated_tool_calls(tool_call_accumulator) yield LLMStreamChunk( content=delta.content or '', is_finished=choice.finish_reason is not None, finish_reason=choice.finish_reason or '', tool_calls=tool_calls, tool_call_delta=tool_call_delta, ) if chunk.usage: yield LLMStreamChunk( content='', is_finished=True, prompt_tokens=chunk.usage.prompt_tokens, completion_tokens=chunk.usage.completion_tokens, total_tokens=chunk.usage.total_tokens, ) _EMBEDDING_KEYWORDS = ('embed', 'bge') _RERANK_KEYWORDS = ('rerank',) _SKIP_KEYWORDS = ( 'paraformer', 'sambert', 'wordart', 'wanx', 'cosyvoice', 'qwen-vl-ocr', 'tts', 'asr', 'realtime', 'tongyi-xiaomi', 'flux', 'stable-diffusion', 'qwen-image-', 'z-image-', 'gui-plus', 'qwen-mt-', 'livetranslate', 'deep-search', 'flash-character', ) async def fetch_models_from_api(self) -> List[Dict[str, Any]]: """通过 DashScope 兼容的 /v1/models 端点在线拉取模型列表""" base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') try: return await self.fetch_models_from_api_strict() except Exception as e: logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}') return [] async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]: """通过 DashScope 兼容端点严格拉取模型列表,失败时保留上游异常""" import httpx base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/') headers = {} if self.api_key: headers['Authorization'] = f'Bearer {self.api_key}' async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(f'{base_url}/models', headers=headers) resp.raise_for_status() data = resp.json() model_list = data.get('data', []) if not model_list: return [] results = [] for item in model_list: model_id = item.get('id', '') if not model_id: continue model_lower = model_id.lower() if any(kw in model_lower for kw in self._SKIP_KEYWORDS): continue if any(kw in model_lower for kw in self._RERANK_KEYWORDS): model_type = 'rerank' elif any(kw in model_lower for kw in self._EMBEDDING_KEYWORDS): model_type = 'embedding' else: model_type = 'chat' results.append({ 'model_name': model_id, 'display_name': model_id, 'model_type': model_type, 'max_tokens': 4096, 'context_window': 4096, 'supports_vision': False, 'supports_function_call': model_type == 'chat', 'input_price': 0, 'output_price': 0, }) # DashScope /v1/models 可能只返回 chat 模型, # 将默认列表中的 embedding/rerank 模型合并进去 fetched_names = {m['model_name'] for m in results} for dm in self.__class__.get_default_models(): if dm['model_type'] in ('embedding', 'rerank') and dm['model_name'] not in fetched_names: results.append(dm) type_order = {'chat': 0, 'embedding': 1, 'rerank': 2} results.sort(key=lambda m: (type_order.get(m['model_type'], 9), m['model_name'])) logger.info(f'从 {base_url} 拉取到 {len(results)} 个模型(含补充的 embedding/rerank)') return results @classmethod def get_default_models(cls) -> List[Dict[str, Any]]: """获取默认模型列表""" return [ # ---- Chat 模型 ---- { 'model_name': 'qwen-max', 'display_name': '通义千问 Max', 'model_type': 'chat', 'max_tokens': 8192, 'context_window': 32768, 'supports_vision': False, 'supports_function_call': True, 'input_price': 0.02, 'output_price': 0.06, }, { 'model_name': 'qwen-plus', 'display_name': '通义千问 Plus', 'model_type': 'chat', 'max_tokens': 8192, 'context_window': 131072, 'supports_vision': False, 'supports_function_call': True, 'input_price': 0.0008, 'output_price': 0.002, }, { 'model_name': 'qwen-turbo', 'display_name': '通义千问 Turbo', 'model_type': 'chat', 'max_tokens': 8192, 'context_window': 131072, 'supports_vision': False, 'supports_function_call': True, 'input_price': 0.0003, 'output_price': 0.0006, }, { 'model_name': 'qwen3-235b-a22b', 'display_name': 'Qwen3 235B-A22B', 'model_type': 'chat', 'max_tokens': 8192, 'context_window': 131072, 'supports_vision': False, 'supports_function_call': True, 'input_price': 0.004, 'output_price': 0.012, }, { 'model_name': 'qwen3-32b', 'display_name': 'Qwen3 32B', 'model_type': 'chat', 'max_tokens': 8192, 'context_window': 131072, 'supports_vision': False, 'supports_function_call': True, 'input_price': 0.002, 'output_price': 0.006, }, { 'model_name': 'qwen-vl-max', 'display_name': '通义千问 VL Max', 'model_type': 'chat', 'max_tokens': 2048, 'context_window': 32768, 'supports_vision': True, 'supports_function_call': False, 'input_price': 0.02, 'output_price': 0.06, }, { 'model_name': 'qwen-vl-plus', 'display_name': '通义千问 VL Plus', 'model_type': 'chat', 'max_tokens': 2048, 'context_window': 32768, 'supports_vision': True, 'supports_function_call': False, 'input_price': 0.008, 'output_price': 0.02, }, # ---- Embedding 模型 ---- { 'model_name': 'text-embedding-v3', 'display_name': '通义文本向量 V3', 'model_type': 'embedding', 'max_tokens': 8192, 'context_window': 8192, 'input_price': 0.0007, 'output_price': 0, }, { 'model_name': 'text-embedding-v2', 'display_name': '通义文本向量 V2', 'model_type': 'embedding', 'max_tokens': 2048, 'context_window': 2048, 'input_price': 0.0007, 'output_price': 0, }, # ---- Rerank 模型 ---- { 'model_name': 'gte-rerank', 'display_name': 'GTE Rerank', 'model_type': 'rerank', 'max_tokens': 4096, 'context_window': 4096, 'input_price': 0.001, 'output_price': 0, }, ]