Files

533 lines
20 KiB
Python

"""
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 端点在线拉取模型列表"""
base_url = (self.api_base or self.DEFAULT_API_BASE).rstrip('/')
try:
return await self.fetch_models_from_api_strict()
except Exception as e:
logger.warning(f'在线拉取 Anthropic 模型列表失败 ({base_url}): {e}')
return []
async def fetch_models_from_api_strict(self) -> List[Dict[str, Any]]:
"""通过 Anthropic 端点严格拉取模型列表,失败时保留上游异常"""
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',
}
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
@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,
},
]