Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions lazyllm/components/prompter/builtinPrompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ def _generate_prompt_dict_impl(self, instruction, input, user, history, tools, l
'content': self._system + '\n' + instruction if instruction else self._system})
return dict(messages=history, tools=tools) if tools else dict(messages=history)

# Used for OnlineChatModule with Anthropic-format API
def _generate_prompt_anthropic_impl(self, instruction, input, user, history, tools, label):
result = self._generate_prompt_dict_impl(instruction, input, user, history, tools, label)
messages = result.get('messages', [])
system_text = None
non_system = []
for msg in messages:
if msg.get('role') == 'system':
system_text = msg['content']
else:
non_system.append(msg)
out = dict(messages=non_system)
if system_text is not None:
out['system'] = system_text
if tools:
out['tools'] = result['tools']
return out

def pre_hook(self, func: Optional[Callable] = None):
self._pre_hook = func
return self
Expand All @@ -203,17 +221,26 @@ def generate_prompt(self, input: Union[str, List, Dict[str, str], None] = None,
history: List[Union[List[str], Dict[str, Any]]] = None,
tools: Union[List[Dict[str, Any]], None] = None,
label: Union[str, None] = None,
*, show: bool = False, return_dict: bool = False) -> Union[str, Dict]:
*, show: bool = False, return_dict: bool = False,
format: Optional[str] = None) -> Union[str, Dict]:
if return_dict and format is None:
LOG.log_once('return_dict is deprecated, use format="openai" instead.', level='warning')
format = 'openai'
input = copy.deepcopy(input)
if self._pre_hook:
input, history, tools, label = self._pre_hook(input, history, tools, label)
tools = tools or self._tools
instruction, input = self._get_instruction_and_input(input, return_dict=return_dict, tools=tools)
history = self._get_histories(history, return_dict=return_dict)
tools = self._get_tools(tools, return_dict=return_dict)
instruction, input = self._get_instruction_and_input(input, return_dict=bool(format), tools=tools)
history = self._get_histories(history, return_dict=bool(format))
tools = self._get_tools(tools, return_dict=bool(format))
self._check_values(instruction, input, history, tools)
instruction, user_instruction = self._split_instruction(instruction)
func = self._generate_prompt_dict_impl if return_dict else self._generate_prompt_impl
if format == 'anthropic':
func = self._generate_prompt_anthropic_impl
elif format == 'openai':
func = self._generate_prompt_dict_impl
else:
func = self._generate_prompt_impl
result = func(instruction, input, user_instruction, history, tools, label)
if self._show or show: LOG.info(result)
return result
Expand All @@ -225,8 +252,12 @@ def get_response(self, output: str, input: Union[str, None] = None) -> str:

class EmptyPrompter(LazyLLMPrompterBase):

def generate_prompt(self, input, history=None, tools=None, label=None, show=False, return_dict=False):
if return_dict:
def generate_prompt(self, input, history=None, tools=None, label=None, show=False,
return_dict=False, format=None):
if return_dict and format is None:
LOG.log_once('return_dict is deprecated, use format="openai" instead.', level='warning')
format = 'openai'
if format:
return {'messages': [{'role': 'user', 'content': input}]}
if self._show or show: LOG.info(input)
return input
58 changes: 58 additions & 0 deletions lazyllm/docs/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,64 @@
>>> print(response)
''')

add_chinese_doc('llms.onlinemodule.supplier.claude.ClaudeChat', '''\
Claude 在线聊天模块,继承自 OnlineChatModuleBase。
封装了对 Anthropic Claude Messages API(/v1/messages)的调用,用于进行多轮问答交互。
默认使用模型 `claude-opus-4-5`,支持流式输出、工具调用和调用链追踪。

与 OpenAI 兼容格式不同,该模块使用 Anthropic 原生 API 格式:请求头使用 `x-api-key` 和
`anthropic-version`,`system` 消息作为顶层字段传递,工具定义使用 `input_schema` 字段。
传入的 OpenAI 格式工具定义会自动转换为 Anthropic 格式。

