# CRITICAL"""Custom LiteLLM callback handler for tool call handling and think tag parsing. 1. PRE-CALL: Converts deprecated functions parameter to tools format2. POST-CALL: Parses tool_calls from content (for models using XML format)3. POST-CALL: Extracts <think>...</think> content to reasoning_content field"""import jsonimport reimport uuidimport loggingfrom typing import List, Literal, Optional, Any, Dictfrom litellm.integrations.custom_logger import CustomLogger # Set up logginglogging.basicConfig(level=logging.INFO)logger = logging.getLogger("ToolCallHandler") # Pattern to match think tags - captures content between <think> and </think># Handles both with and without opening <think> tagTHINK_PATTERN = re.compile(r'^(?:<think>)?(.*?)</think>(.*)$', re.DOTALL) class ToolCallHandler(CustomLogger): """Handle tool calls: convert functions->tools and parse from content + think tag parsing.""" def __init__(self): super().__init__() # Track streaming state per request_id for think tag parsing self._stream_buffers: Dict[str, dict] = {} logger.info("ToolCallHandler initialized with functions-to-tools + think tag parsing!") print("ToolCallHandler initialized with functions-to-tools + think tag parsing!", flush=True) def _parse_think_tags(self, content: str) -> tuple[Optional[str], str]: """ Parse content for think tags. Returns (reasoning_content, main_content). If no </think> tag found, returns (None, original_content). """ if not content: return None, content # Match pattern: everything before </think> is reasoning, everything after is content match = THINK_PATTERN.match(content) if match: reasoning = match.group(1).strip() main_content = match.group(2).strip() return reasoning, main_content return None, content def _get_stream_buffer(self, request_id: str) -> dict: """Get or create a stream buffer for a request.""" if request_id not in self._stream_buffers: self._stream_buffers[request_id] = { 'buffer': '', 'in_thinking': False, # Only true after seeing <think> 'think_complete': False, 'reasoning_emitted': False, # MCP XML tracking 'in_mcp_xml': False, 'mcp_buffer': '', 'content_buffer': '', # Buffer content for final processing 'tool_calls': [], } return self._stream_buffers[request_id] def _cleanup_stream_buffer(self, request_id: str): """Clean up stream buffer after request completes.""" if request_id in self._stream_buffers: del self._stream_buffers[request_id] def _tools_to_mcp_prompt(self, tools: List[dict]) -> str: """Convert OpenAI tools format to MCP system prompt format for MiroThinker.""" tool_docs = [] for tool in tools: if tool.get("type") == "function": func = tool.get("function", {}) name = func.get("name", "unknown") desc = func.get("description", "") params = func.get("parameters", {}) # Parse server__tool format (e.g., exa__search -> server=exa, tool=search) if "__" in name: server, tool_name = name.split("__", 1) else: server, tool_name = "default", name tool_docs.append(f"""## {server} / {tool_name}{desc}Parameters: {json.dumps(params, indent=2)}""") mcp_prompt = """# Available Tools IMPORTANT: You can ONLY use the tools listed below. Do NOT invent or call any other tools like "google_search" or "scrape_and_extract_info" - they do not exist. To call a tool, use this EXACT XML format:<use_mcp_tool><server_name>SERVER_NAME</server_name><tool_name>TOOL_NAME</tool_name><arguments>{"param": "value"}</arguments></use_mcp_tool> ## Available Tools: """ + "\n\n".join(tool_docs) + """ REMINDER: Only use the tools listed above. Use the exact server_name and tool_name shown.""" return mcp_prompt def _is_mcp_model(self, model: str) -> bool: """Check if model uses MCP-style tool calls (not OpenAI native).""" model_lower = (model or "").lower() return "mirothinker" in model_lower def _convert_tool_history_to_mcp(self, messages: List[dict]) -> List[dict]: """Convert OpenAI-style tool call history back to MCP format for MiroThinker. This ensures the model sees its own tool calls in the same format it's supposed to use. OpenAI format: assistant message with tool_calls array, followed by tool role messages MCP format: assistant content with <use_mcp_tool> XML, followed by user messages with results """ converted = [] i = 0 while i < len(messages): msg = messages[i] # Handle assistant messages with tool_calls - convert to MCP XML in content if msg.get("role") == "assistant" and msg.get("tool_calls"): tool_calls = msg["tool_calls"] content_parts = [] # Keep any existing content if msg.get("content"): content_parts.append(msg["content"]) # Convert each tool call to MCP XML for tc in tool_calls: func = tc.get("function", {}) name = func.get("name", "") args = func.get("arguments", "{}") # Parse server__tool format back to MCP if "__" in name: server, tool_name = name.split("__", 1) else: server, tool_name = "default", name # Format as MCP XML mcp_xml = f"""<use_mcp_tool><server_name>{server}</server_name><tool_name>{tool_name}</tool_name><arguments>{args}</arguments></use_mcp_tool>""" content_parts.append(mcp_xml) converted.append({ "role": "assistant", "content": "\n\n".join(content_parts) }) # Now collect all following tool results and combine into a user message tool_results = [] j = i + 1 while j < len(messages) and messages[j].get("role") == "tool": tool_msg = messages[j] tool_call_id = tool_msg.get("tool_call_id", "") tool_name = tool_msg.get("name", "unknown") tool_content = tool_msg.get("content", "") # Find the matching tool call to get server/tool name for tc in tool_calls: if tc.get("id") == tool_call_id: func = tc.get("function", {}) tool_name = func.get("name", tool_name) break tool_results.append(f"Tool result for {tool_name}:\n{tool_content}") j += 1 if tool_results: # Add tool results as a user message (how MCP models expect to see them) converted.append({ "role": "user", "content": "Here are the tool results:\n\n" + "\n\n---\n\n".join(tool_results) }) i = j # Skip past all the tool messages we processed continue # Pass through other messages unchanged converted.append(msg) i += 1 return converted async def async_pre_call_hook( self, user_api_key_dict, cache, data: dict, call_type: Literal["completion", "text_completion", "embeddings", "image_generation", "moderation", "audio_transcription"] ): """Convert tools to MCP format for MiroThinker models.""" logger.info(f"[PRE-CALL] call_type={call_type}, has_functions={'functions' in data}, has_tools={'tools' in data}") if call_type not in ("completion", "acompletion"): return data # Convert deprecated functions to tools first if "functions" in data and "tools" not in data: functions = data.pop("functions") data["tools"] = [{"type": "function", "function": f} for f in functions] if "function_call" in data: fc = data.pop("function_call") if fc == "auto": data["tool_choice"] = "auto" elif fc == "none": data["tool_choice"] = "none" elif isinstance(fc, dict) and "name" in fc: data["tool_choice"] = {"type": "function", "function": {"name": fc["name"]}} # For MCP models (MiroThinker): convert tools to system prompt, remove from request model = data.get("model", "") if self._is_mcp_model(model): tools = data.pop("tools", []) data.pop("tool_choice", None) # Remove tool_choice too messages = data.get("messages", []) # CRITICAL: Convert any OpenAI-style tool call history back to MCP format # This ensures the model sees its own tool calls in the format it expects has_tool_history = any( (m.get("role") == "assistant" and m.get("tool_calls")) or m.get("role") == "tool" for m in messages ) if has_tool_history: messages = self._convert_tool_history_to_mcp(messages) logger.info(f"[PRE-CALL] Converted tool call history to MCP format for {model}") if tools: mcp_prompt = self._tools_to_mcp_prompt(tools) # Prepend MCP tool docs to system message or add new system message if messages and messages[0].get("role") == "system": messages[0]["content"] = mcp_prompt + "\n\n" + messages[0].get("content", "") else: messages.insert(0, {"role": "system", "content": mcp_prompt}) logger.info(f"[PRE-CALL] Converted {len(tools)} tools to MCP prompt for {model}") data["messages"] = messages return data def _strip_tool_call_content(self, content: str) -> str: """Strip tool call XML/JSON from content so it doesn't show in UI. Removes: - <use_mcp_tool>...</use_mcp_tool> blocks (including malformed </use_mcp tool>) - <tool_call>...</tool_call> blocks - Raw JSON tool calls like {"name": "...", "arguments": ...} """ if not content: return content # Remove MCP tool calls - handle malformed closing tags (</use_mcp tool> with space) content = re.sub(r'<?use_mcp_tool>.*?</use_mcp[_ ]?tool>', '', content, flags=re.DOTALL) content = re.sub(r'<use_mcp_tool>.*', '', content, flags=re.DOTALL) # Incomplete at end # Remove hermes-style tool calls content = re.sub(r'<tool_call>.*?</tool_call>', '', content, flags=re.DOTALL) content = re.sub(r'<tool_call>.*', '', content, flags=re.DOTALL) # Remove raw JSON tool calls ({"name": "...", "arguments": ...}) content = re.sub(r'\{"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^}]*\}\s*\}', '', content) # Clean up any remaining fragments content = re.sub(r'use_mcp_tool>.*?</use_mcp[_ ]?tool>', '', content, flags=re.DOTALL) # Missing opening < content = re.sub(r'use_mcp_tool>.*', '', content, flags=re.DOTALL) # Incomplete fragment return content.strip() def _clean_think_tags_from_content(self, content: str) -> str: """Remove stray <think> tags from content that may corrupt JSON parsing. This handles the case where reasoning parser issues cause <think> tokens to be interleaved within structured content (e.g., tool call JSON). Example corruption: <think>{"name<think>":<think> "search"... """ if '<think>' not in content: return content # Remove all <think> tags (but preserve </think> as it marks end of reasoning) cleaned = re.sub(r'<think>', '', content) logger.info(f"[PARSE] Cleaned stray <think> tags from content") return cleaned def _parse_tool_calls(self, content: str) -> List[dict]: """Parse tool calls from various formats including GLM-4.5/INTELLECT-3 style and MCP format. Returns OpenAI-compatible tool_calls format: [{"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}] """ tool_calls = [] content = content.strip() # IMPORTANT: Clean any stray <think> tags that may have corrupted the content # This handles MiroThinker/Qwen3-Thinking models where reasoning parser issues # can cause <think> tokens to appear inside JSON structures content = self._clean_think_tags_from_content(content) # Pattern 0: MCP-style <use_mcp_tool> format (MiroThinker/Qwen3 MCP) # Handles various malformations: # - Missing opening < (e.g., "use_mcp_tool>") # - Space in closing tag (e.g., "</use_mcp tool>") # - Missing closing tag entirely mcp_pattern = r'<?use_mcp_tool>\s*<?server_name>([^<]*)</server_name>\s*<?tool_name>([^<]*)</tool_name>\s*<?arguments>\s*(\{.*?\})\s*</arguments>\s*</use_mcp[_ ]?tool>' mcp_matches = re.findall(mcp_pattern, content, re.DOTALL) for server_name, tool_name, args_json in mcp_matches: try: # Clean any remaining <think> tags from the JSON args_json_clean = self._clean_think_tags_from_content(args_json.strip()) arguments = json.loads(args_json_clean) # Format function name as server__tool for OpenAI compatibility # This matches what the frontend expects (e.g., "exa__search") server = server_name.strip() tool = tool_name.strip() function_name = f"{server}__{tool}" if server else tool tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) } }) logger.info(f"[PARSE] Extracted MCP tool call: {function_name} -> OpenAI format") except json.JSONDecodeError as e: logger.warning(f"[PARSE] Failed to parse MCP tool call arguments: {e}, trying repair...") # Try to repair corrupted JSON by removing non-JSON characters try: # Extract just the JSON part more aggressively json_match = re.search(r'\{[^{}]*\}', args_json, re.DOTALL) if json_match: repaired = json_match.group(0) repaired = self._clean_think_tags_from_content(repaired) arguments = json.loads(repaired) server = server_name.strip() tool = tool_name.strip() function_name = f"{server}__{tool}" if server else tool tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) } }) logger.info(f"[PARSE] Repaired and extracted MCP tool call: {function_name}") except Exception as repair_error: logger.error(f"[PARSE] Could not repair MCP tool call: {repair_error}") continue if tool_calls: return tool_calls # Pattern 0.5: Malformed </tool_call> without <tool_call> - extract JSON before it # Handles: <think>\n{"name": "...", "arguments": {...}}\n</tool_call> if '</tool_call>' in content and '<tool_call>' not in content: # Find JSON object before </tool_call> malformed_pattern = r'(\{"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^}]*\}\s*\})\s*</tool_call>' malformed_matches = re.findall(malformed_pattern, content, re.DOTALL) for json_str in malformed_matches: try: tool_data = json.loads(json_str.strip()) tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": tool_data.get("name"), "arguments": json.dumps(tool_data.get("arguments", {})) } }) logger.info(f"[PARSE] Extracted malformed tool call (no opening tag): name={tool_data.get('name')}") except json.JSONDecodeError: continue if tool_calls: return tool_calls # Pattern 1: Complete <tool_call>...</tool_call> pattern1 = r'<tool_call>(.*?)</tool_call>' matches = re.findall(pattern1, content, re.DOTALL) # Pattern 2: <tool_call>...followed by </think> if not matches and '<tool_call>' in content: pattern2 = r'<tool_call>(.*?)</think>' matches = re.findall(pattern2, content, re.DOTALL) # Pattern 3: Incomplete - <tool_call>{JSON} at end of string if not matches and '<tool_call>' in content: pattern3 = r'<tool_call>(\{.*?\})(?:\s*$|(?=<))' matches = re.findall(pattern3, content, re.DOTALL) # Pattern 4: GLM-style action format: <|action_start|><|plugin|>...JSON...<|action_end|> if not matches: glm_pattern = r'<\|action_start\|><\|plugin\|>\s*(\{.*?\})\s*<\|action_end\|>' matches = re.findall(glm_pattern, content, re.DOTALL) # Pattern 5: Raw JSON with name/arguments (handles nested JSON for arguments) if not matches: # More robust pattern that handles nested braces in arguments raw_json_pattern = r'\{"name"\s*:\s*"([^"]+)"\s*,\s*"(?:arguments|parameters)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\})*\})\}' raw_matches = re.findall(raw_json_pattern, content, re.DOTALL) for name, args in raw_matches: try: arguments = json.loads(args) tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": name, "arguments": json.dumps(arguments) } }) except json.JSONDecodeError: continue if tool_calls: return tool_calls # Pattern 6: Try to find any JSON object with "name" field (fallback) if not matches: # Match JSON objects that have a "name" field json_pattern = r'\{[^{}]*"name"\s*:\s*"[^"]+(?:"[^{}]*|\{[^{}]*\})*\}' matches = re.findall(json_pattern, content, re.DOTALL) for match in matches: try: json_str = match.strip() # Try to extract JSON from the match json_match = re.search(r'\{(?:[^{}]|\{[^{}]*\})*\}', json_str) if json_match: json_str = json_match.group(0) tool_data = json.loads(json_str) function_name = tool_data.get("name") # Support both "arguments" and "parameters" keys arguments = tool_data.get("arguments") or tool_data.get("parameters", {}) if not arguments and isinstance(tool_data, dict): arguments = {k: v for k, v in tool_data.items() if k not in ("name", "id", "type")} if function_name: tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) if isinstance(arguments, dict) else str(arguments) } }) logger.info(f"[PARSE] Extracted tool call: name={function_name}") except (json.JSONDecodeError, AttributeError) as e: logger.debug(f"[PARSE] Failed to parse tool call from match: {e}") continue return tool_calls async def async_post_call_success_hook(self, data: dict, user_api_key_dict, response): """Parse and add tool_calls from content, and extract think tags.""" logger.info(f"[POST-CALL] Processing response, type={type(response).__name__}") try: logger.info(f"[POST-CALL] has_choices={hasattr(response, 'choices')}") if hasattr(response, 'choices') and response.choices: logger.info(f"[POST-CALL] choices_len={len(response.choices)}") choice = response.choices[0] logger.info(f"[POST-CALL] choice_type={type(choice).__name__}") message = getattr(choice, 'message', None) logger.info(f"[POST-CALL] message={message is not None}, message_type={type(message).__name__ if message else 'None'}") if message: content = getattr(message, 'content', '') or '' logger.info(f"[POST-CALL] Content length={len(content)}, has_think_close={'</think>' in content}") # First, parse think tags from content if '</think>' in content: logger.info(f"[POST-CALL] Found </think>, parsing...") reasoning, main_content = self._parse_think_tags(content) logger.info(f"[POST-CALL] Parsed: reasoning={len(reasoning) if reasoning else 0}, main={len(main_content)}") if reasoning: message.content = main_content # Set reasoning_content fieldLines 1–500 of 637. Load more
# CRITICAL """ Custom LiteLLM callback handler for tool call handling and think tag parsing. 1. PRE-CALL: Converts deprecated functions parameter to tools format 2. POST-CALL: Parses tool_calls from content (for models using XML format) 3. POST-CALL: Extracts <think>...</think> content to reasoning_content field """ import json import re import uuid import logging from typing import List, Literal, Optional, Any, Dict from litellm.integrations.custom_logger import CustomLogger # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("ToolCallHandler") # Pattern to match think tags - captures content between <think> and </think> # Handles both with and without opening <think> tag THINK_PATTERN = re.compile(r'^(?:<think>)?(.*?)</think>(.*)$', re.DOTALL) class ToolCallHandler(CustomLogger): """Handle tool calls: convert functions->tools and parse from content + think tag parsing.""" def __init__(self): super().__init__() # Track streaming state per request_id for think tag parsing self._stream_buffers: Dict[str, dict] = {} logger.info("ToolCallHandler initialized with functions-to-tools + think tag parsing!") print("ToolCallHandler initialized with functions-to-tools + think tag parsing!", flush=True) def _parse_think_tags(self, content: str) -> tuple[Optional[str], str]: """ Parse content for think tags. Returns (reasoning_content, main_content). If no </think> tag found, returns (None, original_content). """ if not content: return None, content # Match pattern: everything before </think> is reasoning, everything after is content match = THINK_PATTERN.match(content) if match: reasoning = match.group(1).strip() main_content = match.group(2).strip() return reasoning, main_content return None, content def _get_stream_buffer(self, request_id: str) -> dict: """Get or create a stream buffer for a request.""" if request_id not in self._stream_buffers: self._stream_buffers[request_id] = { 'buffer': '', 'in_thinking': False, # Only true after seeing <think> 'think_complete': False, 'reasoning_emitted': False, # MCP XML tracking 'in_mcp_xml': False, 'mcp_buffer': '', 'content_buffer': '', # Buffer content for final processing 'tool_calls': [], } return self._stream_buffers[request_id] def _cleanup_stream_buffer(self, request_id: str): """Clean up stream buffer after request completes.""" if request_id in self._stream_buffers: del self._stream_buffers[request_id] def _tools_to_mcp_prompt(self, tools: List[dict]) -> str: """Convert OpenAI tools format to MCP system prompt format for MiroThinker.""" tool_docs = [] for tool in tools: if tool.get("type") == "function": func = tool.get("function", {}) name = func.get("name", "unknown") desc = func.get("description", "") params = func.get("parameters", {}) # Parse server__tool format (e.g., exa__search -> server=exa, tool=search) if "__" in name: server, tool_name = name.split("__", 1) else: server, tool_name = "default", name tool_docs.append(f"""## {server} / {tool_name} {desc} Parameters: {json.dumps(params, indent=2)}""") mcp_prompt = """ # Available Tools IMPORTANT: You can ONLY use the tools listed below. Do NOT invent or call any other tools like "google_search" or "scrape_and_extract_info" - they do not exist. To call a tool, use this EXACT XML format: <use_mcp_tool> <server_name>SERVER_NAME</server_name> <tool_name>TOOL_NAME</tool_name> <arguments> {"param": "value"} </arguments> </use_mcp_tool> ## Available Tools: """ + "\n\n".join(tool_docs) + """ REMINDER: Only use the tools listed above. Use the exact server_name and tool_name shown.""" return mcp_prompt def _is_mcp_model(self, model: str) -> bool: """Check if model uses MCP-style tool calls (not OpenAI native).""" model_lower = (model or "").lower() return "mirothinker" in model_lower def _convert_tool_history_to_mcp(self, messages: List[dict]) -> List[dict]: """Convert OpenAI-style tool call history back to MCP format for MiroThinker. This ensures the model sees its own tool calls in the same format it's supposed to use. OpenAI format: assistant message with tool_calls array, followed by tool role messages MCP format: assistant content with <use_mcp_tool> XML, followed by user messages with results """ converted = [] i = 0 while i < len(messages): msg = messages[i] # Handle assistant messages with tool_calls - convert to MCP XML in content if msg.get("role") == "assistant" and msg.get("tool_calls"): tool_calls = msg["tool_calls"] content_parts = [] # Keep any existing content if msg.get("content"): content_parts.append(msg["content"]) # Convert each tool call to MCP XML for tc in tool_calls: func = tc.get("function", {}) name = func.get("name", "") args = func.get("arguments", "{}") # Parse server__tool format back to MCP if "__" in name: server, tool_name = name.split("__", 1) else: server, tool_name = "default", name # Format as MCP XML mcp_xml = f"""<use_mcp_tool> <server_name>{server}</server_name> <tool_name>{tool_name}</tool_name> <arguments> {args} </arguments> </use_mcp_tool>""" content_parts.append(mcp_xml) converted.append({ "role": "assistant", "content": "\n\n".join(content_parts) }) # Now collect all following tool results and combine into a user message tool_results = [] j = i + 1 while j < len(messages) and messages[j].get("role") == "tool": tool_msg = messages[j] tool_call_id = tool_msg.get("tool_call_id", "") tool_name = tool_msg.get("name", "unknown") tool_content = tool_msg.get("content", "") # Find the matching tool call to get server/tool name for tc in tool_calls: if tc.get("id") == tool_call_id: func = tc.get("function", {}) tool_name = func.get("name", tool_name) break tool_results.append(f"Tool result for {tool_name}:\n{tool_content}") j += 1 if tool_results: # Add tool results as a user message (how MCP models expect to see them) converted.append({ "role": "user", "content": "Here are the tool results:\n\n" + "\n\n---\n\n".join(tool_results) }) i = j # Skip past all the tool messages we processed continue # Pass through other messages unchanged converted.append(msg) i += 1 return converted async def async_pre_call_hook( self, user_api_key_dict, cache, data: dict, call_type: Literal["completion", "text_completion", "embeddings", "image_generation", "moderation", "audio_transcription"] ): """Convert tools to MCP format for MiroThinker models.""" logger.info(f"[PRE-CALL] call_type={call_type}, has_functions={'functions' in data}, has_tools={'tools' in data}") if call_type not in ("completion", "acompletion"): return data # Convert deprecated functions to tools first if "functions" in data and "tools" not in data: functions = data.pop("functions") data["tools"] = [{"type": "function", "function": f} for f in functions] if "function_call" in data: fc = data.pop("function_call") if fc == "auto": data["tool_choice"] = "auto" elif fc == "none": data["tool_choice"] = "none" elif isinstance(fc, dict) and "name" in fc: data["tool_choice"] = {"type": "function", "function": {"name": fc["name"]}} # For MCP models (MiroThinker): convert tools to system prompt, remove from request model = data.get("model", "") if self._is_mcp_model(model): tools = data.pop("tools", []) data.pop("tool_choice", None) # Remove tool_choice too messages = data.get("messages", []) # CRITICAL: Convert any OpenAI-style tool call history back to MCP format # This ensures the model sees its own tool calls in the format it expects has_tool_history = any( (m.get("role") == "assistant" and m.get("tool_calls")) or m.get("role") == "tool" for m in messages ) if has_tool_history: messages = self._convert_tool_history_to_mcp(messages) logger.info(f"[PRE-CALL] Converted tool call history to MCP format for {model}") if tools: mcp_prompt = self._tools_to_mcp_prompt(tools) # Prepend MCP tool docs to system message or add new system message if messages and messages[0].get("role") == "system": messages[0]["content"] = mcp_prompt + "\n\n" + messages[0].get("content", "") else: messages.insert(0, {"role": "system", "content": mcp_prompt}) logger.info(f"[PRE-CALL] Converted {len(tools)} tools to MCP prompt for {model}") data["messages"] = messages return data def _strip_tool_call_content(self, content: str) -> str: """Strip tool call XML/JSON from content so it doesn't show in UI. Removes: - <use_mcp_tool>...</use_mcp_tool> blocks (including malformed </use_mcp tool>) - <tool_call>...</tool_call> blocks - Raw JSON tool calls like {"name": "...", "arguments": ...} """ if not content: return content # Remove MCP tool calls - handle malformed closing tags (</use_mcp tool> with space) content = re.sub(r'<?use_mcp_tool>.*?</use_mcp[_ ]?tool>', '', content, flags=re.DOTALL) content = re.sub(r'<use_mcp_tool>.*', '', content, flags=re.DOTALL) # Incomplete at end # Remove hermes-style tool calls content = re.sub(r'<tool_call>.*?</tool_call>', '', content, flags=re.DOTALL) content = re.sub(r'<tool_call>.*', '', content, flags=re.DOTALL) # Remove raw JSON tool calls ({"name": "...", "arguments": ...}) content = re.sub(r'\{"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^}]*\}\s*\}', '', content) # Clean up any remaining fragments content = re.sub(r'use_mcp_tool>.*?</use_mcp[_ ]?tool>', '', content, flags=re.DOTALL) # Missing opening < content = re.sub(r'use_mcp_tool>.*', '', content, flags=re.DOTALL) # Incomplete fragment return content.strip() def _clean_think_tags_from_content(self, content: str) -> str: """Remove stray <think> tags from content that may corrupt JSON parsing. This handles the case where reasoning parser issues cause <think> tokens to be interleaved within structured content (e.g., tool call JSON). Example corruption: <think>{"name<think>":<think> "search"... """ if '<think>' not in content: return content # Remove all <think> tags (but preserve </think> as it marks end of reasoning) cleaned = re.sub(r'<think>', '', content) logger.info(f"[PARSE] Cleaned stray <think> tags from content") return cleaned def _parse_tool_calls(self, content: str) -> List[dict]: """Parse tool calls from various formats including GLM-4.5/INTELLECT-3 style and MCP format. Returns OpenAI-compatible tool_calls format: [{"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}] """ tool_calls = [] content = content.strip() # IMPORTANT: Clean any stray <think> tags that may have corrupted the content # This handles MiroThinker/Qwen3-Thinking models where reasoning parser issues # can cause <think> tokens to appear inside JSON structures content = self._clean_think_tags_from_content(content) # Pattern 0: MCP-style <use_mcp_tool> format (MiroThinker/Qwen3 MCP) # Handles various malformations: # - Missing opening < (e.g., "use_mcp_tool>") # - Space in closing tag (e.g., "</use_mcp tool>") # - Missing closing tag entirely mcp_pattern = r'<?use_mcp_tool>\s*<?server_name>([^<]*)</server_name>\s*<?tool_name>([^<]*)</tool_name>\s*<?arguments>\s*(\{.*?\})\s*</arguments>\s*</use_mcp[_ ]?tool>' mcp_matches = re.findall(mcp_pattern, content, re.DOTALL) for server_name, tool_name, args_json in mcp_matches: try: # Clean any remaining <think> tags from the JSON args_json_clean = self._clean_think_tags_from_content(args_json.strip()) arguments = json.loads(args_json_clean) # Format function name as server__tool for OpenAI compatibility # This matches what the frontend expects (e.g., "exa__search") server = server_name.strip() tool = tool_name.strip() function_name = f"{server}__{tool}" if server else tool tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) } }) logger.info(f"[PARSE] Extracted MCP tool call: {function_name} -> OpenAI format") except json.JSONDecodeError as e: logger.warning(f"[PARSE] Failed to parse MCP tool call arguments: {e}, trying repair...") # Try to repair corrupted JSON by removing non-JSON characters try: # Extract just the JSON part more aggressively json_match = re.search(r'\{[^{}]*\}', args_json, re.DOTALL) if json_match: repaired = json_match.group(0) repaired = self._clean_think_tags_from_content(repaired) arguments = json.loads(repaired) server = server_name.strip() tool = tool_name.strip() function_name = f"{server}__{tool}" if server else tool tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) } }) logger.info(f"[PARSE] Repaired and extracted MCP tool call: {function_name}") except Exception as repair_error: logger.error(f"[PARSE] Could not repair MCP tool call: {repair_error}") continue if tool_calls: return tool_calls # Pattern 0.5: Malformed </tool_call> without <tool_call> - extract JSON before it # Handles: <think>\n{"name": "...", "arguments": {...}}\n</tool_call> if '</tool_call>' in content and '<tool_call>' not in content: # Find JSON object before </tool_call> malformed_pattern = r'(\{"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^}]*\}\s*\})\s*</tool_call>' malformed_matches = re.findall(malformed_pattern, content, re.DOTALL) for json_str in malformed_matches: try: tool_data = json.loads(json_str.strip()) tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": tool_data.get("name"), "arguments": json.dumps(tool_data.get("arguments", {})) } }) logger.info(f"[PARSE] Extracted malformed tool call (no opening tag): name={tool_data.get('name')}") except json.JSONDecodeError: continue if tool_calls: return tool_calls # Pattern 1: Complete <tool_call>...</tool_call> pattern1 = r'<tool_call>(.*?)</tool_call>' matches = re.findall(pattern1, content, re.DOTALL) # Pattern 2: <tool_call>...followed by </think> if not matches and '<tool_call>' in content: pattern2 = r'<tool_call>(.*?)</think>' matches = re.findall(pattern2, content, re.DOTALL) # Pattern 3: Incomplete - <tool_call>{JSON} at end of string if not matches and '<tool_call>' in content: pattern3 = r'<tool_call>(\{.*?\})(?:\s*$|(?=<))' matches = re.findall(pattern3, content, re.DOTALL) # Pattern 4: GLM-style action format: <|action_start|><|plugin|>...JSON...<|action_end|> if not matches: glm_pattern = r'<\|action_start\|><\|plugin\|>\s*(\{.*?\})\s*<\|action_end\|>' matches = re.findall(glm_pattern, content, re.DOTALL) # Pattern 5: Raw JSON with name/arguments (handles nested JSON for arguments) if not matches: # More robust pattern that handles nested braces in arguments raw_json_pattern = r'\{"name"\s*:\s*"([^"]+)"\s*,\s*"(?:arguments|parameters)"\s*:\s*(\{(?:[^{}]|\{[^{}]*\})*\})\}' raw_matches = re.findall(raw_json_pattern, content, re.DOTALL) for name, args in raw_matches: try: arguments = json.loads(args) tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": name, "arguments": json.dumps(arguments) } }) except json.JSONDecodeError: continue if tool_calls: return tool_calls # Pattern 6: Try to find any JSON object with "name" field (fallback) if not matches: # Match JSON objects that have a "name" field json_pattern = r'\{[^{}]*"name"\s*:\s*"[^"]+(?:"[^{}]*|\{[^{}]*\})*\}' matches = re.findall(json_pattern, content, re.DOTALL) for match in matches: try: json_str = match.strip() # Try to extract JSON from the match json_match = re.search(r'\{(?:[^{}]|\{[^{}]*\})*\}', json_str) if json_match: json_str = json_match.group(0) tool_data = json.loads(json_str) function_name = tool_data.get("name") # Support both "arguments" and "parameters" keys arguments = tool_data.get("arguments") or tool_data.get("parameters", {}) if not arguments and isinstance(tool_data, dict): arguments = {k: v for k, v in tool_data.items() if k not in ("name", "id", "type")} if function_name: tool_calls.append({ "id": f"call_{uuid.uuid4().hex[:9]}", "type": "function", "function": { "name": function_name, "arguments": json.dumps(arguments) if isinstance(arguments, dict) else str(arguments) } }) logger.info(f"[PARSE] Extracted tool call: name={function_name}") except (json.JSONDecodeError, AttributeError) as e: logger.debug(f"[PARSE] Failed to parse tool call from match: {e}") continue return tool_calls async def async_post_call_success_hook(self, data: dict, user_api_key_dict, response): """Parse and add tool_calls from content, and extract think tags.""" logger.info(f"[POST-CALL] Processing response, type={type(response).__name__}") try: logger.info(f"[POST-CALL] has_choices={hasattr(response, 'choices')}") if hasattr(response, 'choices') and response.choices: logger.info(f"[POST-CALL] choices_len={len(response.choices)}") choice = response.choices[0] logger.info(f"[POST-CALL] choice_type={type(choice).__name__}") message = getattr(choice, 'message', None) logger.info(f"[POST-CALL] message={message is not None}, message_type={type(message).__name__ if message else 'None'}") if message: content = getattr(message, 'content', '') or '' logger.info(f"[POST-CALL] Content length={len(content)}, has_think_close={'</think>' in content}") # First, parse think tags from content if '</think>' in content: logger.info(f"[POST-CALL] Found </think>, parsing...") reasoning, main_content = self._parse_think_tags(content) logger.info(f"[POST-CALL] Parsed: reasoning={len(reasoning) if reasoning else 0}, main={len(main_content)}") if reasoning: message.content = main_content # Set reasoning_content field