22import json
33import os
44import random
5+ import re
56import secrets
67import time
78from typing import Optional , Tuple , Callable , AsyncGenerator
1718from .abort_detector import AbortSignalDetector , AbortSignalHandler
1819from 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+
2072def _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
4799async 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