Skip to content

Commit b5b5fef

Browse files
committed
feat: support tool_calls extraction from text-based function calls (fix #9)
1 parent a8f41ef commit b5b5fef

3 files changed

Lines changed: 604 additions & 31 deletions

File tree

src/api/app.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ def _initialize_proxy_settings():
7070
else:
7171
server.logger.info('No proxy configured for Playwright.')
7272

73+
async def _wait_for_port(port: int, timeout: float = 10.0, interval: float = 0.3):
74+
import socket
75+
deadline = asyncio.get_event_loop().time() + timeout
76+
while asyncio.get_event_loop().time() < deadline:
77+
try:
78+
with socket.create_connection(('127.0.0.1', port), timeout=1):
79+
return True
80+
except (ConnectionRefusedError, OSError, socket.timeout):
81+
await asyncio.sleep(interval)
82+
return False
83+
7384
async def _start_stream_proxy():
7485
import server
7586
STREAM_PORT = os.environ.get('STREAM_PORT')
@@ -80,7 +91,15 @@ async def _start_stream_proxy():
8091
server.STREAM_QUEUE = multiprocessing.Queue()
8192
server.STREAM_PROCESS = multiprocessing.Process(target=proxy.start, args=(server.STREAM_QUEUE, port, STREAM_PROXY_SERVER_ENV))
8293
server.STREAM_PROCESS.start()
83-
server.logger.info('STREAM proxy process started.')
94+
server.logger.info('STREAM proxy process started. Waiting for port readiness...')
95+
if await _wait_for_port(port):
96+
server.logger.info(f'STREAM proxy port {port} is ready.')
97+
else:
98+
server.logger.error(f'STREAM proxy port {port} not ready after timeout. Browser may fail to connect.')
99+
if server.STREAM_PROCESS and server.STREAM_PROCESS.is_alive():
100+
server.logger.warning('STREAM proxy process is alive but port not listening.')
101+
else:
102+
server.logger.error(f'STREAM proxy process died. Exit code: {server.STREAM_PROCESS.exitcode}')
84103

85104
async def _initialize_browser_and_page():
86105
import server

src/api/request_processor.py

Lines changed: 122 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import os
44
import random
5+
import re
56
import secrets
67
import time
78
from typing import Optional, Tuple, Callable, AsyncGenerator
@@ -17,6 +18,57 @@
1718
from .abort_detector import AbortSignalDetector, AbortSignalHandler
1819
from browser.page_controller import PageController
1920

21+
TOOL_CALL_INSTRUCTION = """When you need to call a tool, you MUST use EXACTLY this format (one per tool call):
22+
23+
```tool_call
24+
{"name": "function_name", "arguments": {"param1": "value1", "param2": "value2"}}
25+
```
26+
27+
Rules:
28+
- Use ```tool_call code blocks, one block per function call.
29+
- The content MUST be valid JSON with "name" and "arguments" keys.
30+
- You may call multiple tools by using multiple ```tool_call blocks.
31+
- Do NOT use any other format like XML tags or custom syntax.
32+
- After receiving tool results, provide your final answer to the user.
33+
"""
34+
35+
_TOOL_CALL_PATTERN = re.compile(
36+
r'```tool_call\s*\n\s*(\{.*?\})\s*\n\s*```',
37+
re.DOTALL
38+
)
39+
40+
def _extract_tool_calls_from_text(text: str, logger=None, req_id: str = '') -> Tuple[Optional[list], str]:
41+
if '```tool_call' not in text:
42+
return None, text
43+
matches = list(_TOOL_CALL_PATTERN.finditer(text))
44+
if not matches:
45+
return None, text
46+
tool_calls = []
47+
for match in matches:
48+
try:
49+
data = json.loads(match.group(1))
50+
fn_name = data.get('name', '')
51+
fn_args = data.get('arguments', {})
52+
if not fn_name:
53+
continue
54+
tool_calls.append({
55+
'id': f'call_{secrets.token_hex(12)}',
56+
'type': 'function',
57+
'function': {
58+
'name': fn_name,
59+
'arguments': json.dumps(fn_args, ensure_ascii=False) if isinstance(fn_args, dict) else str(fn_args)
60+
}
61+
})
62+
except (json.JSONDecodeError, KeyError, TypeError) as e:
63+
if logger:
64+
logger.warning(f"[{req_id}] 解析文本工具调用失败: {e}, raw: {match.group(1)[:100]}")
65+
if not tool_calls:
66+
return None, text
67+
remaining = _TOOL_CALL_PATTERN.sub('', text).strip()
68+
if logger:
69+
logger.info(f"[{req_id}] 🔧 从文本中提取到 {len(tool_calls)} 个工具调用")
70+
return tool_calls, remaining
71+
2072
def _merge_tools_to_system_prompt(system_prompt: str, tools: Optional[list], logger, req_id: str) -> str:
2173
if not tools:
2274
return system_prompt
@@ -41,7 +93,7 @@ def _merge_tools_to_system_prompt(system_prompt: str, tools: Optional[list], log
4193
return system_prompt
4294
tools_json = json.dumps(function_declarations, indent=2, ensure_ascii=False)
4395
logger.info(f"[{req_id}] 🔧 合并 {len(function_declarations)} 个函数到系统提示词")
44-
tools_section = f"<tools>\n{tools_json}\n</tools>\n\n"
96+
tools_section = f"<tools>\n{tools_json}\n</tools>\n\n{TOOL_CALL_INSTRUCTION}\n"
4597
return tools_section + system_prompt
4698

4799
async def _initialize_request_context(req_id: str, request: ChatCompletionRequest) -> dict:
@@ -339,6 +391,7 @@ async def create_stream_generator_from_helper(event_to_set: Event, task_to_cance
339391
body = data.get('body', '')
340392
done = data.get('done', False)
341393
function = data.get('function', [])
394+
has_tools = bool(request.tools)
342395
if reason:
343396
full_reasoning_content = reason
344397
if body:
@@ -347,35 +400,57 @@ async def create_stream_generator_from_helper(event_to_set: Event, task_to_cance
347400
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': None, 'reasoning_content': reason[last_reason_pos:]}, 'finish_reason': None, 'native_finish_reason': None}]}
348401
last_reason_pos = len(reason)
349402
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
350-
if len(body) > last_body_pos:
351-
finish_reason_val = None
403+
if has_tools:
352404
if done:
353-
finish_reason_val = 'stop'
354-
delta_content = {'role': 'assistant', 'content': body[last_body_pos:]}
355-
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': finish_reason_val, 'native_finish_reason': finish_reason_val}
356-
if done and function and (len(function) > 0):
357-
tool_calls_list = []
358-
for func_idx, function_call_data in enumerate(function):
359-
tool_calls_list.append({'id': f'call_{secrets.token_hex(12)}', 'index': func_idx, 'type': 'function', 'function': {'name': function_call_data['name'], 'arguments': json.dumps(function_call_data['params'])}})
360-
delta_content['tool_calls'] = tool_calls_list
361-
choice_item['finish_reason'] = 'tool_calls'
362-
choice_item['native_finish_reason'] = 'tool_calls'
363-
delta_content['content'] = None
364-
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [choice_item]}
365-
last_body_pos = len(body)
366-
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
367-
elif done:
368-
if function and len(function) > 0:
369-
delta_content = {'role': 'assistant', 'content': None}
370-
tool_calls_list = []
371-
for func_idx, function_call_data in enumerate(function):
372-
tool_calls_list.append({'id': f'call_{secrets.token_hex(12)}', 'index': func_idx, 'type': 'function', 'function': {'name': function_call_data['name'], 'arguments': json.dumps(function_call_data['params'])}})
373-
delta_content['tool_calls'] = tool_calls_list
374-
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': 'tool_calls', 'native_finish_reason': 'tool_calls'}
375-
else:
376-
choice_item = {'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': 'stop', 'native_finish_reason': 'stop'}
377-
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [choice_item]}
378-
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
405+
if function and len(function) > 0:
406+
delta_content = {'role': 'assistant', 'content': None}
407+
tool_calls_list = []
408+
for func_idx, function_call_data in enumerate(function):
409+
tool_calls_list.append({'id': f'call_{secrets.token_hex(12)}', 'index': func_idx, 'type': 'function', 'function': {'name': function_call_data['name'], 'arguments': json.dumps(function_call_data['params'])}})
410+
delta_content['tool_calls'] = tool_calls_list
411+
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': 'tool_calls', 'native_finish_reason': 'tool_calls'}
412+
elif full_body_content:
413+
text_tool_calls, remaining_text = _extract_tool_calls_from_text(full_body_content, logger, req_id)
414+
if text_tool_calls:
415+
delta_content = {'role': 'assistant', 'content': remaining_text or None, 'tool_calls': text_tool_calls}
416+
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': 'tool_calls', 'native_finish_reason': 'tool_calls'}
417+
else:
418+
delta_content = {'role': 'assistant', 'content': full_body_content}
419+
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': 'stop', 'native_finish_reason': 'stop'}
420+
else:
421+
choice_item = {'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': 'stop', 'native_finish_reason': 'stop'}
422+
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [choice_item]}
423+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
424+
else:
425+
if len(body) > last_body_pos:
426+
finish_reason_val = None
427+
if done:
428+
finish_reason_val = 'stop'
429+
delta_content = {'role': 'assistant', 'content': body[last_body_pos:]}
430+
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': finish_reason_val, 'native_finish_reason': finish_reason_val}
431+
if done and function and (len(function) > 0):
432+
tool_calls_list = []
433+
for func_idx, function_call_data in enumerate(function):
434+
tool_calls_list.append({'id': f'call_{secrets.token_hex(12)}', 'index': func_idx, 'type': 'function', 'function': {'name': function_call_data['name'], 'arguments': json.dumps(function_call_data['params'])}})
435+
delta_content['tool_calls'] = tool_calls_list
436+
choice_item['finish_reason'] = 'tool_calls'
437+
choice_item['native_finish_reason'] = 'tool_calls'
438+
delta_content['content'] = None
439+
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [choice_item]}
440+
last_body_pos = len(body)
441+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
442+
elif done:
443+
if function and len(function) > 0:
444+
delta_content = {'role': 'assistant', 'content': None}
445+
tool_calls_list = []
446+
for func_idx, function_call_data in enumerate(function):
447+
tool_calls_list.append({'id': f'call_{secrets.token_hex(12)}', 'index': func_idx, 'type': 'function', 'function': {'name': function_call_data['name'], 'arguments': json.dumps(function_call_data['params'])}})
448+
delta_content['tool_calls'] = tool_calls_list
449+
choice_item = {'index': 0, 'delta': delta_content, 'finish_reason': 'tool_calls', 'native_finish_reason': 'tool_calls'}
450+
else:
451+
choice_item = {'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': 'stop', 'native_finish_reason': 'stop'}
452+
output = {'id': chat_completion_id, 'object': 'chat.completion.chunk', 'model': model_name_for_stream, 'created': created_timestamp, 'choices': [choice_item]}
453+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
379454

