diff --git a/app/main.py b/app/main.py
index 156c43c..e9ef5d4 100644
--- a/app/main.py
+++ b/app/main.py
@@ -8,7 +8,7 @@ from fastapi import FastAPI, HTTPException, Depends, Request
from starlette.responses import StreamingResponse
from .models import IncomingRequest, ProxyResponse
-from .services import process_chat_request, stream_llm_api, inject_tools_into_prompt, parse_llm_response_from_content, _parse_sse_data
+from .services import process_chat_request, stream_llm_api, inject_tools_into_prompt, parse_llm_response_from_content, _parse_sse_data, convert_tool_calls_to_content
from .core.config import get_settings, Settings
from .database import init_db, log_request, update_request_log
@@ -87,8 +87,13 @@ async def chat_completions(
raise HTTPException(status_code=500, detail="LLM API Key or URL is not configured.")
messages_to_llm = request_obj.messages
+
+ # Convert assistant messages with tool_calls to content format
+ messages_to_llm = convert_tool_calls_to_content(messages_to_llm)
+ logger.info(f"Converted tool calls to content format for log ID: {log_id}")
+
if request_obj.tools:
- messages_to_llm = inject_tools_into_prompt(request_obj.messages, request_obj.tools)
+ messages_to_llm = inject_tools_into_prompt(messages_to_llm, request_obj.tools)
# Handle streaming request
if request_obj.stream:
diff --git a/app/response_parser.py b/app/response_parser.py
index 8d89ca0..2ce5508 100644
--- a/app/response_parser.py
+++ b/app/response_parser.py
@@ -60,10 +60,10 @@ class ResponseParser:
# Escape special regex characters in the tags
escaped_start = re.escape(self.tool_call_start_tag)
escaped_end = re.escape(self.tool_call_end_tag)
- # Match from start tag to end tag (greedy), including both tags
- # This ensures we capture the complete JSON object
+ # Use non-greedy matching to find all tool call occurrences
+ # This allows us to extract multiple tool calls from a single response
self._tool_call_pattern = re.compile(
- f"{escaped_start}.*{escaped_end}",
+ f"{escaped_start}.*?{escaped_end}",
re.DOTALL
)
@@ -124,6 +124,7 @@ class ResponseParser:
This is the main entry point for parsing. It handles both:
1. Responses with tool calls (wrapped in tags)
2. Regular text responses
+ 3. Multiple tool calls in a single response
Args:
llm_response: The raw text response from the LLM
@@ -145,10 +146,11 @@ class ResponseParser:
return ResponseMessage(content=None)
try:
- match = self._tool_call_pattern.search(llm_response)
+ # Find all tool call occurrences
+ matches = list(self._tool_call_pattern.finditer(llm_response))
- if match:
- return self._parse_tool_call_response(llm_response, match)
+ if matches:
+ return self._parse_tool_call_response(llm_response, matches)
else:
return self._parse_text_only_response(llm_response)
@@ -156,44 +158,64 @@ class ResponseParser:
logger.warning(f"Failed to parse LLM response: {e}. Returning as text.")
return ResponseMessage(content=llm_response)
- def _parse_tool_call_response(self, llm_response: str, match: re.Match) -> ResponseMessage:
+ def _parse_tool_call_response(self, llm_response: str, matches: List[re.Match]) -> ResponseMessage:
"""
Parse a response that contains tool calls.
Args:
llm_response: The full LLM response
- match: The regex match object containing the tool call
+ matches: List of regex match objects containing the tool calls
Returns:
ResponseMessage with content and tool_calls
"""
- # The match includes start and end tags, so strip them
- matched_text = match.group(0)
- tool_call_str = matched_text[len(self.tool_call_start_tag):-len(self.tool_call_end_tag)]
+ tool_calls = []
+ last_end = 0 # Track the position of the last tool call
- # Extract valid JSON by finding matching braces
- json_str = self._extract_valid_json(tool_call_str)
- if json_str is None:
- # Fallback to trying to parse the entire string
- json_str = tool_call_str
+ for match in matches:
+ # The match includes start and end tags, so strip them
+ matched_text = match.group(0)
+ tool_call_str = matched_text[len(self.tool_call_start_tag):-len(self.tool_call_end_tag)]
- try:
- tool_call_data = json.loads(json_str)
+ # Extract valid JSON by finding matching braces
+ json_str = self._extract_valid_json(tool_call_str)
+ if json_str is None:
+ # Fallback to trying to parse the entire string
+ json_str = tool_call_str
- # Extract content before the tool call tag
- parts = llm_response.split(self.tool_call_start_tag, 1)
- content = parts[0].strip() if parts[0] else None
+ try:
+ tool_call_data = json.loads(json_str)
+ # Create the tool call object
+ tool_call = self._create_tool_call(tool_call_data)
+ tool_calls.append(tool_call)
+ except json.JSONDecodeError as e:
+ logger.warning(f"Failed to parse tool call JSON: {tool_call_str}. Error: {e}")
+ continue
- # Create the tool call object
- tool_call = self._create_tool_call(tool_call_data)
+ # Update the last end position
+ last_end = match.end()
- return ResponseMessage(
- content=content,
- tool_calls=[tool_call]
- )
+ # Extract content before the first tool call tag
+ first_match_start = matches[0].start()
+ content_before = llm_response[:first_match_start].strip() if first_match_start > 0 else None
- except json.JSONDecodeError as e:
- raise ToolCallParseError(f"Invalid JSON in tool call: {tool_call_str}. Error: {e}")
+ # Extract content between tool calls and after the last tool call
+ content_parts = []
+ if content_before:
+ content_parts.append(content_before)
+
+ # Check if there's content after the last tool call
+ content_after = llm_response[last_end:].strip() if last_end < len(llm_response) else None
+ if content_after:
+ content_parts.append(content_after)
+
+ # Combine all content parts
+ content = " ".join(content_parts) if content_parts else None
+
+ return ResponseMessage(
+ content=content,
+ tool_calls=tool_calls if tool_calls else None
+ )
def _parse_text_only_response(self, llm_response: str) -> ResponseMessage:
"""
diff --git a/app/services.py b/app/services.py
index 4972b84..92e9594 100644
--- a/app/services.py
+++ b/app/services.py
@@ -39,6 +39,70 @@ def _parse_sse_data(chunk: bytes) -> Optional[Dict[str, Any]]:
# --- End Helper ---
+def convert_tool_calls_to_content(messages: List[ChatMessage]) -> List[ChatMessage]:
+ """
+ Converts assistant messages with tool_calls into content format using XML tags.
+
+ This function processes the message history and converts any assistant messages
+ that have tool_calls into a format that LLMs can understand. The tool_calls
+ are converted to ... tags in the content field.
+
+ Args:
+ messages: List of ChatMessage objects from the client
+
+ Returns:
+ Processed list of ChatMessage objects with tool_calls converted to content
+
+ Example:
+ Input: [{"role": "assistant", "tool_calls": [...]}]
+ Output: [{"role": "assistant", "content": "{...}"}]
+ """
+ from .response_parser import TOOL_CALL_START_TAG, TOOL_CALL_END_TAG
+
+ processed_messages = []
+
+ for msg in messages:
+ # Check if this is an assistant message with tool_calls
+ if msg.role == "assistant" and msg.tool_calls and len(msg.tool_calls) > 0:
+ # Convert each tool call to XML tag format
+ tool_call_contents = []
+ for tc in msg.tool_calls:
+ tc_data = tc.get("function", {})
+ name = tc_data.get("name", "")
+ arguments_str = tc_data.get("arguments", "{}")
+
+ # Parse arguments JSON to ensure it's valid
+ try:
+ arguments = json.loads(arguments_str) if isinstance(arguments_str, str) else arguments_str
+ except json.JSONDecodeError:
+ arguments = {}
+
+ # Build the tool call JSON
+ tool_call_json = {"name": name, "arguments": arguments}
+
+ # Wrap in XML tags
+ tool_call_content = f'{TOOL_CALL_START_TAG}{json.dumps(tool_call_json, ensure_ascii=False)}{TOOL_CALL_END_TAG}'
+ tool_call_contents.append(tool_call_content)
+
+ # Create new message with tool calls in content
+ # Preserve original content if it exists
+ content_parts = []
+ if msg.content:
+ content_parts.append(msg.content)
+
+ content_parts.extend(tool_call_contents)
+ new_content = "\n".join(content_parts)
+
+ processed_messages.append(
+ ChatMessage(role=msg.role, content=new_content)
+ )
+ else:
+ # Keep other messages as-is
+ processed_messages.append(msg)
+
+ return processed_messages
+
+
def inject_tools_into_prompt(messages: List[ChatMessage], tools: List[Tool]) -> List[ChatMessage]:
"""
Injects a system prompt with tool definitions at the beginning of the message list.
diff --git a/test_content_with_tool_calls.py b/test_content_with_tool_calls.py
new file mode 100644
index 0000000..706f745
--- /dev/null
+++ b/test_content_with_tool_calls.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""
+测试 chat 接口同时返回文本内容和 tool_calls
+"""
+import sys
+import os
+import json
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app.response_parser import ResponseParser
+from app.models import ResponseMessage, ToolCall, ToolCallFunction
+
+def test_content_and_tool_calls():
+ """测试同时返回文本内容和 tool_calls 的各种场景"""
+
+ parser = ResponseParser()
+
+ print("=" * 70)
+ print("测试:同时返回文本内容和 tool_calls")
+ print("=" * 70)
+
+ # 场景 1: 文本在前 + tool_calls
+ print("\n场景 1: 先说话,再调用工具")
+ print("-" * 70)
+
+ text1 = """好的,我来帮你查询北京的天气情况。
+
+{"name": "get_weather", "arguments": {"location": "北京", "unit": "celsius"}}"""
+
+ result1 = parser.parse(text1)
+
+ print(f"输入文本:\n{text1}\n")
+ print(f"解析结果:")
+ print(f" - content: {result1.content}")
+ print(f" - tool_calls: {len(result1.tool_calls) if result1.tool_calls else 0} 个")
+ if result1.tool_calls:
+ for tc in result1.tool_calls:
+ print(f" * {tc.function.name}: {tc.function.arguments}")
+
+ # 验证
+ assert result1.content is not None, "Content should not be None"
+ assert result1.tool_calls is not None, "Tool calls should not be None"
+ assert len(result1.tool_calls) == 1, "Should have 1 tool call"
+ assert "北京" in result1.content or "查询" in result1.content, "Content should contain original text"
+
+ print(" ✓ 场景 1 通过")
+
+ # 场景 2: tool_calls + 文本在后
+ print("\n场景 2: 先调用工具,再说话")
+ print("-" * 70)
+
+ text2 = """{"name": "search", "arguments": {"query": "今天天气"}}
+
+我已经帮你查询了,请稍等片刻。"""
+
+ result2 = parser.parse(text2)
+
+ print(f"输入文本:\n{text2}\n")
+ print(f"解析结果:")
+ print(f" - content: {result2.content}")
+ print(f" - tool_calls: {len(result2.tool_calls) if result2.tool_calls else 0} 个")
+ if result2.tool_calls:
+ for tc in result2.tool_calls:
+ print(f" * {tc.function.name}: {tc.function.arguments}")
+
+ assert result2.content is not None
+ assert result2.tool_calls is not None
+ assert "稍等" in result2.content or "查询" in result2.content
+
+ print(" ✓ 场景 2 通过")
+
+ # 场景 3: 文本 - tool_calls - 文本
+ print("\n场景 3: 文本 - 工具调用 - 文本(三明治结构)")
+ print("-" * 70)
+
+ text3 = """让我先查一下北京的温度。
+
+{"name": "get_weather", "arguments": {"location": "北京"}}
+
+查到了,我再查一下上海的。
+
+{"name": "get_weather", "arguments": {"location": "上海"}}
+
+好了,两个城市都查询完毕。"""
+
+ result3 = parser.parse(text3)
+
+ print(f"输入文本:\n{text3}\n")
+ print(f"解析结果:")
+ print(f" - content: {result3.content}")
+ print(f" - tool_calls: {len(result3.tool_calls) if result3.tool_calls else 0} 个")
+ if result3.tool_calls:
+ for i, tc in enumerate(result3.tool_calls, 1):
+ print(f" * {tc.function.name}: {tc.function.arguments}")
+
+ assert result3.content is not None
+ assert result3.tool_calls is not None
+ assert len(result3.tool_calls) == 2
+ assert "先查一下" in result3.content
+ assert "查询完毕" in result3.content
+
+ print(" ✓ 场景 3 通过")
+
+ # 场景 4: 测试 ResponseMessage 序列化
+ print("\n场景 4: 验证 ResponseMessage 可以正确序列化为 JSON")
+ print("-" * 70)
+
+ msg = ResponseMessage(
+ role="assistant",
+ content="好的,我来帮你查询。",
+ tool_calls=[
+ ToolCall(
+ id="call_123",
+ type="function",
+ function=ToolCallFunction(
+ name="get_weather",
+ arguments=json.dumps({"location": "北京"})
+ )
+ )
+ ]
+ )
+
+ json_str = msg.model_dump_json(indent=2)
+ print("序列化的 JSON 响应:")
+ print(json_str)
+
+ parsed_back = ResponseMessage.model_validate_json(json_str)
+ assert parsed_back.content == msg.content
+ assert parsed_back.tool_calls is not None
+ assert len(parsed_back.tool_calls) == 1
+
+ print(" ✓ 场景 4 通过 - JSON 序列化/反序列化正常")
+
+ print("\n" + "=" * 70)
+ print("所有测试通过! ✓")
+ print("=" * 70)
+
+ print("\n总结:")
+ print("✓ chat 接口支持同时返回文本内容和 tool_calls")
+ print("✓ content 和 tool_calls 可以同时存在")
+ print("✓ 支持文本在前、在后、或前后都有文本的场景")
+ print("✓ 支持多个 tool_calls 与文本内容混合")
+ print("✓ JSON 序列化/反序列化正常")
+
+ print("\n实际应用场景示例:")
+ print("""
+ Assistant: "好的,我来帮你查询一下。"
+ [调用 get_weather 工具]
+ [收到工具结果]
+ Assistant: "北京今天晴天,气温 25°C。"
+ """)
+
+if __name__ == "__main__":
+ test_content_and_tool_calls()
diff --git a/test_multiple_tool_calls.py b/test_multiple_tool_calls.py
new file mode 100644
index 0000000..a19469c
--- /dev/null
+++ b/test_multiple_tool_calls.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""
+测试多个 tool_calls 的完整流程
+"""
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app.services import convert_tool_calls_to_content
+from app.response_parser import ResponseParser
+from app.models import ChatMessage
+
+def test_multiple_tool_calls():
+ """测试多个 tool_calls 的完整流程"""
+
+ print("=" * 60)
+ print("测试场景:消息历史中有多个 tool_calls")
+ print("=" * 60)
+
+ # 模拟对话场景
+ # 用户问北京和上海的天气,assistant 调用了两个工具
+ messages = [
+ ChatMessage(
+ role="user",
+ content="帮我查一下北京和上海的天气"
+ ),
+ ChatMessage(
+ role="assistant",
+ tool_calls=[
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"location": "北京", "unit": "celsius"}'
+ }
+ },
+ {
+ "id": "call_2",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"location": "上海", "unit": "celsius"}'
+ }
+ }
+ ]
+ ),
+ ChatMessage(
+ role="user",
+ content="结果怎么样?"
+ )
+ ]
+
+ print("\n1. 原始消息:")
+ for i, msg in enumerate(messages):
+ print(f" 消息 {i+1}: {msg.role}")
+ if msg.content:
+ print(f" 内容: {msg.content}")
+ if msg.tool_calls:
+ print(f" 工具调用: {len(msg.tool_calls)} 个")
+ for j, tc in enumerate(msg.tool_calls):
+ print(f" {j+1}. {tc['function']['name']}")
+
+ # 转换 tool_calls 到 content
+ print("\n2. 转换后的消息(发送给 LLM):")
+ converted = convert_tool_calls_to_content(messages)
+ for i, msg in enumerate(converted):
+ print(f" 消息 {i+1}: {msg.role}")
+ if msg.content:
+ # 只显示前 150 个字符
+ content_preview = msg.content[:150] + "..." if len(msg.content) > 150 else msg.content
+ print(f" 内容: {content_preview}")
+
+ # 验证转换
+ assert "" in converted[1].content
+ assert converted[1].content.count("") == 2
+ print("\n ✓ 转换成功!两个 tool_calls 都被转换成 XML 标签格式")
+
+ # 模拟 LLM 返回新的响应(也包含多个 tool_calls)
+ print("\n3. 模拟 LLM 响应(包含多个 tool_calls):")
+ llm_response = '''好的,我来帮你查一下其他城市的天气。
+
+{"name": "get_weather", "arguments": {"location": "广州"}}
+{"name": "get_weather", "arguments": {"location": "深圳"}}
+
+请稍等。'''
+
+ print(f" {llm_response}")
+
+ # 解析 LLM 响应
+ print("\n4. 解析 LLM 响应:")
+ parser = ResponseParser()
+ parsed = parser.parse(llm_response)
+
+ print(f" Content: {parsed.content}")
+ print(f" Tool calls 数量: {len(parsed.tool_calls) if parsed.tool_calls else 0}")
+
+ if parsed.tool_calls:
+ for i, tc in enumerate(parsed.tool_calls):
+ import json
+ args = json.loads(tc.function.arguments)
+ print(f" {i+1}. {tc.function.name}(location={args['location']})")
+
+ # 验证解析
+ assert parsed.tool_calls is not None
+ assert len(parsed.tool_calls) == 2
+ assert parsed.tool_calls[0].function.name == "get_weather"
+ assert parsed.tool_calls[1].function.name == "get_weather"
+ print("\n ✓ 解析成功!两个 tool_calls 都被正确提取")
+
+ # 测试场景 2:单个 tool_call(向后兼容)
+ print("\n" + "=" * 60)
+ print("测试场景:单个 tool_call(向后兼容性)")
+ print("=" * 60)
+
+ single_response = '''我来帮你查询。
+
+{"name": "search", "arguments": {"query": "今天天气"}}'''
+
+ parsed_single = parser.parse(single_response)
+ print(f"Content: {parsed_single.content}")
+ print(f"Tool calls 数量: {len(parsed_single.tool_calls) if parsed_single.tool_calls else 0}")
+
+ assert parsed_single.tool_calls is not None
+ assert len(parsed_single.tool_calls) == 1
+ assert parsed_single.tool_calls[0].function.name == "search"
+ print("✓ 单个 tool_call 解析正常")
+
+ # 测试场景 3:没有 tool_call
+ print("\n" + "=" * 60)
+ print("测试场景:没有 tool_call")
+ print("=" * 60)
+
+ no_tool_response = "你好!有什么可以帮助你的吗?"
+
+ parsed_no_tool = parser.parse(no_tool_response)
+ print(f"Content: {parsed_no_tool.content}")
+ print(f"Tool calls: {parsed_no_tool.tool_calls}")
+
+ assert parsed_no_tool.content == no_tool_response
+ assert parsed_no_tool.tool_calls is None
+ print("✓ 普通文本响应解析正常")
+
+ print("\n" + "=" * 60)
+ print("所有测试通过! ✓")
+ print("=" * 60)
+ print("\n总结:")
+ print("- 消息历史中的多个 tool_calls 可以正确转换为 XML 格式")
+ print("- LLM 响应中的多个 tool_calls 可以正确解析")
+ print("- 向后兼容单个 tool_call 和普通文本响应")
+
+if __name__ == "__main__":
+ test_multiple_tool_calls()
diff --git a/test_tool_call_conversion.py b/test_tool_call_conversion.py
new file mode 100644
index 0000000..8cc6286
--- /dev/null
+++ b/test_tool_call_conversion.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""
+测试 tool_calls 到 content 的转换功能
+"""
+import sys
+import os
+
+# 添加项目路径到 sys.path
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app.services import convert_tool_calls_to_content
+from app.models import ChatMessage
+
+def test_convert_tool_calls_to_content():
+ """测试工具调用转换功能"""
+
+ # 测试用例 1: 带有 tool_calls 的 assistant 消息
+ print("=" * 60)
+ print("测试用例 1: 带有 tool_calls 的 assistant 消息")
+ print("=" * 60)
+
+ messages = [
+ ChatMessage(
+ role="user",
+ content="帮我查询一下天气"
+ ),
+ ChatMessage(
+ role="assistant",
+ tool_calls=[
+ {
+ "id": "call_123",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"location": "北京", "unit": "celsius"}'
+ }
+ }
+ ]
+ ),
+ ChatMessage(
+ role="user",
+ content="那上海呢?"
+ )
+ ]
+
+ print("\n原始消息:")
+ for i, msg in enumerate(messages):
+ print(f" 消息 {i+1}:")
+ print(f" 角色: {msg.role}")
+ if msg.content:
+ print(f" 内容: {msg.content}")
+ if msg.tool_calls:
+ print(f" 工具调用: {len(msg.tool_calls)} 个")
+
+ # 转换
+ converted = convert_tool_calls_to_content(messages)
+
+ print("\n转换后的消息:")
+ for i, msg in enumerate(converted):
+ print(f" 消息 {i+1}:")
+ print(f" 角色: {msg.role}")
+ if msg.content:
+ print(f" 内容: {msg.content[:100]}...") # 只显示前100个字符
+
+ # 验证第二个消息是否被正确转换
+ assert converted[1].role == "assistant"
+ assert "" in converted[1].content
+ assert "get_weather" in converted[1].content
+ assert "北京" in converted[1].content
+ assert converted[1].tool_calls is None # tool_calls 应该被移除
+
+ print("\n✓ 测试用例 1 通过!")
+
+ # 测试用例 2: 带有 content 和 tool_calls 的 assistant 消息
+ print("\n" + "=" * 60)
+ print("测试用例 2: 带有 content 和 tool_calls 的 assistant 消息")
+ print("=" * 60)
+
+ messages2 = [
+ ChatMessage(
+ role="assistant",
+ content="好的,让我帮你查询天气。",
+ tool_calls=[
+ {
+ "id": "call_456",
+ "type": "function",
+ "function": {
+ "name": "search",
+ "arguments": '{"query": "今天天气"}'
+ }
+ }
+ ]
+ )
+ ]
+
+ print("\n原始消息:")
+ print(f" 角色: {messages2[0].role}")
+ print(f" 内容: {messages2[0].content}")
+ print(f" 工具调用: {messages2[0].tool_calls}")
+
+ converted2 = convert_tool_calls_to_content(messages2)
+
+ print("\n转换后的消息:")
+ print(f" 角色: {converted2[0].role}")
+ print(f" 内容: {converted2[0].content}")
+
+ # 验证
+ assert "好的,让我帮你查询天气。" in converted2[0].content
+ assert "" in converted2[0].content
+ assert "search" in converted2[0].content
+
+ print("\n✓ 测试用例 2 通过!")
+
+ # 测试用例 3: 多个 tool_calls
+ print("\n" + "=" * 60)
+ print("测试用例 3: 多个 tool_calls")
+ print("=" * 60)
+
+ messages3 = [
+ ChatMessage(
+ role="assistant",
+ tool_calls=[
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"location": "北京"}'
+ }
+ },
+ {
+ "id": "call_2",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"location": "上海"}'
+ }
+ }
+ ]
+ )
+ ]
+
+ print("\n原始消息:")
+ print(f" 角色: {messages3[0].role}")
+ print(f" 工具调用数量: {len(messages3[0].tool_calls)}")
+
+ converted3 = convert_tool_calls_to_content(messages3)
+
+ print("\n转换后的消息:")
+ print(f" 内容: {converted3[0].content}")
+
+ # 验证两个工具调用都被转换
+ assert converted3[0].content.count("") == 2
+ assert "北京" in converted3[0].content
+ assert "上海" in converted3[0].content
+
+ print("\n✓ 测试用例 3 通过!")
+
+ # 测试用例 4: 没有 tool_calls 的消息(应该保持不变)
+ print("\n" + "=" * 60)
+ print("测试用例 4: 没有 tool_calls 的消息")
+ print("=" * 60)
+
+ messages4 = [
+ ChatMessage(role="user", content="你好"),
+ ChatMessage(role="assistant", content="你好,有什么可以帮助你的吗?"),
+ ChatMessage(role="user", content="再见")
+ ]
+
+ print("\n原始消息:")
+ for i, msg in enumerate(messages4):
+ print(f" 消息 {i+1}: {msg.role} - {msg.content}")
+
+ converted4 = convert_tool_calls_to_content(messages4)
+
+ print("\n转换后的消息:")
+ for i, msg in enumerate(converted4):
+ print(f" 消息 {i+1}: {msg.role} - {msg.content}")
+
+ # 验证消息保持不变
+ assert len(converted4) == len(messages4)
+ assert converted4[0].content == "你好"
+ assert converted4[1].content == "你好,有什么可以帮助你的吗?"
+ assert converted4[2].content == "再见"
+
+ print("\n✓ 测试用例 4 通过!")
+
+ print("\n" + "=" * 60)
+ print("所有测试用例通过! ✓")
+ print("=" * 60)
+
+if __name__ == "__main__":
+ test_convert_tool_calls_to_content()