Files
ai-agent-admin/backend-fastapi/ai_platform/providers/qwen_provider.py
T
2026-06-08 18:14:59 +08:00

556 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
阿里通义千问提供商适配器
"""
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 端点在线拉取模型列表"""
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}'
try:
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
except Exception as e:
logger.warning(f'在线拉取模型列表失败 ({base_url}): {e}')
return []
@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,
},
]