380455
# Late Rate Limit Check
381456
late_check_wait = 2.0 if len(full_body_content) < 50 else 0.2
@@ -519,6 +594,12 @@ async def create_stream_generator_from_helper(event_to_set: Event, task_to_cance
519594
message_payload['tool_calls'] = tool_calls_list
520595
finish_reason_val = 'tool_calls'
521596
message_payload['content'] = None
597+
elif content:
598+
text_tool_calls, remaining_text = _extract_tool_calls_from_text(content, logger, req_id)
599+
if text_tool_calls:
600+
message_payload['tool_calls'] = text_tool_calls
601+
message_payload['content'] = remaining_text or None
602+
finish_reason_val = 'tool_calls'
522603
if reasoning_content:
523604
message_payload['reasoning_content'] = reasoning_content
524605
usage_stats = calculate_usage_stats([msg.model_dump() for msg in request.messages], content or '', reasoning_content)
@@ -586,7 +667,13 @@ async def create_response_stream_generator():
586667
# await asyncio.sleep(0.01)
587668
usage_stats = calculate_usage_stats([msg.model_dump() for msg in request.messages], final_content, '')
588669
logger.info(f'[{req_id}] Playwright非流式计算的token使用统计: {usage_stats}')
589-
yield generate_sse_stop_chunk(req_id, current_ai_studio_model_id or MODEL_NAME, 'stop', usage_stats)
670+
text_tool_calls, remaining_text = _extract_tool_calls_from_text(final_content, logger, req_id)
671+
if text_tool_calls:
672+
tool_call_chunk = {'id': f'{CHAT_COMPLETION_ID_PREFIX}{req_id}', 'object': 'chat.completion.chunk', 'model': current_ai_studio_model_id or MODEL_NAME, 'created': int(time.time()), 'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': remaining_text or None, 'tool_calls': text_tool_calls}, 'finish_reason': 'tool_calls'}]}
673+
yield f"data: {json.dumps(tool_call_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
674+
yield generate_sse_stop_chunk(req_id, current_ai_studio_model_id or MODEL_NAME, 'tool_calls', usage_stats)
675+
else:
676+
yield generate_sse_stop_chunk(req_id, current_ai_studio_model_id or MODEL_NAME, 'stop', usage_stats)
590677
except ClientDisconnectedError as disconnect_err:
591678
abort_handler = AbortSignalHandler()
592679
disconnect_info = abort_handler.handle_error(disconnect_err, req_id)
@@ -632,6 +719,11 @@ async def create_response_stream_generator():
632719
usage_stats = calculate_usage_stats([msg.model_dump() for msg in request.messages], final_content, '')
633720
logger.info(f'[{req_id}] Playwright非流式计算的token使用统计: {usage_stats}')
634721
response_payload = {'id': f'{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}', 'object': 'chat.completion', 'created': int(time.time()), 'model': current_ai_studio_model_id or MODEL_NAME, 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': final_content}, 'finish_reason': 'stop'}], 'usage': usage_stats}
722+
text_tool_calls, remaining_text = _extract_tool_calls_from_text(final_content, logger, req_id)
723+
if text_tool_calls:
724+
response_payload['choices'][0]['message']['tool_calls'] = text_tool_calls
725+
response_payload['choices'][0]['message']['content'] = remaining_text or None
726+
response_payload['choices'][0]['finish_reason'] = 'tool_calls'
635727
if not result_future.done():
636728
result_future.set_result(JSONResponse(content=response_payload))
637729
return None

0 commit comments

Comments
 (0)