Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
LLM 提供商适配器
|
||||
"""
|
||||
from .base import (
|
||||
BaseLLMProvider,
|
||||
LLMResponse,
|
||||
LLMMessage,
|
||||
LLMConfig,
|
||||
LLMStreamChunk,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
)
|
||||
from .registry import ProviderRegistry
|
||||
|
||||
__all__ = [
|
||||
'BaseLLMProvider',
|
||||
'LLMResponse',
|
||||
'LLMMessage',
|
||||
'LLMConfig',
|
||||
'LLMStreamChunk',
|
||||
'ToolCall',
|
||||
'ToolDefinition',
|
||||
'ProviderRegistry',
|
||||
]
|
||||
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
LLM 提供商基类
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMMessage:
|
||||
"""LLM 消息
|
||||
|
||||
支持多模态内容格式:
|
||||
- 纯文本: content 为字符串
|
||||
- 多模态: content 为列表 [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "..."}}]
|
||||
"""
|
||||
role: str # system, user, assistant, tool
|
||||
content: Any # str 或 List[Dict] (多模态内容)
|
||||
name: Optional[str] = None
|
||||
# Function Calling 相关
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None # assistant 消息中的工具调用
|
||||
tool_call_id: Optional[str] = None # tool 消息中的工具调用 ID
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result = {'role': self.role, 'content': self.content}
|
||||
if self.name:
|
||||
result['name'] = self.name
|
||||
if self.tool_calls:
|
||||
result['tool_calls'] = self.tool_calls
|
||||
if self.tool_call_id:
|
||||
result['tool_call_id'] = self.tool_call_id
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def create_multimodal_content(text: str, attachments: List[Dict] = None) -> Any:
|
||||
"""
|
||||
创建多模态内容
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
attachments: 附件列表 [{"type": "image/file", "url": "...", "mime_type": "...", "base64": "..."}]
|
||||
|
||||
Returns:
|
||||
str 或 List[Dict] 格式的内容
|
||||
|
||||
Note:
|
||||
对于图片类型,优先使用 base64 格式(因为 LLM 无法访问内部 URL)
|
||||
base64 格式: data:<mime_type>;base64,<base64_content>
|
||||
"""
|
||||
if not attachments:
|
||||
return text
|
||||
|
||||
content = []
|
||||
|
||||
# 添加文本
|
||||
if text:
|
||||
content.append({"type": "text", "text": text})
|
||||
|
||||
# 添加附件
|
||||
for att in attachments:
|
||||
att_type = att.get('type', 'file')
|
||||
mime_type = att.get('mime_type', '')
|
||||
url = att.get('url', '')
|
||||
base64_content = att.get('base64', '')
|
||||
|
||||
if att_type == 'image' or mime_type.startswith('image/'):
|
||||
# 图片类型
|
||||
text_content = att.get('text_content', '')
|
||||
|
||||
if base64_content:
|
||||
# 多模态模型:使用 OpenAI 多模态格式
|
||||
# 使用 data URL 格式
|
||||
image_url = f"data:{mime_type};base64,{base64_content}"
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url}
|
||||
})
|
||||
elif text_content:
|
||||
# 非多模态模型:使用 OCR 识别的文本内容
|
||||
file_name = att.get('name', 'unknown')
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": f"\n{text_content}"
|
||||
})
|
||||
elif url:
|
||||
# 回退到 URL(仅适用于公网可访问的 URL)
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url}
|
||||
})
|
||||
else:
|
||||
# 其他文件类型 - 提取文本内容或显示文件信息
|
||||
file_name = att.get('name', 'unknown')
|
||||
text_content = att.get('text_content', '')
|
||||
|
||||
if text_content:
|
||||
# 有提取的文本内容,将其添加到消息中
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": f"\n--- 文件: {file_name} ---\n{text_content}\n--- 文件结束 ---"
|
||||
})
|
||||
else:
|
||||
# 无法提取内容,只显示文件信息
|
||||
file_size = att.get('size', 0)
|
||||
size_str = f"{file_size / 1024:.1f}KB" if file_size < 1024 * 1024 else f"{file_size / 1024 / 1024:.1f}MB"
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": f"\n[附件: {file_name} ({size_str}),该文件类型暂不支持内容提取]"
|
||||
})
|
||||
|
||||
return content if len(content) > 1 else (content[0].get('text', '') if content else text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""工具调用"""
|
||||
id: str # 工具调用 ID
|
||||
name: str # 工具/函数名称
|
||||
arguments: Dict[str, Any] # 参数
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
import json
|
||||
return {
|
||||
'id': self.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': self.name,
|
||||
'arguments': json.dumps(self.arguments, ensure_ascii=False) if isinstance(self.arguments, dict) else self.arguments,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""LLM 响应"""
|
||||
content: str
|
||||
model: str = ''
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
finish_reason: str = ''
|
||||
raw_response: Optional[Dict] = None
|
||||
# Function Calling 相关
|
||||
tool_calls: Optional[List[ToolCall]] = None # 工具调用列表
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return self.finish_reason in ('stop', 'end_turn', 'length')
|
||||
|
||||
@property
|
||||
def has_tool_calls(self) -> bool:
|
||||
"""是否包含工具调用"""
|
||||
return bool(self.tool_calls)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMStreamChunk:
|
||||
"""LLM 流式响应块"""
|
||||
content: str = ''
|
||||
is_finished: bool = False
|
||||
finish_reason: str = ''
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
# Function Calling 相关
|
||||
tool_calls: Optional[List[ToolCall]] = None # 工具调用(流式时可能分多次返回)
|
||||
tool_call_delta: Optional[Dict[str, Any]] = None # 工具调用增量
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDefinition:
|
||||
"""工具定义(用于 Function Calling)"""
|
||||
name: str # 工具名称
|
||||
description: str # 工具描述
|
||||
parameters: Dict[str, Any] # 参数 Schema (JSON Schema 格式)
|
||||
|
||||
def to_openai_format(self) -> Dict[str, Any]:
|
||||
"""转换为 OpenAI 格式"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'parameters': self.parameters,
|
||||
}
|
||||
}
|
||||
|
||||
def to_claude_format(self) -> Dict[str, Any]:
|
||||
"""转换为 Claude 格式"""
|
||||
return {
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'input_schema': self.parameters,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""LLM 配置"""
|
||||
model: str
|
||||
temperature: float = 0.7
|
||||
top_p: float = 1.0
|
||||
max_tokens: int = 2048
|
||||
stop: Optional[List[str]] = None
|
||||
presence_penalty: float = 0.0
|
||||
frequency_penalty: float = 0.0
|
||||
extra_params: Dict[str, Any] = field(default_factory=dict)
|
||||
# Function Calling 相关
|
||||
tools: Optional[List[ToolDefinition]] = None # 工具定义列表
|
||||
tool_choice: str = 'auto' # 工具选择策略: auto, none, required, 或具体工具名
|
||||
|
||||
|
||||
class BaseLLMProvider(ABC):
|
||||
"""
|
||||
LLM 提供商基类
|
||||
|
||||
所有提供商适配器必须继承此类并实现抽象方法
|
||||
"""
|
||||
|
||||
# 提供商类型标识
|
||||
provider_type: str = ''
|
||||
|
||||
# 提供商显示名称
|
||||
provider_name: str = ''
|
||||
|
||||
# 支持的模型类型
|
||||
supported_model_types: List[str] = ['chat']
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str = '',
|
||||
api_base: str = '',
|
||||
**kwargs
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.extra_config = kwargs
|
||||
|
||||
@abstractmethod
|
||||
def chat(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
同步对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
config: 配置
|
||||
|
||||
Returns:
|
||||
LLMResponse
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chat_async(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
异步对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
config: 配置
|
||||
|
||||
Returns:
|
||||
LLMResponse
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> AsyncGenerator[LLMStreamChunk, None]:
|
||||
"""
|
||||
异步流式对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
config: 配置
|
||||
|
||||
Yields:
|
||||
LLMStreamChunk
|
||||
"""
|
||||
pass
|
||||
|
||||
def chat_stream_sync(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
):
|
||||
"""
|
||||
同步流式对话(生成器)
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
config: 配置
|
||||
|
||||
Yields:
|
||||
LLMStreamChunk
|
||||
"""
|
||||
# 默认实现:调用同步 chat 并返回单个结果
|
||||
response = self.chat(messages, config)
|
||||
yield LLMStreamChunk(
|
||||
content=response.content,
|
||||
is_finished=True,
|
||||
finish_reason=response.finish_reason,
|
||||
prompt_tokens=response.prompt_tokens,
|
||||
completion_tokens=response.completion_tokens,
|
||||
total_tokens=response.total_tokens,
|
||||
)
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""
|
||||
验证配置是否有效
|
||||
|
||||
Returns:
|
||||
是否有效
|
||||
"""
|
||||
return bool(self.api_key)
|
||||
|
||||
def get_available_models(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
Returns:
|
||||
模型列表
|
||||
"""
|
||||
return []
|
||||
|
||||
async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从提供商 API 在线拉取最新模型列表
|
||||
|
||||
子类可覆盖此方法实现具体的 API 调用逻辑。
|
||||
默认返回空列表,表示该提供商不支持在线拉取。
|
||||
|
||||
Returns:
|
||||
模型列表,格式与 get_default_models 一致
|
||||
"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取默认模型列表(用于初始化)
|
||||
|
||||
Returns:
|
||||
默认模型列表
|
||||
"""
|
||||
return []
|
||||
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Anthropic Claude 提供商适配器
|
||||
"""
|
||||
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 ClaudeProvider(BaseLLMProvider):
|
||||
"""
|
||||
Anthropic Claude 提供商适配器
|
||||
|
||||
支持 Claude 3 系列模型
|
||||
"""
|
||||
|
||||
provider_type = 'claude'
|
||||
provider_name = 'Anthropic Claude'
|
||||
supported_model_types = ['chat']
|
||||
|
||||
DEFAULT_API_BASE = 'https://api.anthropic.com'
|
||||
|
||||
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:
|
||||
import anthropic
|
||||
self._client = anthropic.Anthropic(
|
||||
api_key=self.api_key,
|
||||
base_url=self.api_base if self.api_base != self.DEFAULT_API_BASE else None,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError('请安装 anthropic 库: pip install anthropic')
|
||||
return self._client
|
||||
|
||||
def _get_async_client(self):
|
||||
"""获取异步客户端"""
|
||||
if self._async_client is None:
|
||||
try:
|
||||
import anthropic
|
||||
self._async_client = anthropic.AsyncAnthropic(
|
||||
api_key=self.api_key,
|
||||
base_url=self.api_base if self.api_base != self.DEFAULT_API_BASE else None,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError('请安装 anthropic 库: pip install anthropic')
|
||||
return self._async_client
|
||||
|
||||
def _convert_messages(self, messages: List[LLMMessage]) -> tuple:
|
||||
"""
|
||||
转换消息格式
|
||||
|
||||
Claude API 需要将 system 消息单独传递
|
||||
"""
|
||||
system_prompt = ''
|
||||
converted_messages = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == 'system':
|
||||
system_prompt = msg.content
|
||||
elif msg.role == 'tool':
|
||||
# Claude 的工具结果消息格式
|
||||
converted_messages.append({
|
||||
'role': 'user',
|
||||
'content': [{
|
||||
'type': 'tool_result',
|
||||
'tool_use_id': msg.tool_call_id,
|
||||
'content': msg.content,
|
||||
}],
|
||||
})
|
||||
elif msg.role == 'assistant' and msg.tool_calls:
|
||||
# 包含工具调用的助手消息
|
||||
content = []
|
||||
if msg.content:
|
||||
content.append({'type': 'text', 'text': msg.content})
|
||||
for tc in msg.tool_calls:
|
||||
content.append({
|
||||
'type': 'tool_use',
|
||||
'id': tc.get('id', ''),
|
||||
'name': tc.get('function', {}).get('name', ''),
|
||||
'input': tc.get('function', {}).get('arguments', {}),
|
||||
})
|
||||
converted_messages.append({
|
||||
'role': 'assistant',
|
||||
'content': content,
|
||||
})
|
||||
else:
|
||||
converted_messages.append({
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
})
|
||||
|
||||
return system_prompt, converted_messages
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> LLMResponse:
|
||||
"""同步对话"""
|
||||
client = self._get_client()
|
||||
system_prompt, converted_messages = self._convert_messages(messages)
|
||||
|
||||
kwargs = {
|
||||
'model': config.model,
|
||||
'messages': converted_messages,
|
||||
'max_tokens': config.max_tokens,
|
||||
'temperature': config.temperature,
|
||||
'top_p': config.top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
kwargs['system'] = system_prompt
|
||||
|
||||
if config.stop:
|
||||
kwargs['stop_sequences'] = config.stop
|
||||
|
||||
# 添加 Function Calling 参数
|
||||
if config.tools:
|
||||
kwargs['tools'] = [t.to_claude_format() for t in config.tools]
|
||||
if config.tool_choice == 'required':
|
||||
kwargs['tool_choice'] = {'type': 'any'}
|
||||
elif config.tool_choice == 'none':
|
||||
# Claude 不支持 none,不传递 tools 即可
|
||||
del kwargs['tools']
|
||||
elif config.tool_choice != 'auto':
|
||||
# 指定具体工具
|
||||
kwargs['tool_choice'] = {'type': 'tool', 'name': config.tool_choice}
|
||||
else:
|
||||
kwargs['tool_choice'] = {'type': 'auto'}
|
||||
|
||||
response = client.messages.create(**kwargs)
|
||||
|
||||
# 解析响应内容和工具调用
|
||||
content = ''
|
||||
tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
content = block.text
|
||||
elif block.type == 'tool_use':
|
||||
tool_calls.append(ToolCall(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
arguments=block.input if isinstance(block.input, dict) else {},
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=response.model,
|
||||
prompt_tokens=response.usage.input_tokens,
|
||||
completion_tokens=response.usage.output_tokens,
|
||||
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
||||
finish_reason=response.stop_reason or '',
|
||||
raw_response=response.model_dump(),
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
)
|
||||
|
||||
async def chat_async(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> LLMResponse:
|
||||
"""异步对话"""
|
||||
client = self._get_async_client()
|
||||
system_prompt, converted_messages = self._convert_messages(messages)
|
||||
|
||||
kwargs = {
|
||||
'model': config.model,
|
||||
'messages': converted_messages,
|
||||
'max_tokens': config.max_tokens,
|
||||
'temperature': config.temperature,
|
||||
'top_p': config.top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
kwargs['system'] = system_prompt
|
||||
|
||||
if config.stop:
|
||||
kwargs['stop_sequences'] = config.stop
|
||||
|
||||
# 添加 Function Calling 参数
|
||||
if config.tools:
|
||||
kwargs['tools'] = [t.to_claude_format() for t in config.tools]
|
||||
if config.tool_choice == 'required':
|
||||
kwargs['tool_choice'] = {'type': 'any'}
|
||||
elif config.tool_choice == 'none':
|
||||
del kwargs['tools']
|
||||
elif config.tool_choice != 'auto':
|
||||
kwargs['tool_choice'] = {'type': 'tool', 'name': config.tool_choice}
|
||||
else:
|
||||
kwargs['tool_choice'] = {'type': 'auto'}
|
||||
|
||||
response = await client.messages.create(**kwargs)
|
||||
|
||||
# 解析响应内容和工具调用
|
||||
content = ''
|
||||
tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
content = block.text
|
||||
elif block.type == 'tool_use':
|
||||
tool_calls.append(ToolCall(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
arguments=block.input if isinstance(block.input, dict) else {},
|
||||
))
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=response.model,
|
||||
prompt_tokens=response.usage.input_tokens,
|
||||
completion_tokens=response.usage.output_tokens,
|
||||
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
||||
finish_reason=response.stop_reason or '',
|
||||
raw_response=response.model_dump(),
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: List[LLMMessage],
|
||||
config: LLMConfig,
|
||||
) -> AsyncGenerator[LLMStreamChunk, None]:
|
||||
"""流式对话"""
|
||||
client = self._get_async_client()
|
||||
system_prompt, converted_messages = self._convert_messages(messages)
|
||||
|
||||
kwargs = {
|
||||
'model': config.model,
|
||||
'messages': converted_messages,
|
||||
'max_tokens': config.max_tokens,
|
||||
'temperature': config.temperature,
|
||||
'top_p': config.top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
kwargs['system'] = system_prompt
|
||||
|
||||
if config.stop:
|
||||
kwargs['stop_sequences'] = config.stop
|
||||
|
||||
# 添加 Function Calling 参数
|
||||
if config.tools:
|
||||
kwargs['tools'] = [t.to_claude_format() for t in config.tools]
|
||||
if config.tool_choice == 'required':
|
||||
kwargs['tool_choice'] = {'type': 'any'}
|
||||
elif config.tool_choice == 'none':
|
||||
del kwargs['tools']
|
||||
elif config.tool_choice != 'auto':
|
||||
kwargs['tool_choice'] = {'type': 'tool', 'name': config.tool_choice}
|
||||
else:
|
||||
kwargs['tool_choice'] = {'type': 'auto'}
|
||||
|
||||
# 用于累积工具调用
|
||||
tool_calls_accumulator = [] # List of {id, name, input_json}
|
||||
current_tool_call = None
|
||||
|
||||
async with client.messages.stream(**kwargs) as stream:
|
||||
async for event in stream:
|
||||
if hasattr(event, 'type'):
|
||||
if event.type == 'content_block_start':
|
||||
if hasattr(event, 'content_block') and event.content_block.type == 'tool_use':
|
||||
current_tool_call = {
|
||||
'id': event.content_block.id,
|
||||
'name': event.content_block.name,
|
||||
'input_json': '',
|
||||
}
|
||||
elif event.type == 'content_block_delta':
|
||||
if hasattr(event, 'delta'):
|
||||
if event.delta.type == 'text_delta':
|
||||
yield LLMStreamChunk(
|
||||
content=event.delta.text,
|
||||
is_finished=False,
|
||||
)
|
||||
elif event.delta.type == 'input_json_delta' and current_tool_call:
|
||||
current_tool_call['input_json'] += event.delta.partial_json
|
||||
elif event.type == 'content_block_stop':
|
||||
if current_tool_call:
|
||||
tool_calls_accumulator.append(current_tool_call)
|
||||
current_tool_call = None
|
||||
|
||||
# 获取最终的 usage 信息
|
||||
final_message = await stream.get_final_message()
|
||||
|
||||
# 解析工具调用
|
||||
tool_calls = None
|
||||
if tool_calls_accumulator:
|
||||
tool_calls = self._parse_accumulated_tool_calls(tool_calls_accumulator)
|
||||
|
||||
yield LLMStreamChunk(
|
||||
content='',
|
||||
is_finished=True,
|
||||
finish_reason=final_message.stop_reason or '',
|
||||
prompt_tokens=final_message.usage.input_tokens,
|
||||
completion_tokens=final_message.usage.output_tokens,
|
||||
total_tokens=final_message.usage.input_tokens + final_message.usage.output_tokens,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
def _parse_accumulated_tool_calls(self, accumulator: List[Dict]) -> List[ToolCall]:
|
||||
"""解析累积的工具调用"""
|
||||
import json
|
||||
result = []
|
||||
for tc in accumulator:
|
||||
try:
|
||||
arguments = json.loads(tc['input_json']) if tc['input_json'] else {}
|
||||
except json.JSONDecodeError:
|
||||
arguments = {'raw': tc['input_json']}
|
||||
|
||||
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()
|
||||
system_prompt, converted_messages = self._convert_messages(messages)
|
||||
|
||||
kwargs = {
|
||||
'model': config.model,
|
||||
'messages': converted_messages,
|
||||
'max_tokens': config.max_tokens,
|
||||
'temperature': config.temperature,
|
||||
'top_p': config.top_p,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
kwargs['system'] = system_prompt
|
||||
|
||||
if config.stop:
|
||||
kwargs['stop_sequences'] = config.stop
|
||||
|
||||
# 添加 Function Calling 参数
|
||||
if config.tools:
|
||||
kwargs['tools'] = [t.to_claude_format() for t in config.tools]
|
||||
if config.tool_choice == 'required':
|
||||
kwargs['tool_choice'] = {'type': 'any'}
|
||||
elif config.tool_choice == 'none':
|
||||
del kwargs['tools']
|
||||
elif config.tool_choice != 'auto':
|
||||
kwargs['tool_choice'] = {'type': 'tool', 'name': config.tool_choice}
|
||||
else:
|
||||
kwargs['tool_choice'] = {'type': 'auto'}
|
||||
|
||||
# 用于累积工具调用
|
||||
tool_calls_accumulator = []
|
||||
current_tool_call = None
|
||||
|
||||
with client.messages.stream(**kwargs) as stream:
|
||||
for event in stream:
|
||||
if hasattr(event, 'type'):
|
||||
if event.type == 'content_block_start':
|
||||
if hasattr(event, 'content_block') and event.content_block.type == 'tool_use':
|
||||
current_tool_call = {
|
||||
'id': event.content_block.id,
|
||||
'name': event.content_block.name,
|
||||
'input_json': '',
|
||||
}
|
||||
elif event.type == 'content_block_delta':
|
||||
if hasattr(event, 'delta'):
|
||||
if event.delta.type == 'text_delta':
|
||||
yield LLMStreamChunk(
|
||||
content=event.delta.text,
|
||||
is_finished=False,
|
||||
)
|
||||
elif event.delta.type == 'input_json_delta' and current_tool_call:
|
||||
current_tool_call['input_json'] += event.delta.partial_json
|
||||
elif event.type == 'content_block_stop':
|
||||
if current_tool_call:
|
||||
tool_calls_accumulator.append(current_tool_call)
|
||||
current_tool_call = None
|
||||
|
||||
# 获取最终的 usage 信息
|
||||
final_message = stream.get_final_message()
|
||||
|
||||
# 解析工具调用
|
||||
tool_calls = None
|
||||
if tool_calls_accumulator:
|
||||
tool_calls = self._parse_accumulated_tool_calls(tool_calls_accumulator)
|
||||
|
||||
yield LLMStreamChunk(
|
||||
content='',
|
||||
is_finished=True,
|
||||
finish_reason=final_message.stop_reason or '',
|
||||
prompt_tokens=final_message.usage.input_tokens,
|
||||
completion_tokens=final_message.usage.output_tokens,
|
||||
total_tokens=final_message.usage.input_tokens + final_message.usage.output_tokens,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
|
||||
"""通过 Anthropic /v1/models 端点在线拉取模型列表"""
|
||||
import httpx
|
||||
|
||||
base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/')
|
||||
headers = {
|
||||
'x-api-key': self.api_key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(f'{base_url}/v1/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', '')
|
||||
display = item.get('display_name', model_id)
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
'model_name': model_id,
|
||||
'display_name': display,
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
})
|
||||
|
||||
results.sort(key=lambda m: m['model_name'])
|
||||
logger.info(f'从 Anthropic ({base_url}) 拉取到 {len(results)} 个模型')
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'在线拉取 Anthropic 模型列表失败 ({base_url}): {e}')
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls) -> List[Dict[str, Any]]:
|
||||
"""获取默认模型列表"""
|
||||
return [
|
||||
# ---- Chat 模型 ----
|
||||
{
|
||||
'model_name': 'claude-sonnet-4-20250514',
|
||||
'display_name': 'Claude Sonnet 4',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.003,
|
||||
'output_price': 0.015,
|
||||
},
|
||||
{
|
||||
'model_name': 'claude-3-7-sonnet-20250219',
|
||||
'display_name': 'Claude 3.7 Sonnet',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.003,
|
||||
'output_price': 0.015,
|
||||
},
|
||||
{
|
||||
'model_name': 'claude-3-5-sonnet-20241022',
|
||||
'display_name': 'Claude 3.5 Sonnet',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.003,
|
||||
'output_price': 0.015,
|
||||
},
|
||||
{
|
||||
'model_name': 'claude-3-5-haiku-20241022',
|
||||
'display_name': 'Claude 3.5 Haiku',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.001,
|
||||
'output_price': 0.005,
|
||||
},
|
||||
{
|
||||
'model_name': 'claude-3-opus-20240229',
|
||||
'display_name': 'Claude 3 Opus',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.015,
|
||||
'output_price': 0.075,
|
||||
},
|
||||
{
|
||||
'model_name': 'claude-3-haiku-20240307',
|
||||
'display_name': 'Claude 3 Haiku',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.00025,
|
||||
'output_price': 0.00125,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
DeepSeek 提供商适配器
|
||||
|
||||
兼容 OpenAI API,继承 OpenAI 适配器
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .registry import ProviderRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ProviderRegistry.register
|
||||
class DeepSeekProvider(OpenAIProvider):
|
||||
"""
|
||||
DeepSeek 提供商适配器
|
||||
|
||||
兼容 OpenAI API 格式,支持 DeepSeek-V3、DeepSeek-R1 等模型
|
||||
"""
|
||||
|
||||
provider_type = 'deepseek'
|
||||
provider_name = 'DeepSeek'
|
||||
supported_model_types = ['chat', 'completion']
|
||||
|
||||
DEFAULT_API_BASE = 'https://api.deepseek.com/v1'
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls) -> List[Dict[str, Any]]:
|
||||
"""获取默认模型列表"""
|
||||
return [
|
||||
# ---- Chat 模型 ----
|
||||
{
|
||||
'model_name': 'deepseek-chat',
|
||||
'display_name': 'DeepSeek V3',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 65536,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0014,
|
||||
'output_price': 0.0028,
|
||||
},
|
||||
{
|
||||
'model_name': 'deepseek-reasoner',
|
||||
'display_name': 'DeepSeek R1',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 65536,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': False,
|
||||
'input_price': 0.004,
|
||||
'output_price': 0.016,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
Ollama 本地模型提供商适配器
|
||||
"""
|
||||
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 OllamaProvider(BaseLLMProvider):
|
||||
"""
|
||||
Ollama 本地模型提供商适配器
|
||||
|
||||
支持本地部署的开源模型
|
||||
"""
|
||||
|
||||
provider_type = 'ollama'
|
||||
provider_name = 'Ollama (本地)'
|
||||
supported_model_types = ['chat', 'embedding']
|
||||
|
||||
DEFAULT_API_BASE = 'http://localhost:11434/v1'
|
||||
|
||||
def __init__(self, api_key: str = '', api_base: str = '', **kwargs):
|
||||
super().__init__(api_key, api_base, **kwargs)
|
||||
# Ollama 使用 ollama_host 参数
|
||||
ollama_host = kwargs.get('ollama_host', '')
|
||||
if ollama_host:
|
||||
self.api_base = f'{ollama_host.rstrip("/")}/v1'
|
||||
else:
|
||||
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='ollama', # Ollama 不需要 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='ollama', # Ollama 不需要 API Key
|
||||
base_url=self.api_base,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError('请安装 openai 库: pip install openai')
|
||||
return self._async_client
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""Ollama 不需要 API Key"""
|
||||
return True
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# 添加 Function Calling 参数(Ollama 部分模型支持)
|
||||
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 or f'call_{len(result)}',
|
||||
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,
|
||||
}
|
||||
|
||||
# 添加 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,
|
||||
}
|
||||
|
||||
# 添加 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 hasattr(tc_delta, 'index') else 0
|
||||
if tc_index not in tool_call_accumulator:
|
||||
tool_call_accumulator[tc_index] = {
|
||||
'id': getattr(tc_delta, 'id', '') or f'call_{tc_index}',
|
||||
'name': '',
|
||||
'arguments': '',
|
||||
}
|
||||
if hasattr(tc_delta, 'id') and 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 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,
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# 添加 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 hasattr(tc_delta, 'index') else 0
|
||||
if tc_index not in tool_call_accumulator:
|
||||
tool_call_accumulator[tc_index] = {
|
||||
'id': getattr(tc_delta, 'id', '') or f'call_{tc_index}',
|
||||
'name': '',
|
||||
'arguments': '',
|
||||
}
|
||||
if hasattr(tc_delta, 'id') and 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 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,
|
||||
)
|
||||
|
||||
_EMBEDDING_KEYWORDS = ('embed', 'bge', 'nomic-embed', 'mxbai-embed')
|
||||
_RERANK_KEYWORDS = ('rerank', 'reranker')
|
||||
|
||||
def get_available_models(self) -> List[Dict[str, Any]]:
|
||||
"""获取本地可用的模型列表"""
|
||||
try:
|
||||
import httpx
|
||||
base_url = self.api_base.replace('/v1', '')
|
||||
response = httpx.get(f'{base_url}/api/tags', timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = []
|
||||
for model in data.get('models', []):
|
||||
models.append({
|
||||
'model_name': model.get('name', ''),
|
||||
'display_name': model.get('name', ''),
|
||||
'size': model.get('size', 0),
|
||||
'modified_at': model.get('modified_at', ''),
|
||||
})
|
||||
return models
|
||||
except Exception as e:
|
||||
logger.warning(f'获取 Ollama 模型列表失败: {e}')
|
||||
return []
|
||||
|
||||
async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
|
||||
"""通过 /api/tags 端点拉取 Ollama 本地已安装的模型"""
|
||||
import httpx
|
||||
|
||||
base_url = (self.api_base or 'http://localhost:11434').rstrip('/')
|
||||
# Ollama 的 /api/tags 在根路径,去掉 /v1
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f'{base_url}/api/tags')
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for item in data.get('models', []):
|
||||
name = item.get('name', '')
|
||||
if not name:
|
||||
continue
|
||||
|
||||
name_lower = name.lower()
|
||||
if any(kw in name_lower for kw in self._RERANK_KEYWORDS):
|
||||
model_type = 'rerank'
|
||||
elif any(kw in name_lower for kw in self._EMBEDDING_KEYWORDS):
|
||||
model_type = 'embedding'
|
||||
else:
|
||||
model_type = 'chat'
|
||||
|
||||
# 从 size 推算显示名称
|
||||
size_gb = round(item.get('size', 0) / (1024 ** 3), 1)
|
||||
display = f'{name} ({size_gb}GB)' if size_gb > 0 else name
|
||||
|
||||
results.append({
|
||||
'model_name': name,
|
||||
'display_name': display,
|
||||
'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,
|
||||
})
|
||||
|
||||
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'从 Ollama ({base_url}) 拉取到 {len(results)} 个本地模型')
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'在线拉取 Ollama 模型列表失败 ({base_url}): {e}')
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls) -> List[Dict[str, Any]]:
|
||||
"""获取默认模型列表(常用的开源模型)"""
|
||||
return [
|
||||
# ---- Chat 模型 ----
|
||||
{
|
||||
'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,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'qwen3:8b',
|
||||
'display_name': 'Qwen3 8B',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 131072,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'llama3.3',
|
||||
'display_name': 'Llama 3.3 70B',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 128000,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'llama3.2',
|
||||
'display_name': 'Llama 3.2',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 128000,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'deepseek-r1',
|
||||
'display_name': 'DeepSeek R1',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 64000,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': False,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'mistral',
|
||||
'display_name': 'Mistral',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 4096,
|
||||
'context_window': 32000,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'gemma3',
|
||||
'display_name': 'Gemma 3',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 128000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
# ---- Embedding 模型 ----
|
||||
{
|
||||
'model_name': 'nomic-embed-text',
|
||||
'display_name': 'Nomic Embed Text',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'bge-m3',
|
||||
'display_name': 'BGE-M3',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'mxbai-embed-large',
|
||||
'display_name': 'MxBai Embed Large',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 512,
|
||||
'context_window': 512,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
# ---- Rerank 模型 ----
|
||||
{
|
||||
'model_name': 'bge-reranker-v2-m3',
|
||||
'display_name': 'BGE Reranker V2 M3',
|
||||
'model_type': 'rerank',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
OpenAI 提供商适配器
|
||||
"""
|
||||
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 OpenAIProvider(BaseLLMProvider):
|
||||
"""
|
||||
OpenAI 提供商适配器
|
||||
|
||||
支持 GPT-3.5, GPT-4 等模型
|
||||
"""
|
||||
|
||||
provider_type = 'openai'
|
||||
provider_name = 'OpenAI'
|
||||
supported_model_types = ['chat', 'completion', 'embedding']
|
||||
|
||||
DEFAULT_API_BASE = 'https://api.openai.com/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,
|
||||
'presence_penalty': config.presence_penalty,
|
||||
'frequency_penalty': config.frequency_penalty,
|
||||
**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,
|
||||
'presence_penalty': config.presence_penalty,
|
||||
'frequency_penalty': config.frequency_penalty,
|
||||
**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,
|
||||
'presence_penalty': config.presence_penalty,
|
||||
'frequency_penalty': config.frequency_penalty,
|
||||
'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 = {} # id -> {name, arguments}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# 最后一个 chunk 包含 usage 信息
|
||||
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,
|
||||
'presence_penalty': config.presence_penalty,
|
||||
'frequency_penalty': config.frequency_penalty,
|
||||
'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,
|
||||
)
|
||||
|
||||
# 最后一个 chunk 包含 usage 信息
|
||||
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', 'nomic-embed', 'mxbai-embed', 'jina-embedding')
|
||||
_RERANK_KEYWORDS = ('rerank', 'reranker')
|
||||
_SKIP_KEYWORDS = ('dall-e', 'tts', 'whisper', 'audio', 'moderation', 'davinci', 'babbage')
|
||||
|
||||
async def fetch_models_from_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
通过 /v1/models API 在线拉取最新模型列表
|
||||
|
||||
适用于所有 OpenAI 兼容的提供商
|
||||
"""
|
||||
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,
|
||||
})
|
||||
|
||||
# 补充默认列表中的 embedding/rerank 模型(API 可能不返回这些类型)
|
||||
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)
|
||||
|
||||
# 按类型排序:chat → embedding → rerank
|
||||
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)} 个模型')
|
||||
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': 'o4-mini',
|
||||
'display_name': 'o4-mini',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 200000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0011,
|
||||
'output_price': 0.0044,
|
||||
},
|
||||
{
|
||||
'model_name': 'o3-mini',
|
||||
'display_name': 'o3-mini',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 200000,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0011,
|
||||
'output_price': 0.0044,
|
||||
},
|
||||
{
|
||||
'model_name': 'gpt-4.1',
|
||||
'display_name': 'GPT-4.1',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 32768,
|
||||
'context_window': 1047576,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.002,
|
||||
'output_price': 0.008,
|
||||
},
|
||||
{
|
||||
'model_name': 'gpt-4.1-mini',
|
||||
'display_name': 'GPT-4.1 Mini',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 32768,
|
||||
'context_window': 1047576,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0004,
|
||||
'output_price': 0.0016,
|
||||
},
|
||||
{
|
||||
'model_name': 'gpt-4.1-nano',
|
||||
'display_name': 'GPT-4.1 Nano',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 32768,
|
||||
'context_window': 1047576,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0001,
|
||||
'output_price': 0.0004,
|
||||
},
|
||||
{
|
||||
'model_name': 'gpt-4o',
|
||||
'display_name': 'GPT-4o',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 128000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.0025,
|
||||
'output_price': 0.01,
|
||||
},
|
||||
{
|
||||
'model_name': 'gpt-4o-mini',
|
||||
'display_name': 'GPT-4o Mini',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 16384,
|
||||
'context_window': 128000,
|
||||
'supports_vision': True,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.00015,
|
||||
'output_price': 0.0006,
|
||||
},
|
||||
# ---- Embedding 模型 ----
|
||||
{
|
||||
'model_name': 'text-embedding-3-small',
|
||||
'display_name': 'Text Embedding 3 Small',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8191,
|
||||
'context_window': 8191,
|
||||
'input_price': 0.00002,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'text-embedding-3-large',
|
||||
'display_name': 'Text Embedding 3 Large',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8191,
|
||||
'context_window': 8191,
|
||||
'input_price': 0.00013,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'text-embedding-ada-002',
|
||||
'display_name': 'Text Embedding Ada 002',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8191,
|
||||
'context_window': 8191,
|
||||
'input_price': 0.0001,
|
||||
'output_price': 0,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
阿里通义千问提供商适配器
|
||||
"""
|
||||
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,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
提供商注册中心
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
from .base import BaseLLMProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""
|
||||
提供商注册中心
|
||||
|
||||
管理所有 LLM 提供商适配器的注册和获取
|
||||
"""
|
||||
|
||||
_providers: Dict[str, Type[BaseLLMProvider]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, provider_class: Type[BaseLLMProvider]) -> Type[BaseLLMProvider]:
|
||||
"""
|
||||
注册提供商(可作为装饰器使用)
|
||||
|
||||
Args:
|
||||
provider_class: 提供商类
|
||||
|
||||
Returns:
|
||||
提供商类
|
||||
"""
|
||||
provider_type = provider_class.provider_type
|
||||
if not provider_type:
|
||||
raise ValueError(f'Provider {provider_class.__name__} must have a provider_type')
|
||||
|
||||
cls._providers[provider_type] = provider_class
|
||||
logger.info(f'Registered LLM provider: {provider_type}')
|
||||
return provider_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, provider_type: str) -> Optional[Type[BaseLLMProvider]]:
|
||||
"""
|
||||
获取提供商类
|
||||
|
||||
Args:
|
||||
provider_type: 提供商类型
|
||||
|
||||
Returns:
|
||||
提供商类或 None
|
||||
"""
|
||||
return cls._providers.get(provider_type)
|
||||
|
||||
@classmethod
|
||||
def create_instance(
|
||||
cls,
|
||||
provider_type: str,
|
||||
api_key: str = '',
|
||||
api_base: str = '',
|
||||
**kwargs
|
||||
) -> Optional[BaseLLMProvider]:
|
||||
"""
|
||||
创建提供商实例
|
||||
|
||||
Args:
|
||||
provider_type: 提供商类型
|
||||
api_key: API Key
|
||||
api_base: API 地址
|
||||
**kwargs: 其他配置
|
||||
|
||||
Returns:
|
||||
提供商实例或 None
|
||||
"""
|
||||
provider_class = cls.get(provider_type)
|
||||
if not provider_class:
|
||||
logger.warning(f'Unknown provider type: {provider_type}')
|
||||
return None
|
||||
|
||||
return provider_class(api_key=api_key, api_base=api_base, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_all_types(cls) -> List[Dict[str, str]]:
|
||||
"""
|
||||
获取所有已注册的提供商类型
|
||||
|
||||
Returns:
|
||||
提供商类型列表
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'type': provider_type,
|
||||
'name': provider_class.provider_name,
|
||||
}
|
||||
for provider_type, provider_class in cls._providers.items()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls, provider_type: str) -> List[Dict]:
|
||||
"""
|
||||
获取提供商的默认模型列表
|
||||
|
||||
Args:
|
||||
provider_type: 提供商类型
|
||||
|
||||
Returns:
|
||||
默认模型列表
|
||||
"""
|
||||
provider_class = cls.get(provider_type)
|
||||
if not provider_class:
|
||||
return []
|
||||
return provider_class.get_default_models()
|
||||
|
||||
@classmethod
|
||||
async def fetch_models_from_api(
|
||||
cls,
|
||||
provider_type: str,
|
||||
api_key: str = '',
|
||||
api_base: str = '',
|
||||
**kwargs
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
通过 API 在线拉取提供商的最新模型列表
|
||||
|
||||
Args:
|
||||
provider_type: 提供商类型
|
||||
api_key: API Key
|
||||
api_base: API 地址
|
||||
|
||||
Returns:
|
||||
模型列表,失败时返回空列表
|
||||
"""
|
||||
instance = cls.create_instance(provider_type, api_key=api_key, api_base=api_base, **kwargs)
|
||||
if not instance:
|
||||
return []
|
||||
return await instance.fetch_models_from_api()
|
||||
|
||||
|
||||
# 自动加载所有提供商
|
||||
def _load_providers():
|
||||
"""加载所有提供商适配器"""
|
||||
from . import openai_provider
|
||||
from . import claude_provider
|
||||
from . import qwen_provider
|
||||
from . import ollama_provider
|
||||
from . import deepseek_provider
|
||||
from . import siliconflow_provider
|
||||
|
||||
|
||||
# 延迟加载
|
||||
try:
|
||||
_load_providers()
|
||||
except ImportError as e:
|
||||
logger.warning(f'Failed to load some providers: {e}')
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
硅基流动 (SiliconFlow) 提供商适配器
|
||||
|
||||
兼容 OpenAI API,继承 OpenAI 适配器
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .registry import ProviderRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ProviderRegistry.register
|
||||
class SiliconFlowProvider(OpenAIProvider):
|
||||
"""
|
||||
硅基流动 (SiliconFlow) 提供商适配器
|
||||
|
||||
兼容 OpenAI API 格式,支持多种开源模型、Embedding 和 Rerank
|
||||
"""
|
||||
|
||||
provider_type = 'siliconflow'
|
||||
provider_name = 'SiliconFlow'
|
||||
supported_model_types = ['chat', 'embedding', 'rerank']
|
||||
|
||||
DEFAULT_API_BASE = 'https://api.siliconflow.cn/v1'
|
||||
|
||||
@classmethod
|
||||
def get_default_models(cls) -> List[Dict[str, Any]]:
|
||||
"""获取默认模型列表"""
|
||||
return [
|
||||
# ---- Chat 模型 ----
|
||||
{
|
||||
'model_name': 'Qwen/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': 'Qwen/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/Qwen3-8B',
|
||||
'display_name': 'Qwen3 8B',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 131072,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'deepseek-ai/DeepSeek-V3',
|
||||
'display_name': 'DeepSeek V3',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 65536,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': True,
|
||||
'input_price': 0.002,
|
||||
'output_price': 0.008,
|
||||
},
|
||||
{
|
||||
'model_name': 'deepseek-ai/DeepSeek-R1',
|
||||
'display_name': 'DeepSeek R1',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 65536,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': False,
|
||||
'input_price': 0.004,
|
||||
'output_price': 0.016,
|
||||
},
|
||||
{
|
||||
'model_name': 'Pro/deepseek-ai/DeepSeek-R1',
|
||||
'display_name': 'DeepSeek R1 (Pro)',
|
||||
'model_type': 'chat',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 65536,
|
||||
'supports_vision': False,
|
||||
'supports_function_call': False,
|
||||
'input_price': 0.008,
|
||||
'output_price': 0.032,
|
||||
},
|
||||
# ---- Embedding 模型 ----
|
||||
{
|
||||
'model_name': 'BAAI/bge-m3',
|
||||
'display_name': 'BGE-M3',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'BAAI/bge-large-zh-v1.5',
|
||||
'display_name': 'BGE Large ZH v1.5',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 512,
|
||||
'context_window': 512,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'jinaai/jina-embeddings-v3',
|
||||
'display_name': 'Jina Embeddings V3',
|
||||
'model_type': 'embedding',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
# ---- Rerank 模型 ----
|
||||
{
|
||||
'model_name': 'BAAI/bge-reranker-v2-m3',
|
||||
'display_name': 'BGE Reranker V2 M3',
|
||||
'model_type': 'rerank',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
{
|
||||
'model_name': 'jinaai/jina-reranker-v2-base-multilingual',
|
||||
'display_name': 'Jina Reranker V2 Multilingual',
|
||||
'model_type': 'rerank',
|
||||
'max_tokens': 8192,
|
||||
'context_window': 8192,
|
||||
'input_price': 0,
|
||||
'output_price': 0,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user