Args:
model (str): 使用的模型名称,默认为 `claude-opus-4-5`。
base_url (str): API 基础 URL,默认为 `https://api.anthropic.com/v1/`。
api_key (Optional[str]): Anthropic API Key,若未提供,则从 `lazyllm.config['claude_api_key']` 读取。
stream (bool): 是否启用流式输出,默认为 True。
return_trace (bool): 是否返回调用链追踪信息,默认为 False。
**kwargs: 其他传递给基类 OnlineChatModuleBase 的参数。
''')

add_english_doc('llms.onlinemodule.supplier.claude.ClaudeChat', '''\
Claude online chat module, inheriting from OnlineChatModuleBase.
Wraps the Anthropic Claude Messages API (/v1/messages) for multi-turn Q&A interactions.
Defaults to model `claude-opus-4-5`, supporting streaming, tool calls, and optional trace return.

Unlike OpenAI-compatible format, this module uses the native Anthropic API format: the request
header uses `x-api-key` and `anthropic-version`, the `system` message is passed as a top-level
field, and tools use the `input_schema` field. OpenAI-format tool definitions are automatically
converted to Anthropic format.

Args:
model (str): The model name to use. Defaults to `claude-opus-4-5`.
base_url (str): Base URL of the API. Defaults to `https://api.anthropic.com/v1/`.
api_key (Optional[str]): Anthropic API key. If not provided, it is read from `lazyllm.config['claude_api_key']`.
stream (bool): Whether to enable streaming output. Defaults to True.
return_trace (bool): Whether to return trace information. Defaults to False.
**kwargs: Additional arguments passed to the base class OnlineChatModuleBase.
''')

add_example('llms.onlinemodule.supplier.claude.ClaudeChat', '''\
>>> import lazyllm
>>> # Set environment variable: export LAZYLLM_CLAUDE_API_KEY=your_api_key
>>> chat = lazyllm.OnlineChatModule(source='claude', model='claude-opus-4-5')
>>> response = chat('Hello, who are you?')
>>> print(response)

>>> # Tool call example (requires FunctionCallFormatter to preserve tool_calls in output)
>>> from lazyllm.components.formatter import FunctionCallFormatter
>>> tools = [{'type': 'function', 'function': {
... 'name': 'get_weather',
... 'description': 'Get the current weather for a city',
... 'parameters': {'type': 'object', 'properties': {'city': {'type': 'string'}}, 'required': ['city']}
... }}]
>>> chat = lazyllm.OnlineChatModule(source='claude', model='claude-opus-4-5', stream=False)
>>> chat._formatter = FunctionCallFormatter()
>>> result = chat('Please call get_weather for Beijing', tools=tools)
>>> print(result)
''')


add_chinese_doc('llms.onlinemodule.supplier.openai.OpenAIEmbed', '''\
OpenAI 在线嵌入模块。
该类封装了对 OpenAI 嵌入 API 的调用,默认使用模型 `text-embedding-ada-002`,用于将文本编码为向量表示。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LazyLLMOnlineChatModuleBase(LazyLLMOnlineBase, LLMBase):
VLM_MODEL_PREFIX = []
NO_PROXY = True
__lazyllm_registry_key__ = LLMType.CHAT
__format__ = 'openai'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using __format__ as a class attribute is problematic because double underscores trigger Python's name mangling. In the forward method (line 138), self.__format__ will always resolve to _LazyLLMOnlineChatModuleBase__format__ (i.e., 'openai'), even if a subclass like ClaudeChat defines its own __format__. Additionally, __format__ is a special Python method name. It should be renamed to something like _format or message_format to allow proper polymorphism.

Suggested change
__format__ = 'openai'
_format = 'openai'


