diff --git a/src/cai/sdk/agents/models/openai_chatcompletions.py b/src/cai/sdk/agents/models/openai_chatcompletions.py index 0df96012..e3fe3772 100644 --- a/src/cai/sdk/agents/models/openai_chatcompletions.py +++ b/src/cai/sdk/agents/models/openai_chatcompletions.py @@ -1141,7 +1141,9 @@ class OpenAIChatCompletionsModel(Model): else: # Non-streaming mode: Use simple text output from cai.util import print_claude_reasoning_simple, detect_claude_thinking_in_stream - if detect_claude_thinking_in_stream(str(self.model)): + # Check if model supports reasoning (Claude or DeepSeek) + model_str_lower = str(self.model).lower() + if detect_claude_thinking_in_stream(str(self.model)) or "deepseek" in model_str_lower: print_claude_reasoning_simple(reasoning_content, self.agent_name, str(self.model)) @@ -2153,6 +2155,14 @@ class OpenAIChatCompletionsModel(Model): # Remove tool_choice if no tools are specified if not converted_tools: kwargs.pop("tool_choice", None) + + # Add reasoning support for DeepSeek + # DeepSeek supports reasoning_effort parameter + if hasattr(model_settings, "reasoning_effort") and model_settings.reasoning_effort: + kwargs["reasoning_effort"] = model_settings.reasoning_effort + else: + # Default to "low" reasoning effort if model supports it + kwargs["reasoning_effort"] = "low" elif provider == "claude" or "claude" in model_str: litellm.drop_params = True kwargs.pop("store", None) @@ -2355,6 +2365,13 @@ class OpenAIChatCompletionsModel(Model): provider_kwargs["custom_llm_provider"] = "deepseek" provider_kwargs.pop("store", None) # DeepSeek doesn't support store parameter provider_kwargs.pop("parallel_tool_calls", None) # DeepSeek doesn't support parallel tool calls + + # Add reasoning support for DeepSeek + if hasattr(model_settings, "reasoning_effort") and model_settings.reasoning_effort: + provider_kwargs["reasoning_effort"] = model_settings.reasoning_effort + else: + # Default to "low" reasoning effort + provider_kwargs["reasoning_effort"] = "low" elif provider == "claude" or "claude" in model_str: provider_kwargs["custom_llm_provider"] = "anthropic" provider_kwargs.pop("store", None) # Claude doesn't support store parameter @@ -3234,6 +3251,9 @@ class _Converter: call_id = func_output["call_id"] output_content = func_output["output"] + # IMPORTANT: Truncate call_id to 40 characters for consistency + truncated_call_id = call_id[:40] if call_id else call_id + # Update execution timing if we have the start time if hasattr(cls, 'recent_tool_calls') and call_id in cls.recent_tool_calls: tool_call_details = cls.recent_tool_calls[call_id] # Renamed for clarity @@ -3354,10 +3374,10 @@ class _Converter: # The responsibility for ensuring a preceding assistant message # is now fully deferred to fix_message_list, called later. - # Now add the tool message + # Now add the tool message with truncated call_id msg: ChatCompletionToolMessageParam = { "role": "tool", - "tool_call_id": func_output["call_id"], + "tool_call_id": truncated_call_id, "content": func_output["output"], } result.append(msg) diff --git a/src/cai/util.py b/src/cai/util.py index 45ebf5f4..68d60f22 100644 --- a/src/cai/util.py +++ b/src/cai/util.py @@ -797,6 +797,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 a tool_result block (tool message with matching tool_call_id). 6. Each 'tool' message must be immediately preceded by an 'assistant' message with matching tool_call_id in its tool_calls. + 7. Tool call IDs are truncated to 40 characters for API compatibility. Args: messages (List[dict]): List of message dictionaries containing @@ -810,22 +811,45 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 # Deep-copy to ensure we don't modify the input sanitized_messages = [] - # First pass - identify tool_call_ids from assistant messages and tool messages + # First, truncate all tool call IDs to 40 characters throughout the messages + # This ensures consistency for providers like DeepSeek that have strict ID matching + for msg in messages: + msg_copy = msg.copy() + + # Truncate tool_call_id in tool messages + if msg_copy.get("role") == "tool" and msg_copy.get("tool_call_id"): + if len(msg_copy["tool_call_id"]) > 40: + msg_copy["tool_call_id"] = msg_copy["tool_call_id"][:40] + + # Truncate IDs in assistant tool_calls + if msg_copy.get("role") == "assistant" and msg_copy.get("tool_calls"): + tool_calls_copy = [] + for tc in msg_copy["tool_calls"]: + tc_copy = tc.copy() + if tc_copy.get("id") and len(tc_copy["id"]) > 40: + tc_copy["id"] = tc_copy["id"][:40] + tool_calls_copy.append(tc_copy) + msg_copy["tool_calls"] = tool_calls_copy + + sanitized_messages.append(msg_copy) + + # Now process the messages with truncated IDs + processed_messages = [] tool_call_map = {} # Map from tool_call_id to (assistant_idx, tool_idx) - for i, msg in enumerate(messages): + for i, msg in enumerate(sanitized_messages): # Skip empty messages (considered empty if 'content' is None or only whitespace) if msg.get("role") in ["user", "system"] and (msg.get("content") is None or not str(msg.get("content", "")).strip()): # Special case: if it's a system message, set content to empty string instead of skipping if msg.get("role") == "system": # Replace None with empty string msg["content"] = "" - sanitized_messages.append(msg) + processed_messages.append(msg) # Skip empty user messages entirely continue - # Add valid messages to our sanitized list first - sanitized_messages.append(msg) + # Add valid messages to our processed list first + processed_messages.append(msg) # Now track tool calls and tool messages for pairing if msg.get("role") == "assistant" and msg.get("tool_calls"): @@ -833,12 +857,12 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 if tc.get("id"): tool_id = tc.get("id") if tool_id not in tool_call_map: - tool_call_map[tool_id] = {"assistant_idx": len(sanitized_messages) - 1, "tool_idx": None} + tool_call_map[tool_id] = {"assistant_idx": len(processed_messages) - 1, "tool_idx": None} if msg.get("role") == "tool" and msg.get("tool_call_id"): tool_id = msg.get("tool_call_id") if tool_id in tool_call_map: - tool_call_map[tool_id]["tool_idx"] = len(sanitized_messages) - 1 + tool_call_map[tool_id]["tool_idx"] = len(processed_messages) - 1 else: # Tool response without a matching tool call - create a synthetic pair # by adding a dummy assistant message with a tool_call @@ -855,15 +879,15 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 }] } # Insert the assistant message *before* the tool message - sanitized_messages.insert(len(sanitized_messages) - 1, assistant_msg) + processed_messages.insert(len(processed_messages) - 1, assistant_msg) # Update mapping - tool_call_map[tool_id] = {"assistant_idx": len(sanitized_messages) - 2, "tool_idx": len(sanitized_messages) - 1} + tool_call_map[tool_id] = {"assistant_idx": len(processed_messages) - 2, "tool_idx": len(processed_messages) - 1} # Second pass - ensure correct sequence (tool messages must directly follow their assistant messages) # This fixes the error "messages with role 'tool' must be a response to a preceeding message with 'tool_calls'" i = 0 - while i < len(sanitized_messages): - msg = sanitized_messages[i] + while i < len(processed_messages): + msg = processed_messages[i] # Check if this is a tool message that might be out of sequence if msg.get("role") == "tool" and msg.get("tool_call_id"): @@ -871,7 +895,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 # If this isn't the first message, check if the previous message is a matching assistant message if i > 0: - prev_msg = sanitized_messages[i-1] + prev_msg = processed_messages[i-1] # Check if the previous message is an assistant message with matching tool_call_id is_valid_sequence = ( @@ -883,7 +907,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 if not is_valid_sequence: # Find the assistant message with this tool_call_id assistant_idx = None - for j, assistant_msg in enumerate(sanitized_messages): + for j, assistant_msg in enumerate(processed_messages): if (assistant_msg.get("role") == "assistant" and assistant_msg.get("tool_calls") and any(tc.get("id") == tool_id for tc in assistant_msg.get("tool_calls", []))): @@ -893,10 +917,10 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 # If we found a matching assistant message, move this tool message right after it if assistant_idx is not None: # Remember to save the tool message - tool_msg = sanitized_messages.pop(i) + tool_msg = processed_messages.pop(i) # Insert right after the assistant message - sanitized_messages.insert(assistant_idx + 1, tool_msg) + processed_messages.insert(assistant_idx + 1, tool_msg) # Adjust i to account for the move if assistant_idx < i: @@ -923,7 +947,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 } # Insert the assistant message before the tool message - sanitized_messages.insert(i, assistant_msg) + processed_messages.insert(i, assistant_msg) # Skip past both messages i += 2 @@ -945,7 +969,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 } # Insert the assistant message before the tool message - sanitized_messages.insert(0, assistant_msg) + processed_messages.insert(0, assistant_msg) # Skip past both messages i += 2 @@ -959,7 +983,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 if indices["tool_idx"] is None: # Tool call without a response - create a synthetic tool message assistant_idx = indices["assistant_idx"] - assistant_msg = sanitized_messages[assistant_idx] + assistant_msg = processed_messages[assistant_idx] # Find the relevant tool call tool_name = "unknown_function" @@ -977,18 +1001,18 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 } # Insert immediately after the assistant message - if assistant_idx + 1 < len(sanitized_messages): + if assistant_idx + 1 < len(processed_messages): # Insert at the position after assistant - sanitized_messages.insert(assistant_idx + 1, tool_msg) + processed_messages.insert(assistant_idx + 1, tool_msg) else: # Just append if we're at the end - sanitized_messages.append(tool_msg) + processed_messages.append(tool_msg) # Update the map to note that this tool call now has a response tool_call_map[tool_id]["tool_idx"] = assistant_idx + 1 # Ensure messages have non-null content (required by some providers) - for msg in sanitized_messages: + for msg in processed_messages: # For assistant messages with tool_calls, content can be None if msg.get("role") == "assistant" and msg.get("tool_calls"): # Assistant messages with tool calls can have None content - this is valid @@ -1005,9 +1029,9 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 # Special case for Claude: ensure strict alternating pattern between assistant tool_calls and tool results # If multiple consecutive assistant messages with tool_calls exist, interleave them with tool responses i = 0 - while i < len(sanitized_messages) - 1: - current_msg = sanitized_messages[i] - next_msg = sanitized_messages[i + 1] + while i < len(processed_messages) - 1: + current_msg = processed_messages[i] + next_msg = processed_messages[i + 1] # When current message is assistant with tool_calls and next message is NOT a tool response if (current_msg.get("role") == "assistant" and @@ -1028,13 +1052,13 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 } # Insert the tool message after the current assistant message - sanitized_messages.insert(i + 1, tool_msg) + processed_messages.insert(i + 1, tool_msg) # Skip over the newly inserted message i += 2 else: i += 1 - return sanitized_messages + return processed_messages def cli_print_tool_call(tool_name="", args="", output="", prefix=" "): """Print a tool call with pretty formatting""" @@ -3221,8 +3245,8 @@ def setup_ctf(): def create_claude_thinking_context(agent_name, counter, model): """ - Create a streaming context for Claude thinking/reasoning display. - This creates a dedicated panel that shows Claude's internal reasoning process. + Create a streaming context for AI thinking/reasoning display. + This creates a dedicated panel that shows the model's internal reasoning process. Args: agent_name: The name of the agent @@ -3254,10 +3278,19 @@ def create_claude_thinking_context(agent_name, counter, model): terminal_width, _ = shutil.get_terminal_size((100, 24)) panel_width = min(terminal_width - 4, 120) + # Determine model type for display + model_str = str(model).lower() + if "claude" in model_str: + model_display = "Claude" + elif "deepseek" in model_str: + model_display = "DeepSeek" + else: + model_display = "AI" + # Create the thinking panel header header = Text() header.append("🧠 ", style="bold yellow") - header.append(f"Claude Reasoning [{counter}]", style="bold yellow") + header.append(f"{model_display} Reasoning [{counter}]", style="bold yellow") header.append(f" | {agent_name}", style="bold cyan") header.append(f" | {timestamp}", style="dim") @@ -3267,7 +3300,7 @@ def create_claude_thinking_context(agent_name, counter, model): # Create the panel for thinking panel = Panel( Group(header, Text("\n"), thinking_content), - title="[bold yellow]🧠 Thinking Process[/bold yellow]", + title=f"[bold yellow]🧠 {model_display} Thinking Process[/bold yellow]", border_style="yellow", box=ROUNDED, padding=(1, 2), @@ -3286,6 +3319,7 @@ def create_claude_thinking_context(agent_name, counter, model): "thinking_content": thinking_content, "timestamp": timestamp, "model": model, + "model_display": model_display, "agent_name": agent_name, "panel_width": panel_width, "is_started": False, @@ -3298,12 +3332,12 @@ def create_claude_thinking_context(agent_name, counter, model): return context except Exception as e: - print(f"Error creating Claude thinking context: {e}") + print(f"Error creating {model_display} thinking context: {e}") return None def update_claude_thinking_content(context, thinking_delta): """ - Update the Claude thinking content with new reasoning text. + Update the AI thinking content with new reasoning text. Args: context: The thinking context created by create_claude_thinking_context @@ -3339,6 +3373,9 @@ def update_claude_thinking_content(context, thinking_delta): # For short thinking, use regular text with styling thinking_display = Text(thinking_text, style="white") + # Get model display name from context + model_display = context.get("model_display", "AI") + # Update the panel content updated_panel = Panel( Group( @@ -3346,7 +3383,7 @@ def update_claude_thinking_content(context, thinking_delta): Text("\n"), thinking_display ), - title="[bold yellow]🧠 Thinking Process[/bold yellow]", + title=f"[bold yellow]🧠 {model_display} Thinking Process[/bold yellow]", border_style="yellow", box=ROUNDED, padding=(1, 2), @@ -3360,7 +3397,8 @@ def update_claude_thinking_content(context, thinking_delta): context["live"].start() context["is_started"] = True except Exception as e: - print(f"Error starting Claude thinking display: {e}") + model_display = context.get("model_display", "AI") + print(f"Error starting {model_display} thinking display: {e}") return False # Update the live display @@ -3371,12 +3409,13 @@ def update_claude_thinking_content(context, thinking_delta): return True except Exception as e: - print(f"Error updating Claude thinking content: {e}") + model_display = context.get("model_display", "AI") + print(f"Error updating {model_display} thinking content: {e}") return False def finish_claude_thinking_display(context): """ - Finish the Claude thinking display session. + Finish the AI thinking display session. Args: context: The thinking context to finish @@ -3395,10 +3434,13 @@ def finish_claude_thinking_display(context): from rich.syntax import Syntax from rich.console import Group + # Get model display name + model_display = context.get("model_display", "AI") + # Add final formatting to show completion final_header = Text() final_header.append("🧠 ", style="bold green") - final_header.append(f"Claude Reasoning Complete", style="bold green") + final_header.append(f"{model_display} Reasoning Complete", style="bold green") final_header.append(f" | {context['agent_name']}", style="bold cyan") final_header.append(f" | {context['timestamp']}", style="dim") @@ -3424,7 +3466,7 @@ def finish_claude_thinking_display(context): Text("\n"), final_thinking_display ), - title="[bold green]🧠 Thinking Complete[/bold green]", + title=f"[bold green]🧠 {model_display} Thinking Complete[/bold green]", border_style="green", box=ROUNDED, padding=(1, 2), @@ -3446,13 +3488,14 @@ def finish_claude_thinking_display(context): return True except Exception as e: - print(f"Error finishing Claude thinking display: {e}") + model_display = context.get("model_display", "AI") + print(f"Error finishing {model_display} thinking display: {e}") return False def detect_claude_thinking_in_stream(model_name): """ Detect if a model should show thinking/reasoning display. - Only applies to Claude models with reasoning capability. + Applies to Claude and DeepSeek models with reasoning capability. Args: model_name: The model name to check @@ -3468,7 +3511,7 @@ def detect_claude_thinking_in_stream(model_name): # Check for Claude models with reasoning capability # Claude 4 models (like claude-sonnet-4-20250514) support reasoning # Also check for explicit "thinking" in model name - has_reasoning = ( + has_claude_reasoning = ( "claude" in model_str and ( # Claude 4 models (sonnet-4, haiku-4, opus-4) "-4-" in model_str or @@ -3481,11 +3524,23 @@ def detect_claude_thinking_in_stream(model_name): ) ) - return has_reasoning + # Check for DeepSeek models with reasoning capability + has_deepseek_reasoning = ( + "deepseek" in model_str and ( + # DeepSeek reasoner models + "reasoner" in model_str or + # DeepSeek chat models also support reasoning + "chat" in model_str or + # Generic deepseek models likely support it + "/" in model_str # e.g., deepseek/deepseek-chat + ) + ) + + return has_claude_reasoning or has_deepseek_reasoning def print_claude_reasoning_simple(reasoning_content, agent_name, model_name): """ - Print Claude reasoning content in simple mode (no Rich panels). + Print AI reasoning content in simple mode (no Rich panels). Used when CAI_STREAM=False. Args: @@ -3496,16 +3551,26 @@ def print_claude_reasoning_simple(reasoning_content, agent_name, model_name): if not reasoning_content or not reasoning_content.strip(): return + # Determine model type for display + model_str = str(model_name).lower() + if "claude" in model_str: + model_display = "Claude" + elif "deepseek" in model_str: + model_display = "DeepSeek" + else: + model_display = "AI" + # Simple text output without Rich formatting timestamp = datetime.now().strftime("%H:%M:%S") - print(f"\n🧠 Reasoning | {agent_name} | {model_name} | {timestamp}") + print(f"\n🧠 {model_display} Reasoning | {agent_name} | {model_name} | {timestamp}") print("=" * 60) print(reasoning_content) print("=" * 60 + "\n") def start_claude_thinking_if_applicable(model_name, agent_name, counter): """ - Start Claude thinking display if the model supports it AND streaming is enabled. + Start AI thinking display if the model supports it AND streaming is enabled. + Supports Claude and DeepSeek models with reasoning capabilities. Args: model_name: The model name