def __init__(self, api_key: Union[str, List[str]], base_url: str, model_name: str,
stream: Union[bool, Dict[str, str]], return_trace: bool = False, skip_auth: bool = False,
Expand Down Expand Up @@ -70,6 +71,9 @@ def _get_models_list(self):
def _convert_msg_format(self, msg: Dict[str, Any]):
return msg

def _prepare_request_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
return data

def _str_to_json(self, msg: str, stream_output: bool):
if isinstance(msg, bytes):
pattern = re.compile(r'^data:\s*')
Expand Down Expand Up @@ -131,7 +135,7 @@ def forward(self, __input: Union[Dict, str] = None, *, llm_chat_history: List[Li
runtime_url = self._get_chat_url(url) if url else self._chat_url
runtime_model = model or self._model_name

params = {'input': __input, 'history': llm_chat_history, 'return_dict': True}
params = {'input': __input, 'history': llm_chat_history, 'format': self.__format__}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Access the renamed attribute _format instead of the mangled __format__ to ensure subclasses can provide their own format identifier to the prompter.

Suggested change
params = {'input': __input, 'history': llm_chat_history, 'format': self.__format__}
params = {'input': __input, 'history': llm_chat_history, 'format': self._format}

if tools: params['tools'] = tools
data = self._prompt.generate_prompt(**params)
data.update(self._static_params, **dict(model=runtime_model, stream=bool(stream_output)))
Expand All @@ -146,6 +150,7 @@ def forward(self, __input: Union[Dict, str] = None, *, llm_chat_history: List[Li
if msg.get('role') == 'user' and isinstance(msg.get('content'), str):
msg['content'] = self._format_vl_chat_query(msg['content'])

data = self._prepare_request_data(data)
proxies = {'http': None, 'https': None} if self.NO_PROXY else None
with requests.post(runtime_url, json=data, headers=self._header, stream=stream_output,
proxies=proxies) as r:
Expand Down
132 changes: 132 additions & 0 deletions lazyllm/module/llms/onlinemodule/supplier/claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import json
import re
import requests
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin
from ..base import OnlineChatModuleBase


class ClaudeChat(OnlineChatModuleBase):
# Anthropic native Messages API (/v1/messages).
# Differs from OpenAI: x-api-key header, system as top-level field,
# max_tokens required, and SSE event types for streaming.

_ANTHROPIC_VERSION = '2023-06-01'
_DEFAULT_MAX_TOKENS = 4096
__format__ = 'anthropic'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Rename this to _format to match the change in the base class and avoid name mangling issues.

Suggested change
__format__ = 'anthropic'
_format = 'anthropic'


def __init__(self, base_url: Optional[str] = None, model: Optional[str] = None,
api_key: str = None, stream: bool = True, return_trace: bool = False, **kwargs):
base_url = base_url or 'https://api.anthropic.com/v1/'
model = model or 'claude-opus-4-5'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

claude-opus-4-5 is not a valid Anthropic model name. The current flagship models are claude-3-opus-20240229 or claude-3-5-sonnet-20241022. Using a non-existent model name as the default will cause API requests to fail with a 404 or 400 error.

Suggested change
model = model or 'claude-opus-4-5'
model = model or 'claude-3-5-sonnet-20241022'

super().__init__(api_key=api_key or self._default_api_key(),
base_url=base_url, model_name=model, stream=stream,
return_trace=return_trace, **kwargs)

def _get_system_prompt(self):
return 'You are Claude, an AI assistant made by Anthropic. You are helpful, harmless, and honest.'

def _get_chat_url(self, url):
if url.rstrip('/').endswith('v1/messages'):
return url
base = url.rstrip('/')
if base.endswith('/v1'):
return base + '/messages'
return urljoin(url if url.endswith('/') else url + '/', 'v1/messages')

@staticmethod
def _get_header(api_key: str) -> dict:
header = {'Content-Type': 'application/json',
'anthropic-version': ClaudeChat._ANTHROPIC_VERSION}
if api_key:
header['x-api-key'] = api_key
return header

@staticmethod
def _convert_tools(tools: List[Dict]) -> List[Dict]:
# Convert OpenAI-format tools to Anthropic format.
# OpenAI: [{"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}]
# Anthropic: [{"name": ..., "description": ..., "input_schema": {...}}]
result = []
for tool in tools:
fn = tool.get('function', tool)
result.append({
'name': fn['name'],
'description': fn.get('description', ''),
'input_schema': fn.get('parameters', fn.get('input_schema', {'type': 'object', 'properties': {}})),
})
return result

def _prepare_request_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
data = dict(data)
data.setdefault('max_tokens', self._DEFAULT_MAX_TOKENS)
if data.get('tools'):
data['tools'] = self._convert_tools(data['tools'])
return data

def _convert_msg_format(self, msg: Dict[str, Any]):
msg_type = msg.get('type', '')
if msg_type == 'message': # non-stream response
text = ''.join(b.get('text', '') for b in msg.get('content', []) if b.get('type') == 'text')
tool_calls = [
{'id': b['id'], 'type': 'function',
'function': {'name': b['name'], 'arguments': json.dumps(b.get('input', {}), ensure_ascii=False)}}
for b in msg.get('content', []) if b.get('type') == 'tool_use'
]
message: Dict[str, Any] = {'role': 'assistant', 'content': text}
if tool_calls:
message['tool_calls'] = tool_calls
usage = msg.get('usage', {})
return {'choices': [{'message': message}],
'usage': {'prompt_tokens': usage.get('input_tokens', -1),
'completion_tokens': usage.get('output_tokens', -1)}}
if msg_type == 'content_block_delta':
delta_obj = msg.get('delta', {})
if delta_obj.get('type') == 'text_delta':
return {'choices': [{'delta': {'role': 'assistant', 'content': delta_obj.get('text', '')}}]}
if delta_obj.get('type') == 'input_json_delta':
# Partial tool input — carry as tool_calls delta with index
return {'choices': [{'index': msg.get('index', 0),
'delta': {'tool_calls': [{'function': {'arguments': delta_obj.get('partial_json', '')}}]}}]}
if msg_type == 'content_block_start':
block = msg.get('content_block', {})
if block.get('type') == 'tool_use':
# Emit the tool call header (id + name) as the first delta
return {'choices': [{'index': msg.get('index', 0),
'delta': {'role': 'assistant', 'content': '',
'tool_calls': [{'index': msg.get('index', 0),
'id': block['id'], 'type': 'function',
'function': {'name': block['name'], 'arguments': ''}}]}}]}
if msg_type == 'message_start':
usage = msg.get('message', {}).get('usage', {})
return {'choices': [{'delta': {'role': 'assistant', 'content': ''}}],
'usage': {'prompt_tokens': usage.get('input_tokens', -1), 'completion_tokens': -1}}
if msg_type == 'message_delta':
usage = msg.get('usage', {})
return {'choices': [{'delta': {'role': 'assistant', 'content': ''}}],
'usage': {'prompt_tokens': -1, 'completion_tokens': usage.get('output_tokens', -1)}}
return '' # ping / content_block_stop / message_stop → filtered out

def _str_to_json(self, msg: Union[str, bytes], stream_output: bool):
if isinstance(msg, bytes):
msg = re.sub(r'^data:\s*', '', msg.decode('utf-8'))
try:
message = self._convert_msg_format(json.loads(msg))
if not stream_output:
return message
color = stream_output.get('color') if isinstance(stream_output, dict) else None
for item in (message.get('choices', []) if isinstance(message, dict) else []):
delta = item.get('delta', {})
if (content := delta.get('content', '')) and not delta.get('tool_calls'):
self._stream_output(content, color)
return message
except Exception:
return ''

def _validate_api_key(self):
try:
models_url = urljoin(self._base_url, 'models')
response = requests.get(models_url, headers=self._header, timeout=10)
return response.status_code == 200
except Exception:
return False
Comment on lines +127 to +135
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Anthropic's API does not provide a /v1/models endpoint. This validation logic will consistently return a 404 error, causing API key validation to fail even for valid keys. Consider validating the key by making a minimal request to the /v1/messages endpoint (e.g., with max_tokens=1) or simply verifying that the key is provided in the configuration.

5 changes: 5 additions & 0 deletions tests/charge_tests/Models/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,8 @@ def test_aiping_chat(self):
@pytest.mark.xfail
def test_ppio_chat(self):
self.common_chat(source='ppio')

@pytest.mark.ignore_cache_on_change(get_path('claude'))
@pytest.mark.xfail
def test_claude_chat(self):
self.common_chat(source='claude')
Loading