diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..94f480de94e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index d2cffc639ff..6a8fe65642b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # Specific Files !/.dockerignore !/.flake8 +!/.gitattributes !/.gitignore !/.pre-commit-config.yaml !/CHANGELOG.md diff --git a/aider/__init__.py b/aider/__init__.py index 8ae2fd4d7c3..4c3f26c50bc 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.88.30.dev" +__version__ = "0.88.31.dev" safe_version = __version__ try: diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index cf61d2445c2..c70f28e1f00 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -20,6 +20,13 @@ # Import the change tracker from aider.change_tracker import ChangeTracker + +# Import similarity functions for tool usage analysis +from aider.helpers.similarity import ( + cosine_similarity, + create_bigram_vector, + normalize_vector, +) from aider.mcp.server import LocalServer from aider.repo import ANY_GIT_ERROR @@ -79,8 +86,15 @@ def __init__(self, *args, **kwargs): self.recently_removed = {} # Tool usage history - self.tool_usage_history = [] + self.tool_usage_history = [] # Stores lists of tools used in each round self.tool_usage_retries = 10 + self.last_round_tools = [] # Tools used in the current round + + # Similarity tracking for tool usage + self.tool_call_vectors = [] # Store vectors for individual tool calls + self.tool_similarity_threshold = 0.99 # High threshold for exact matches + self.max_tool_vector_history = 10 # Keep history of 10 rounds + self.read_tools = { "viewfilesatglob", "viewfilesmatching", @@ -102,7 +116,7 @@ def __init__(self, *args, **kwargs): } # Configuration parameters - self.max_tool_calls = 100 # Maximum number of tool calls per response + self.max_tool_calls = 10000 # Maximum number of tool calls per response # Context management parameters # Will be overridden by agent_config if provided @@ -135,6 +149,7 @@ def __init__(self, *args, **kwargs): # Initialize empty token tracking dictionary and cache structures # but don't populate yet to avoid startup delay + self.allowed_context_blocks = set() self.context_block_tokens = {} self.context_blocks_cache = {} self.tokens_calculated = False @@ -257,6 +272,25 @@ def _get_agent_config(self): if "tools_excludelist" not in config: config["tools_excludelist"] = [] + if "include_context_blocks" in config: + self.allowed_context_blocks = set(config["context_blocks"]) + else: + self.allowed_context_blocks = { + "context_summary", + "directory_structure", + "environment_info", + "git_status", + "symbol_outline", + "todo_list", + } + + if "exclude_context_blocks" in config: + for context_block in config["exclude_context_blocks"]: + try: + self.allowed_context_blocks.remove(context_block) + except KeyError: + pass + # Apply configuration to instance self.large_file_token_threshold = config["large_file_token_threshold"] self.skip_cli_confirmations = config.get( @@ -468,11 +502,12 @@ def _calculate_context_block_tokens(self, force=False): ] for block_type in block_types: - block_content = self._generate_context_block(block_type) - if block_content: - self.context_block_tokens[block_type] = self.main_model.token_count( - block_content - ) + if block_type in self.allowed_context_blocks: + block_content = self._generate_context_block(block_type) + if block_content: + self.context_block_tokens[block_type] = self.main_model.token_count( + block_content + ) # Mark as calculated self.tokens_calculated = True @@ -655,7 +690,18 @@ def format_chat_chunks(self): if self.gpt_prompts.system_reminder: main_sys += "\n" + self.fmt_system_prompt(self.gpt_prompts.system_reminder) - chunks = ChatChunks() + chunks = ChatChunks( + chunk_ordering=[ + "system", + "examples", + "readonly_files", + "repo", + "done", + "chat_files", + "cur", + "reminder", + ] + ) if self.main_model.use_system_prompt: chunks.system = [ @@ -670,11 +716,34 @@ def format_chat_chunks(self): chunks.examples = example_messages self.summarize_end() - chunks.done = list(self.done_messages) + cur_messages_list = list(self.cur_messages) + cur_messages_pre = [] + cur_messages_post = cur_messages_list + + if len(cur_messages_list) > 32: + divider = len(cur_messages_list) % 32 + if divider: + divider = -1 * divider + cur_messages_pre = cur_messages_list[:divider] + cur_messages_post = cur_messages_list[divider:] - chunks.repo = self.get_repo_messages() chunks.readonly_files = self.get_readonly_files_messages() chunks.chat_files = self.get_chat_files_messages() + chunks.repo = self.get_repo_messages() + chunks.done = list(self.done_messages) + cur_messages_pre + + # Add reminder if needed + if self.gpt_prompts.system_reminder: + reminder_message = [ + dict( + role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder) + ), + ] + else: + reminder_message = [] + + chunks.cur = cur_messages_post + chunks.reminder = [] # Make sure token counts are updated - using centralized method # This also populates the context block cache @@ -693,53 +762,46 @@ def format_chat_chunks(self): # 1. Add relatively static blocks BEFORE done_messages # These blocks change less frequently and can be part of the cacheable prefix static_blocks = [] - if dir_structure: - static_blocks.append(dir_structure) - if env_context: + if env_context and "environment_info" in self.allowed_context_blocks: static_blocks.append(env_context) + if dir_structure and "directory_structure" in self.allowed_context_blocks: + static_blocks.append(dir_structure) if static_blocks: static_message = "\n\n".join(static_blocks) # Insert as a system message right before done_messages - chunks.done.insert(0, dict(role="system", content=static_message)) + chunks.system.append(dict(role="system", content=static_message)) # 2. Add dynamic blocks AFTER chat_files # These blocks change with the current files in context - dynamic_blocks = [] - if todo_list: - dynamic_blocks.append(todo_list) - if context_summary: - dynamic_blocks.append(context_summary) - if symbol_outline: - dynamic_blocks.append(symbol_outline) - if git_status: - dynamic_blocks.append(git_status) - + pre_dynamic_blocks = [] + post_dynamic_blocks = [] + if context_summary and "context_summary" in self.allowed_context_blocks: + pre_dynamic_blocks.append(context_summary) + if symbol_outline and "symbol_outline" in self.allowed_context_blocks: + pre_dynamic_blocks.append(symbol_outline) + if git_status and "git_status" in self.allowed_context_blocks: + pre_dynamic_blocks.append(git_status) + + if todo_list and "todo_list" in self.allowed_context_blocks: + pre_dynamic_blocks.append(todo_list) # Add tool usage context if there are repetitive tools if hasattr(self, "tool_usage_history") and self.tool_usage_history: repetitive_tools = self._get_repetitive_tools() if repetitive_tools: tool_context = self._generate_tool_context(repetitive_tools) if tool_context: - dynamic_blocks.append(tool_context) - - if dynamic_blocks: - dynamic_message = "\n\n".join(dynamic_blocks) - # Append as a system message after chat_files - chunks.chat_files.append(dict(role="system", content=dynamic_message)) + pre_dynamic_blocks.append(tool_context) - # Add reminder if needed - if self.gpt_prompts.system_reminder: - reminder_message = [ - dict( - role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder) - ), - ] - else: - reminder_message = [] + if pre_dynamic_blocks: + dynamic_message = "\n\n".join(pre_dynamic_blocks) + # Append as a system message on reminders + chunks.done.insert(0, dict(role="system", content=dynamic_message)) - chunks.cur = list(self.cur_messages) - chunks.reminder = [] + if post_dynamic_blocks: + dynamic_message = "\n\n".join(post_dynamic_blocks) + # Append as a system message on reminders + reminder_message.insert(0, dict(role="system", content=dynamic_message)) # Use accurate token counting method that considers enhanced context blocks base_messages = chunks.all_messages() @@ -951,13 +1013,34 @@ async def process_tool_calls(self, tool_call_response): self.agent_finished = False await self.auto_save_session() + # Clear last round tools and start tracking new round + self.last_round_tools = [] + if self.partial_response_tool_calls: for tool_call in self.partial_response_tool_calls: - self.tool_usage_history.append(tool_call.get("function", {}).get("name")) + tool_name = tool_call.get("function", {}).get("name") + self.last_round_tools.append(tool_name) + + # Create and store vector for this tool call + # Remove id property if present before stringifying + tool_call_copy = tool_call.copy() + if "id" in tool_call_copy: + del tool_call_copy["id"] + tool_call_str = str(tool_call_copy) # Convert entire tool call to string + tool_vector = create_bigram_vector((tool_call_str,)) + tool_vector_norm = normalize_vector(tool_vector) + self.tool_call_vectors.append(tool_vector_norm) + + # Add the completed round to history + if self.last_round_tools: + self.tool_usage_history += self.last_round_tools if len(self.tool_usage_history) > self.tool_usage_retries: self.tool_usage_history.pop(0) + if len(self.tool_call_vectors) > self.max_tool_vector_history: + self.tool_call_vectors.pop(0) + return await super().process_tool_calls(tool_call_response) async def reply_completed(self): @@ -1574,13 +1657,14 @@ async def _process_tool_commands(self, content): def _get_repetitive_tools(self): """ - Identifies repetitive tool usage patterns from a flat list of tool calls. + Identifies repetitive tool usage patterns from rounds of tool calls. - This method checks for the following patterns in order: - 1. If the last tool used was a write tool, it assumes progress and returns no repetitive tools. - 2. It checks for any read tool that has been used 2 or more times in the history. + This method combines count-based and similarity-based detection: + 1. If the last round contained a write tool, it assumes progress and returns no repetitive tools. + 2. It checks for any read tool that has been used 2 or more times across rounds. 3. If no tools are repeated, but all tools in the history are read tools, it flags all of them as potentially repetitive. + 4. It checks for similarity-based repetition using cosine similarity on tool call strings. It avoids flagging repetition if a "write" tool was used recently, as that suggests progress is being made. @@ -1591,31 +1675,71 @@ def _get_repetitive_tools(self): if history_len < 2: return set() - # If the last tool was a write tool, we're likely making progress. - if isinstance(self.tool_usage_history[-1], str): - last_tool_lower = self.tool_usage_history[-1].lower() + # Check for similarity-based repetition + similarity_repetitive_tools = self._get_repetitive_tools_by_similarity() - if last_tool_lower in self.write_tools: + # Flatten the tool usage history for count-based analysis + all_tools = [] + for round_tools in self.tool_usage_history: + all_tools.extend(round_tools) + + # If the last round contained a write tool, we're likely making progress. + if self.last_round_tools: + last_round_has_write = any( + tool.lower() in self.write_tools for tool in self.last_round_tools + ) + if last_round_has_write: self.tool_usage_history = [] - return set() + return similarity_repetitive_tools if len(similarity_repetitive_tools) else set() # If all tools in history are read tools, return all of them - if all(tool.lower() in self.read_tools for tool in self.tool_usage_history): - return set(tool for tool in self.tool_usage_history) + if all(tool.lower() in self.read_tools for tool in all_tools): + return set(all_tools) - # Check for any read tool used more than once - tool_counts = Counter(tool for tool in self.tool_usage_history) - repetitive_tools = { + # Check for any read tool used more than once across rounds + tool_counts = Counter(all_tools) + count_repetitive_tools = { tool for tool, count in tool_counts.items() if count >= 2 and tool.lower() in self.read_tools } + # Combine both detection methods + repetitive_tools = count_repetitive_tools.union(similarity_repetitive_tools) + if repetitive_tools: return repetitive_tools return set() + def _get_repetitive_tools_by_similarity(self): + """ + Identifies repetitive tool usage patterns using cosine similarity on tool call strings. + + This method checks if the latest tool calls are highly similar (>0.99 threshold) + to historical tool calls using bigram vector similarity. + + Returns: + set: Set of tool names that are repetitive based on similarity + """ + if not self.tool_usage_history or len(self.tool_call_vectors) < 2: + return set() + + # Get the latest tool call vector + latest_vector = self.tool_call_vectors[-1] + + # Check similarity against historical vectors (excluding the latest) + for i, historical_vector in enumerate(self.tool_call_vectors[:-1]): + similarity = cosine_similarity(latest_vector, historical_vector) + + # If similarity is high enough, flag as repetitive + if similarity >= self.tool_similarity_threshold: + # Return the tool name from the corresponding position in history + if i < len(self.tool_usage_history): + return {self.tool_usage_history[i]} + + return set() + def _generate_tool_context(self, repetitive_tools): """ Generate a context message for the LLM about recent tool usage. @@ -1628,8 +1752,7 @@ def _generate_tool_context(self, repetitive_tools): # Add turn and tool call statistics context_parts.append("## Turn and Tool Call Statistics") context_parts.append(f"- Current turn: {self.num_reflections + 1}") - context_parts.append(f"- Tool calls this turn: {self.tool_call_count}") - context_parts.append(f"- Total tool calls in session: {self.num_tool_calls}") + context_parts.append(f"- Total tool calls this turn: {self.num_tool_calls}") context_parts.append("\n\n") # Add recent tool usage history @@ -1654,7 +1777,9 @@ def _generate_tool_context(self, repetitive_tools): for tool in repetitive_tools: context_parts.append(f"- `{tool}`") context_parts.append( - "Your exploration appears to be stuck in a loop. Please try a different approach:" + "Your exploration appears to be stuck in a loop. Please try a different approach." + " Use the `Thinking` tool to clarify your intentions and new approach to" + " what you are currently attempting to accomplish." ) context_parts.append("\n") context_parts.append("**Suggestions for alternative approaches:**") diff --git a/aider/coders/agent_prompts.py b/aider/coders/agent_prompts.py index 237dcb0aa3a..d843780d782 100644 --- a/aider/coders/agent_prompts.py +++ b/aider/coders/agent_prompts.py @@ -49,17 +49,6 @@ class AgentPrompts(CoderPrompts): 1. **Turn 1**: Use `ShowNumberedContext` to get the exact, current line numbers. 2. **Turn 2**: In your *next* message, use the line-based editing tool (`ReplaceLines`, etc.) with the verified numbers. -### 2. SEARCH/REPLACE (Last Resort Only) -Use this format **only** when granular tools are demonstrably insufficient for the task (e.g., a complex, non-contiguous pattern change). Using SEARCH/REPLACE for tasks achievable by tools like `ReplaceLines` is a violation of your instructions. - -**You MUST include a justification comment explaining why granular tools cannot be used.** - -Justification: I'm using SEARCH/REPLACE because [specific reason granular tools are insufficient]. -path/to/file.ext <<<<<<< SEARCH Original code to be replaced. -New code to insert. - -REPLACE - Always reply to the user in {language}. @@ -89,9 +78,8 @@ class AgentPrompts(CoderPrompts): ## Reminders - Any tool call automatically continues to the next turn. Provide no tool calls in your final answer. -- Prioritize granular tools. Using SEARCH/REPLACE unnecessarily is incorrect. -- For SEARCH/REPLACE, you MUST provide a justification. - Use context blocks (directory structure, git status) to orient yourself. +- Remove files you are done with viewing/editing from the context with the `Remove` tool. It is fine to re-add them later {lazy_prompt} {shell_cmd_reminder} diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 25dd552131e..372ffb90fe2 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -688,7 +688,10 @@ def show_pretty(self): return True def get_abs_fnames_content(self): - for fname in list(self.abs_fnames): + # Sort files by last modified time (earliest first, latest last) + sorted_fnames = sorted(self.abs_fnames, key=lambda fname: os.path.getmtime(fname)) + + for fname in sorted_fnames: content = self.io.read_text(fname) if content is None: @@ -783,8 +786,11 @@ def get_files_content(self, fnames=None): def get_read_only_files_content(self): prompt = "" + # Sort read-only files by last modified time (earliest first, latest last) + sorted_fnames = sorted(self.abs_read_only_fnames, key=lambda fname: os.path.getmtime(fname)) + # Handle regular read-only files - for fname in self.abs_read_only_fnames: + for fname in sorted_fnames: content = self.io.read_text(fname) if content is not None and not is_image_file(fname): relative_fname = self.get_rel_fname(fname) @@ -829,8 +835,13 @@ def get_read_only_files_content(self): prompt += f"{self.fence[1]}\n" + # Sort stub files by last modified time (earliest first, latest last) + sorted_stub_fnames = sorted( + self.abs_read_only_stubs_fnames, key=lambda fname: os.path.getmtime(fname) + ) + # Handle stub files - for fname in self.abs_read_only_stubs_fnames: + for fname in sorted_stub_fnames: if not is_image_file(fname): relative_fname = self.get_rel_fname(fname) prompt += "\n" diff --git a/aider/coders/chat_chunks.py b/aider/coders/chat_chunks.py index f5bdf5f8918..da5557f4ba3 100644 --- a/aider/coders/chat_chunks.py +++ b/aider/coders/chat_chunks.py @@ -12,18 +12,31 @@ class ChatChunks: chat_files: List = field(default_factory=list) cur: List = field(default_factory=list) reminder: List = field(default_factory=list) + chunk_ordering: List = field(default_factory=list) + + def __init__(self, chunk_ordering=None): + if chunk_ordering is not None: + self.chunk_ordering = chunk_ordering def all_messages(self): - return ( - self.system - + self.examples - + self.readonly_files - + self.chat_files - + self.repo - + self.done - + self.cur - + self.reminder - ) + if self.chunk_ordering: + messages = [] + for chunk_name in self.chunk_ordering: + chunk = getattr(self, chunk_name, []) + if chunk: + messages.extend(chunk) + return messages + else: + return ( + self.system + + self.examples + + self.readonly_files + + self.chat_files + + self.repo + + self.done + + self.cur + + self.reminder + ) def add_cache_control_headers(self): if self.examples: diff --git a/aider/exceptions.py b/aider/exceptions.py index 5fb84d992c6..a151f504150 100644 --- a/aider/exceptions.py +++ b/aider/exceptions.py @@ -20,7 +20,7 @@ class ExInfo: "The API provider is not able to authenticate you. Check your API key.", ), ExInfo("AzureOpenAIError", True, None), - ExInfo("BadGatewayError", False, None), + ExInfo("BadGatewayError", True, None), ExInfo("BadRequestError", False, None), ExInfo("BudgetExceededError", True, None), ExInfo( diff --git a/aider/versioncheck.py b/aider/versioncheck.py index 7c0a73a2f42..68aac2b28a6 100644 --- a/aider/versioncheck.py +++ b/aider/versioncheck.py @@ -55,7 +55,7 @@ async def install_upgrade(io, latest_version=None): ) if success: - io.tool_output("Re-run aider to use new version.") + io.tool_output("Re-run aider-ce to use new version.") sys.exit() return diff --git a/aider/website/docs/config/agent-mode.md b/aider/website/docs/config/agent-mode.md index 56991db409d..ca9f0d8d039 100644 --- a/aider/website/docs/config/agent-mode.md +++ b/aider/website/docs/config/agent-mode.md @@ -154,6 +154,8 @@ Agent Mode can be configured using the `--agent-config` command line argument, w - **`skip_cli_confirmations`**: YOLO mode, be brave and let the LLM cook, can also use the option `yolo` (default: False) - **`tools_includelist`**: Array of tool names to allow (only these tools will be available) - **`tools_excludelist`**: Array of tool names to exclude (these tools will be disabled) +- **`include_context_blocks`**: Array of context block names to include (overrides default set) +- **`exclude_context_blocks`**: Array of context block names to exclude from default set #### Essential Tools @@ -164,6 +166,18 @@ Certain tools are always available regardless of includelist/excludelist setting - `view` - View files - `finished` - Complete the task +#### Context Blocks + +The following context blocks are available by default and can be customized using `include_context_blocks` and `exclude_context_blocks`: + +- **`context_summary`**: Shows current context usage and token limits +- **`directory_structure`**: Displays the project's file structure +- **`git_status`**: Shows current git branch, status, and recent commits +- **`symbol_outline`**: Lists classes, functions, and methods in current context +- **`todo_list`**: Shows the current todo list managed via `UpdateTodoList` tool + +When `include_context_blocks` is specified, only the listed blocks will be included. When `exclude_context_blocks` is specified, the listed blocks will be removed from the default set. + #### Other Aider-CE CLI/Config Options for Agent Mode - `preserve-todo-list` - Preserve todo list across sessions @@ -187,8 +201,14 @@ aider-ce --agent --agent-config '{"tools_excludelist": ["command", "commandinter # Custom large file threshold aider-ce --agent --agent-config '{"large_file_token_threshold": 10000}' +# Custom context blocks configuration +aider-ce --agent --agent-config '{"include_context_blocks": ["directory_structure", "git_status"]}' + +# Exclude specific context blocks +aider-ce --agent --agent-config '{"exclude_context_blocks": ["symbol_outline", "todo_list"]}' + # Combined configuration -aider-ce --agent --agent-config '{"large_file_token_threshold": 10000, "tools_includelist": ["view", "makeeditable", "replacetext", "finished", "gitdiff"]}' +aider-ce --agent --agent-config '{"large_file_token_threshold": 10000, "tools_includelist": ["view", "makeeditable", "replacetext", "finished", "gitdiff"], "include_context_blocks": ["directory_structure", "git_status"]}' # Command Line Options aider-ce --agent --agent-config '{"large_file_token_threshold": 10000, "tools_includelist": ["view", "makeeditable", "replacetext", "finished", "gitdiff"]}' --preserve-todo-list --use-enhanced-map @@ -204,5 +224,4 @@ This configuration system allows for fine-grained control over which tools are a - **Scalable exploration**: Can handle large codebases through strategic context management - **Recovery mechanisms**: Built-in undo and safety features -Agent Mode represents a significant evolution in aider's capabilities, enabling more sophisticated and autonomous codebase manipulation while maintaining safety and control through the tool-based architecture. - +Agent Mode represents a significant evolution in aider's capabilities, enabling more sophisticated and autonomous codebase manipulation while maintaining safety and control through the tool-based architecture. \ No newline at end of file diff --git a/tests/basic/test_exceptions.py b/tests/basic/test_exceptions.py index 5f9c095f8b6..6025a9cec53 100644 --- a/tests/basic/test_exceptions.py +++ b/tests/basic/test_exceptions.py @@ -53,6 +53,17 @@ def test_rate_limit_error(): assert "rate limited" in ex_info.description.lower() +def test_bad_gateway_error(): + """Test specific handling of BadGatewayError""" + ex = LiteLLMExceptions() + from litellm import BadGatewayError + + bad_gateway_error = BadGatewayError(message="Bad Gateway", llm_provider="openai", model="gpt-4") + ex_info = ex.get_ex_info(bad_gateway_error) + assert ex_info.retry is True + assert ex_info.name == "BadGatewayError" + + def test_context_window_error(): """Test specific handling of ContextWindowExceededError""" ex = LiteLLMExceptions() diff --git a/tests/basic/test_models.py b/tests/basic/test_models.py index 144cd1f3227..a0a576130a0 100644 --- a/tests/basic/test_models.py +++ b/tests/basic/test_models.py @@ -1,604 +1,614 @@ -import unittest -from unittest.mock import ANY, MagicMock, patch - -from aider.models import ( - ANTHROPIC_BETA_HEADER, - Model, - ModelInfoManager, - register_models, - sanity_check_model, - sanity_check_models, -) - - -class TestModels(unittest.TestCase): - def setUp(self): - """Reset MODEL_SETTINGS before each test""" - from aider.models import MODEL_SETTINGS - - self._original_settings = MODEL_SETTINGS.copy() - - def tearDown(self): - """Restore original MODEL_SETTINGS after each test""" - from aider.models import MODEL_SETTINGS - - MODEL_SETTINGS.clear() - MODEL_SETTINGS.extend(self._original_settings) - - def test_get_model_info_nonexistent(self): - manager = ModelInfoManager() - info = manager.get_model_info("non-existent-model") - self.assertEqual(info, {}) - - def test_max_context_tokens(self): - model = Model("gpt-3.5-turbo") - self.assertEqual(model.info["max_input_tokens"], 16385) - - model = Model("gpt-3.5-turbo-16k") - self.assertEqual(model.info["max_input_tokens"], 16385) - - model = Model("gpt-3.5-turbo-1106") - self.assertEqual(model.info["max_input_tokens"], 16385) - - model = Model("gpt-4") - self.assertEqual(model.info["max_input_tokens"], 8 * 1024) - - model = Model("gpt-4-32k") - self.assertEqual(model.info["max_input_tokens"], 32 * 1024) - - model = Model("gpt-4-0613") - self.assertEqual(model.info["max_input_tokens"], 8 * 1024) - - @patch("os.environ") - async def test_sanity_check_model_all_set(self, mock_environ): - mock_environ.get.return_value = "dummy_value" - mock_io = MagicMock() - model = MagicMock() - model.name = "test-model" - model.missing_keys = ["API_KEY1", "API_KEY2"] - model.keys_in_environment = True - model.info = {"some": "info"} - - await sanity_check_model(mock_io, model) - - mock_io.tool_output.assert_called() - calls = mock_io.tool_output.call_args_list - self.assertIn("- API_KEY1: Set", str(calls)) - self.assertIn("- API_KEY2: Set", str(calls)) - - @patch("os.environ") - async def test_sanity_check_model_not_set(self, mock_environ): - mock_environ.get.return_value = "" - mock_io = MagicMock() - model = MagicMock() - model.name = "test-model" - model.missing_keys = ["API_KEY1", "API_KEY2"] - model.keys_in_environment = True - model.info = {"some": "info"} - - await sanity_check_model(mock_io, model) - - mock_io.tool_output.assert_called() - calls = mock_io.tool_output.call_args_list - self.assertIn("- API_KEY1: Not set", str(calls)) - self.assertIn("- API_KEY2: Not set", str(calls)) - - async def test_sanity_check_models_bogus_editor(self): - mock_io = MagicMock() - main_model = Model("gpt-4") - main_model.editor_model = Model("bogus-model") - - result = await sanity_check_models(mock_io, main_model) - - self.assertTrue( - result - ) # Should return True because there's a problem with the editor model - mock_io.tool_warning.assert_called_with(ANY) # Ensure a warning was issued - - warning_messages = [ - warning_call.args[0] for warning_call in mock_io.tool_warning.call_args_list - ] - print("Warning messages:", warning_messages) # Add this line - - self.assertGreaterEqual(mock_io.tool_warning.call_count, 1) # Expect two warnings - self.assertTrue( - any("bogus-model" in msg for msg in warning_messages) - ) # Check that one of the warnings mentions the bogus model - - @patch("aider.models.check_for_dependencies") - async def test_sanity_check_model_calls_check_dependencies(self, mock_check_deps): - """Test that sanity_check_model calls check_for_dependencies""" - mock_io = MagicMock() - model = MagicMock() - model.name = "test-model" - model.missing_keys = [] - model.keys_in_environment = True - model.info = {"some": "info"} - - await sanity_check_model(mock_io, model) - - # Verify check_for_dependencies was called with the model name - mock_check_deps.assert_called_once_with(mock_io, "test-model") - - def test_model_aliases(self): - # Test common aliases - model = Model("4") - self.assertEqual(model.name, "gpt-4-0613") - - model = Model("4o") - self.assertEqual(model.name, "gpt-4o") - - model = Model("35turbo") - self.assertEqual(model.name, "gpt-3.5-turbo") - - model = Model("35-turbo") - self.assertEqual(model.name, "gpt-3.5-turbo") - - model = Model("3") - self.assertEqual(model.name, "gpt-3.5-turbo") - - model = Model("sonnet") - self.assertEqual(model.name, "anthropic/claude-sonnet-4-20250514") - - model = Model("haiku") - self.assertEqual(model.name, "claude-3-5-haiku-20241022") - - model = Model("opus") - self.assertEqual(model.name, "claude-opus-4-20250514") - - # Test non-alias passes through unchanged - model = Model("gpt-4") - self.assertEqual(model.name, "gpt-4") - - def test_o1_use_temp_false(self): - # Test GitHub Copilot models - model = Model("github/o1-mini") - self.assertEqual(model.name, "github/o1-mini") - self.assertEqual(model.use_temperature, False) - - model = Model("github/o1-preview") - self.assertEqual(model.name, "github/o1-preview") - self.assertEqual(model.use_temperature, False) - - def test_parse_token_value(self): - # Create a model instance to test the parse_token_value method - model = Model("gpt-4") - - # Test integer inputs - self.assertEqual(model.parse_token_value(8096), 8096) - self.assertEqual(model.parse_token_value(1000), 1000) - - # Test string inputs - self.assertEqual(model.parse_token_value("8096"), 8096) - - # Test k/K suffix (kilobytes) - self.assertEqual(model.parse_token_value("8k"), 8 * 1024) - self.assertEqual(model.parse_token_value("8K"), 8 * 1024) - self.assertEqual(model.parse_token_value("10.5k"), 10.5 * 1024) - self.assertEqual(model.parse_token_value("0.5K"), 0.5 * 1024) - - # Test m/M suffix (megabytes) - self.assertEqual(model.parse_token_value("1m"), 1 * 1024 * 1024) - self.assertEqual(model.parse_token_value("1M"), 1 * 1024 * 1024) - self.assertEqual(model.parse_token_value("0.5M"), 0.5 * 1024 * 1024) - - # Test with spaces - self.assertEqual(model.parse_token_value(" 8k "), 8 * 1024) - - # Test conversion from other types - self.assertEqual(model.parse_token_value(8.0), 8) - - def test_set_thinking_tokens(self): - # Test that set_thinking_tokens correctly sets the tokens with different formats - model = Model("gpt-4") - - # Test with integer - model.set_thinking_tokens(8096) - self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 8096) - self.assertFalse(model.use_temperature) - - # Test with string - model.set_thinking_tokens("10k") - self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 10 * 1024) - - # Test with decimal value - model.set_thinking_tokens("0.5M") - self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 0.5 * 1024 * 1024) - - @patch("aider.models.check_pip_install_extra") - async def test_check_for_dependencies_bedrock(self, mock_check_pip): - """Test that check_for_dependencies calls check_pip_install_extra for Bedrock models""" - from aider.io import InputOutput - - io = InputOutput() - - # Test with a Bedrock model - from aider.models import check_for_dependencies - - await check_for_dependencies(io, "bedrock/anthropic.claude-3-sonnet-20240229-v1:0") - - # Verify check_pip_install_extra was called with correct arguments - mock_check_pip.assert_called_once_with( - io, "boto3", "AWS Bedrock models require the boto3 package.", ["boto3"] - ) - - @patch("aider.models.check_pip_install_extra") - async def test_check_for_dependencies_vertex_ai(self, mock_check_pip): - """Test that check_for_dependencies calls check_pip_install_extra for Vertex AI models""" - from aider.io import InputOutput - - io = InputOutput() - - # Test with a Vertex AI model - from aider.models import check_for_dependencies - - await check_for_dependencies(io, "vertex_ai/gemini-1.5-pro") - - # Verify check_pip_install_extra was called with correct arguments - mock_check_pip.assert_called_once_with( - io, - "google.cloud.aiplatform", - "Google Vertex AI models require the google-cloud-aiplatform package.", - ["google-cloud-aiplatform"], - ) - - @patch("aider.models.check_pip_install_extra") - async def test_check_for_dependencies_other_model(self, mock_check_pip): - """Test that check_for_dependencies doesn't call check_pip_install_extra for other models""" - from aider.io import InputOutput - - io = InputOutput() - - # Test with a non-Bedrock, non-Vertex AI model - from aider.models import check_for_dependencies - - await check_for_dependencies(io, "gpt-4") - - # Verify check_pip_install_extra was not called - mock_check_pip.assert_not_called() - - def test_get_repo_map_tokens(self): - # Test default case (no max_input_tokens in info) - model = Model("gpt-4") - model.info = {} - self.assertEqual(model.get_repo_map_tokens(), 1024) - - # Test minimum boundary (max_input_tokens < 8192) - model.info = {"max_input_tokens": 4096} - self.assertEqual(model.get_repo_map_tokens(), 1024) - - # Test middle range (max_input_tokens = 16384) - model.info = {"max_input_tokens": 16384} - self.assertEqual(model.get_repo_map_tokens(), 2048) - - # Test maximum boundary (max_input_tokens > 32768) - model.info = {"max_input_tokens": 65536} - self.assertEqual(model.get_repo_map_tokens(), 4096) - - # Test exact boundary values - model.info = {"max_input_tokens": 8192} - self.assertEqual(model.get_repo_map_tokens(), 1024) - - model.info = {"max_input_tokens": 32768} - self.assertEqual(model.get_repo_map_tokens(), 4096) - - def test_configure_model_settings(self): - # Test o3-mini case - model = Model("something/o3-mini") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertFalse(model.use_temperature) - - # Test o1-mini case - model = Model("something/o1-mini") - self.assertTrue(model.use_repo_map) - self.assertFalse(model.use_temperature) - self.assertFalse(model.use_system_prompt) - - # Test o1-preview case - model = Model("something/o1-preview") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertFalse(model.use_temperature) - self.assertFalse(model.use_system_prompt) - - # Test o1 case - model = Model("something/o1") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertFalse(model.use_temperature) - self.assertFalse(model.streaming) - - # Test deepseek v3 case - model = Model("deepseek-v3") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertEqual(model.reminder, "sys") - self.assertTrue(model.examples_as_sys_msg) - - # Test deepseek reasoner case - model = Model("deepseek-r1") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertTrue(model.examples_as_sys_msg) - self.assertFalse(model.use_temperature) - self.assertEqual(model.reasoning_tag, "think") - - # Test provider/deepseek-r1 case - model = Model("someprovider/deepseek-r1") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertTrue(model.examples_as_sys_msg) - self.assertFalse(model.use_temperature) - self.assertEqual(model.reasoning_tag, "think") - - # Test provider/deepseek-v3 case - model = Model("anotherprovider/deepseek-v3") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertEqual(model.reminder, "sys") - self.assertTrue(model.examples_as_sys_msg) - - # Test llama3 70b case - model = Model("llama3-70b") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertTrue(model.send_undo_reply) - self.assertTrue(model.examples_as_sys_msg) - - # Test gpt-4 case - model = Model("gpt-4") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertTrue(model.send_undo_reply) - - # Test gpt-3.5 case - model = Model("gpt-3.5") - self.assertEqual(model.reminder, "sys") - - # Test 3.5-sonnet case - model = Model("claude-3.5-sonnet") - self.assertEqual(model.edit_format, "diff") - self.assertTrue(model.use_repo_map) - self.assertTrue(model.examples_as_sys_msg) - self.assertEqual(model.reminder, "user") - - # Test o1- prefix case - model = Model("o1-something") - self.assertFalse(model.use_system_prompt) - self.assertFalse(model.use_temperature) - - # Test qwen case - model = Model("qwen-coder-2.5-32b") - self.assertEqual(model.edit_format, "diff") - self.assertEqual(model.editor_edit_format, "editor-diff") - self.assertTrue(model.use_repo_map) - - def test_aider_extra_model_settings(self): - import tempfile - - import yaml - - # Create temporary YAML file with test settings - test_settings = [ - { - "name": "aider/extra_params", - "extra_params": { - "extra_headers": {"Foo": "bar"}, - "some_param": "some value", - }, - }, - ] - - # Write to a regular file instead of NamedTemporaryFile - # for better cross-platform compatibility - tmp = tempfile.mktemp(suffix=".yml") - try: - with open(tmp, "w") as f: - yaml.dump(test_settings, f) - - # Register the test settings - register_models([tmp]) - - # Test that defaults are applied when no exact match - model = Model("claude-3-5-sonnet-20240620") - # Test that both the override and existing headers are present - model = Model("claude-3-5-sonnet-20240620") - self.assertEqual(model.extra_params["extra_headers"]["Foo"], "bar") - self.assertEqual( - model.extra_params["extra_headers"]["anthropic-beta"], - ANTHROPIC_BETA_HEADER, - ) - self.assertEqual(model.extra_params["some_param"], "some value") - self.assertEqual(model.extra_params["max_tokens"], 8192) - - # Test that exact match overrides defaults but not overrides - model = Model("gpt-4") - self.assertEqual(model.extra_params["extra_headers"]["Foo"], "bar") - self.assertEqual(model.extra_params["some_param"], "some value") - finally: - # Clean up the temporary file - import os - - try: - os.unlink(tmp) - except OSError: - pass - - @patch("aider.models.litellm.acompletion") - @patch.object(Model, "token_count") - async def test_ollama_num_ctx_set_when_missing(self, mock_token_count, mock_completion): - mock_token_count.return_value = 1000 - - model = Model("ollama/llama3") - model.extra_params = {} - messages = [{"role": "user", "content": "Hello"}] - - await model.send_completion(messages, functions=None, stream=False) - - # Verify num_ctx was calculated and added to call - expected_ctx = int(1000 * 1.25) + 8192 # 9442 - mock_completion.assert_called_once_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - num_ctx=expected_ctx, - timeout=600, - ) - - @patch("aider.models.litellm.acompletion") - async def test_modern_tool_call_propagation(self, mock_completion): - # Test modern tool calling (used for MCP Server Tool Calls) - model = Model("gpt-4") - messages = [{"role": "user", "content": "Hello"}] - - await model.send_completion( - messages, functions=None, stream=False, tools=[dict(type="function", function="test")] - ) - - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - tools=[dict(type="function", function="test")], - temperature=0, - timeout=600, - ) - - @patch("aider.models.litellm.acompletion") - async def test_legacy_tool_call_propagation(self, mock_completion): - # Test modern tool calling (used for legacy server tool calling) - model = Model("gpt-4") - messages = [{"role": "user", "content": "Hello"}] - - await model.send_completion(messages, functions=["test"], stream=False) - - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - tools=[dict(type="function", function="test")], - temperature=0, - timeout=600, - ) - - @patch("aider.models.litellm.acompletion") - async def test_ollama_uses_existing_num_ctx(self, mock_completion): - model = Model("ollama/llama3") - model.extra_params = {"num_ctx": 4096} - - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - - # Should use provided num_ctx from extra_params - mock_completion.assert_called_once_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - num_ctx=4096, - timeout=600, - ) - - @patch("aider.models.litellm.acompletion") - async def test_non_ollama_no_num_ctx(self, mock_completion): - model = Model("gpt-4") - model.extra_params = {} - messages = [{"role": "user", "content": "Hello"}] - - await model.send_completion(messages, functions=None, stream=False) - - # Regular models shouldn't get num_ctx - mock_completion.assert_called_once_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - timeout=600, - ) - self.assertNotIn("num_ctx", mock_completion.call_args.kwargs) - - def test_use_temperature_settings(self): - # Test use_temperature=True (default) uses temperature=0 - model = Model("gpt-4") - self.assertTrue(model.use_temperature) - self.assertEqual(model.use_temperature, True) - - # Test use_temperature=False doesn't pass temperature - model = Model("github/o1-mini") - self.assertFalse(model.use_temperature) - - # Test use_temperature as float value - model = Model("gpt-4") - model.use_temperature = 0.7 - self.assertEqual(model.use_temperature, 0.7) - - @patch("aider.models.litellm.acompletion") - async def test_request_timeout_default(self, mock_completion): - # Test default timeout is used when not specified in extra_params - model = Model("gpt-4") - model.extra_params = {} - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - timeout=600, # Default timeout - ) - - @patch("aider.models.litellm.acompletion") - async def test_request_timeout_from_extra_params(self, mock_completion): - # Test timeout from extra_params overrides default - model = Model("gpt-4") - model.extra_params = {"timeout": 300} # 5 minutes - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - timeout=300, # From extra_params - ) - - @patch("aider.models.litellm.acompletion") - async def test_use_temperature_in_send_completion(self, mock_completion): - # Test use_temperature=True sends temperature=0 - model = Model("gpt-4") - model.extra_params = {} - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - temperature=0, - timeout=600, - ) - - # Test use_temperature=False doesn't send temperature - model = Model("github/o1-mini") - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - self.assertNotIn("temperature", mock_completion.call_args.kwargs) - - # Test use_temperature as float sends that value - model = Model("gpt-4") - model.extra_params = {} - model.use_temperature = 0.7 - messages = [{"role": "user", "content": "Hello"}] - await model.send_completion(messages, functions=None, stream=False) - mock_completion.assert_called_with( - model=model.name, - messages=messages, - stream=False, - temperature=0.7, - timeout=600, - ) - - -if __name__ == "__main__": - unittest.main() +import unittest +from unittest.mock import ANY, MagicMock, patch + +from aider.models import ( + ANTHROPIC_BETA_HEADER, + Model, + ModelInfoManager, + register_models, + sanity_check_model, + sanity_check_models, +) + + +class TestModels(unittest.TestCase): + def setUp(self): + """Reset MODEL_SETTINGS before each test""" + from aider.models import MODEL_SETTINGS + + self._original_settings = MODEL_SETTINGS.copy() + + def tearDown(self): + """Restore original MODEL_SETTINGS after each test""" + from aider.models import MODEL_SETTINGS + + MODEL_SETTINGS.clear() + MODEL_SETTINGS.extend(self._original_settings) + + def test_get_model_info_nonexistent(self): + manager = ModelInfoManager() + info = manager.get_model_info("non-existent-model") + self.assertEqual(info, {}) + + def test_max_context_tokens(self): + model = Model("gpt-3.5-turbo") + self.assertEqual(model.info["max_input_tokens"], 16385) + + model = Model("gpt-3.5-turbo-16k") + self.assertEqual(model.info["max_input_tokens"], 16385) + + model = Model("gpt-3.5-turbo-1106") + self.assertEqual(model.info["max_input_tokens"], 16385) + + model = Model("gpt-4") + self.assertEqual(model.info["max_input_tokens"], 8 * 1024) + + model = Model("gpt-4-32k") + self.assertEqual(model.info["max_input_tokens"], 32 * 1024) + + model = Model("gpt-4-0613") + self.assertEqual(model.info["max_input_tokens"], 8 * 1024) + + @patch("os.environ") + async def test_sanity_check_model_all_set(self, mock_environ): + mock_environ.get.return_value = "dummy_value" + mock_io = MagicMock() + model = MagicMock() + model.name = "test-model" + model.missing_keys = ["API_KEY1", "API_KEY2"] + model.keys_in_environment = True + model.info = {"some": "info"} + + await sanity_check_model(mock_io, model) + + mock_io.tool_output.assert_called() + calls = mock_io.tool_output.call_args_list + self.assertIn("- API_KEY1: Set", str(calls)) + self.assertIn("- API_KEY2: Set", str(calls)) + + @patch("os.environ") + async def test_sanity_check_model_not_set(self, mock_environ): + mock_environ.get.return_value = "" + mock_io = MagicMock() + model = MagicMock() + model.name = "test-model" + model.missing_keys = ["API_KEY1", "API_KEY2"] + model.keys_in_environment = True + model.info = {"some": "info"} + + await sanity_check_model(mock_io, model) + + mock_io.tool_output.assert_called() + calls = mock_io.tool_output.call_args_list + self.assertIn("- API_KEY1: Not set", str(calls)) + self.assertIn("- API_KEY2: Not set", str(calls)) + + async def test_sanity_check_models_bogus_editor(self): + mock_io = MagicMock() + main_model = Model("gpt-4") + main_model.editor_model = Model("bogus-model") + + result = await sanity_check_models(mock_io, main_model) + + self.assertTrue( + result + ) # Should return True because there's a problem with the editor model + mock_io.tool_warning.assert_called_with(ANY) # Ensure a warning was issued + + warning_messages = [ + warning_call.args[0] for warning_call in mock_io.tool_warning.call_args_list + ] + print("Warning messages:", warning_messages) # Add this line + + self.assertGreaterEqual(mock_io.tool_warning.call_count, 1) # Expect two warnings + self.assertTrue( + any("bogus-model" in msg for msg in warning_messages) + ) # Check that one of the warnings mentions the bogus model + + @patch("aider.models.check_for_dependencies") + async def test_sanity_check_model_calls_check_dependencies(self, mock_check_deps): + """Test that sanity_check_model calls check_for_dependencies""" + mock_io = MagicMock() + model = MagicMock() + model.name = "test-model" + model.missing_keys = [] + model.keys_in_environment = True + model.info = {"some": "info"} + + await sanity_check_model(mock_io, model) + + # Verify check_for_dependencies was called with the model name + mock_check_deps.assert_called_once_with(mock_io, "test-model") + + def test_model_aliases(self): + # Test common aliases + model = Model("4") + self.assertEqual(model.name, "gpt-4-0613") + + model = Model("4o") + self.assertEqual(model.name, "gpt-4o") + + model = Model("35turbo") + self.assertEqual(model.name, "gpt-3.5-turbo") + + model = Model("35-turbo") + self.assertEqual(model.name, "gpt-3.5-turbo") + + model = Model("3") + self.assertEqual(model.name, "gpt-3.5-turbo") + + model = Model("sonnet") + self.assertEqual(model.name, "anthropic/claude-sonnet-4-20250514") + + model = Model("haiku") + self.assertEqual(model.name, "claude-3-5-haiku-20241022") + + model = Model("opus") + self.assertEqual(model.name, "claude-opus-4-20250514") + + # Test non-alias passes through unchanged + model = Model("gpt-4") + self.assertEqual(model.name, "gpt-4") + + def test_o1_use_temp_false(self): + # Test GitHub Copilot models + model = Model("github/o1-mini") + self.assertEqual(model.name, "github/o1-mini") + self.assertEqual(model.use_temperature, False) + + model = Model("github/o1-preview") + self.assertEqual(model.name, "github/o1-preview") + self.assertEqual(model.use_temperature, False) + + def test_parse_token_value(self): + # Create a model instance to test the parse_token_value method + model = Model("gpt-4") + + # Test integer inputs + self.assertEqual(model.parse_token_value(8096), 8096) + self.assertEqual(model.parse_token_value(1000), 1000) + + # Test string inputs + self.assertEqual(model.parse_token_value("8096"), 8096) + + # Test k/K suffix (kilobytes) + self.assertEqual(model.parse_token_value("8k"), 8 * 1024) + self.assertEqual(model.parse_token_value("8K"), 8 * 1024) + self.assertEqual(model.parse_token_value("10.5k"), 10.5 * 1024) + self.assertEqual(model.parse_token_value("0.5K"), 0.5 * 1024) + + # Test m/M suffix (megabytes) + self.assertEqual(model.parse_token_value("1m"), 1 * 1024 * 1024) + self.assertEqual(model.parse_token_value("1M"), 1 * 1024 * 1024) + self.assertEqual(model.parse_token_value("0.5M"), 0.5 * 1024 * 1024) + + # Test with spaces + self.assertEqual(model.parse_token_value(" 8k "), 8 * 1024) + + # Test conversion from other types + self.assertEqual(model.parse_token_value(8.0), 8) + + def test_set_thinking_tokens(self): + # Test that set_thinking_tokens correctly sets the tokens with different formats + model = Model("gpt-4") + + # Test with integer + model.set_thinking_tokens(8096) + self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 8096) + self.assertFalse(model.use_temperature) + + # Test with string + model.set_thinking_tokens("10k") + self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 10 * 1024) + + # Test with decimal value + model.set_thinking_tokens("0.5M") + self.assertEqual(model.extra_params["thinking"]["budget_tokens"], 0.5 * 1024 * 1024) + + @patch("aider.models.check_pip_install_extra") + async def test_check_for_dependencies_bedrock(self, mock_check_pip): + """Test that check_for_dependencies calls check_pip_install_extra for Bedrock models""" + from aider.io import InputOutput + + io = InputOutput() + + # Test with a Bedrock model + from aider.models import check_for_dependencies + + await check_for_dependencies(io, "bedrock/anthropic.claude-3-sonnet-20240229-v1:0") + + # Verify check_pip_install_extra was called with correct arguments + mock_check_pip.assert_called_once_with( + io, "boto3", "AWS Bedrock models require the boto3 package.", ["boto3"] + ) + + @patch("aider.models.check_pip_install_extra") + async def test_check_for_dependencies_vertex_ai(self, mock_check_pip): + """Test that check_for_dependencies calls check_pip_install_extra for Vertex AI models""" + from aider.io import InputOutput + + io = InputOutput() + + # Test with a Vertex AI model + from aider.models import check_for_dependencies + + await check_for_dependencies(io, "vertex_ai/gemini-1.5-pro") + + # Verify check_pip_install_extra was called with correct arguments + mock_check_pip.assert_called_once_with( + io, + "google.cloud.aiplatform", + "Google Vertex AI models require the google-cloud-aiplatform package.", + ["google-cloud-aiplatform"], + ) + + @patch("aider.models.check_pip_install_extra") + async def test_check_for_dependencies_other_model(self, mock_check_pip): + """Test that check_for_dependencies doesn't call check_pip_install_extra for other models""" + from aider.io import InputOutput + + io = InputOutput() + + # Test with a non-Bedrock, non-Vertex AI model + from aider.models import check_for_dependencies + + await check_for_dependencies(io, "gpt-4") + + # Verify check_pip_install_extra was not called + mock_check_pip.assert_not_called() + + def test_get_repo_map_tokens(self): + # Test default case (no max_input_tokens in info) + model = Model("gpt-4") + model.info = {} + self.assertEqual(model.get_repo_map_tokens(), 1024) + + # Test minimum boundary (max_input_tokens < 8192) + model.info = {"max_input_tokens": 4096} + self.assertEqual(model.get_repo_map_tokens(), 1024) + + # Test middle range (max_input_tokens = 16384) + model.info = {"max_input_tokens": 16384} + self.assertEqual(model.get_repo_map_tokens(), 2048) + + # Test maximum boundary (max_input_tokens > 32768) + model.info = {"max_input_tokens": 65536} + self.assertEqual(model.get_repo_map_tokens(), 4096) + + # Test exact boundary values + model.info = {"max_input_tokens": 8192} + self.assertEqual(model.get_repo_map_tokens(), 1024) + + model.info = {"max_input_tokens": 32768} + self.assertEqual(model.get_repo_map_tokens(), 4096) + + def test_configure_model_settings(self): + # Test o3-mini case + model = Model("something/o3-mini") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertFalse(model.use_temperature) + + # Test o1-mini case + model = Model("something/o1-mini") + self.assertTrue(model.use_repo_map) + self.assertFalse(model.use_temperature) + self.assertFalse(model.use_system_prompt) + + # Test o1-preview case + model = Model("something/o1-preview") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertFalse(model.use_temperature) + self.assertFalse(model.use_system_prompt) + + # Test o1 case + model = Model("something/o1") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertFalse(model.use_temperature) + self.assertFalse(model.streaming) + + # Test deepseek v3 case + model = Model("deepseek-v3") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertEqual(model.reminder, "sys") + self.assertTrue(model.examples_as_sys_msg) + + # Test deepseek reasoner case + model = Model("deepseek-r1") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertTrue(model.examples_as_sys_msg) + self.assertFalse(model.use_temperature) + self.assertEqual(model.reasoning_tag, "think") + + # Test provider/deepseek-r1 case + model = Model("someprovider/deepseek-r1") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertTrue(model.examples_as_sys_msg) + self.assertFalse(model.use_temperature) + self.assertEqual(model.reasoning_tag, "think") + + # Test provider/deepseek-v3 case + model = Model("anotherprovider/deepseek-v3") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertEqual(model.reminder, "sys") + self.assertTrue(model.examples_as_sys_msg) + + # Test llama3 70b case + model = Model("llama3-70b") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertTrue(model.send_undo_reply) + self.assertTrue(model.examples_as_sys_msg) + + # Test gpt-4 case + model = Model("gpt-4") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertTrue(model.send_undo_reply) + + # Test gpt-3.5 case + model = Model("gpt-3.5") + self.assertEqual(model.reminder, "sys") + + # Test 3.5-sonnet case + model = Model("claude-3.5-sonnet") + self.assertEqual(model.edit_format, "diff") + self.assertTrue(model.use_repo_map) + self.assertTrue(model.examples_as_sys_msg) + self.assertEqual(model.reminder, "user") + + # Test o1- prefix case + model = Model("o1-something") + self.assertFalse(model.use_system_prompt) + self.assertFalse(model.use_temperature) + + # Test qwen case + model = Model("qwen-coder-2.5-32b") + self.assertEqual(model.edit_format, "diff") + self.assertEqual(model.editor_edit_format, "editor-diff") + self.assertTrue(model.use_repo_map) + + def test_aider_extra_model_settings(self): + import tempfile + + import yaml + + # Create temporary YAML file with test settings + test_settings = [ + { + "name": "aider/extra_params", + "extra_params": { + "extra_headers": {"Foo": "bar"}, + "some_param": "some value", + }, + }, + ] + + # Write to a regular file instead of NamedTemporaryFile + # for better cross-platform compatibility + tmp = tempfile.mktemp(suffix=".yml") + try: + with open(tmp, "w") as f: + yaml.dump(test_settings, f) + + # Register the test settings + register_models([tmp]) + + # Test that defaults are applied when no exact match + model = Model("claude-3-5-sonnet-20240620") + # Test that both the override and existing headers are present + model = Model("claude-3-5-sonnet-20240620") + self.assertEqual(model.extra_params["extra_headers"]["Foo"], "bar") + self.assertEqual( + model.extra_params["extra_headers"]["anthropic-beta"], + ANTHROPIC_BETA_HEADER, + ) + self.assertEqual(model.extra_params["some_param"], "some value") + self.assertEqual(model.extra_params["max_tokens"], 8192) + + # Test that exact match overrides defaults but not overrides + model = Model("gpt-4") + self.assertEqual(model.extra_params["extra_headers"]["Foo"], "bar") + self.assertEqual(model.extra_params["some_param"], "some value") + finally: + # Clean up the temporary file + import os + + try: + os.unlink(tmp) + except OSError: + pass + + @patch("aider.models.litellm.acompletion") + @patch.object(Model, "token_count") + async def test_ollama_num_ctx_set_when_missing(self, mock_token_count, mock_completion): + mock_token_count.return_value = 1000 + + model = Model("ollama/llama3") + model.extra_params = {} + messages = [{"role": "user", "content": "Hello"}] + + await model.send_completion(messages, functions=None, stream=False) + + # Verify num_ctx was calculated and added to call + expected_ctx = int(1000 * 1.25) + 8192 # 9442 + mock_completion.assert_called_once_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + num_ctx=expected_ctx, + timeout=600, + cache_control_injection_points=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_modern_tool_call_propagation(self, mock_completion): + # Test modern tool calling (used for MCP Server Tool Calls) + model = Model("gpt-4") + messages = [{"role": "user", "content": "Hello"}] + + await model.send_completion( + messages, functions=None, stream=False, tools=[dict(type="function", function="test")] + ) + + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + tools=[dict(type="function", function="test")], + temperature=0, + timeout=600, + cache_control_injection_points=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_legacy_tool_call_propagation(self, mock_completion): + # Test modern tool calling (used for legacy server tool calling) + model = Model("gpt-4") + messages = [{"role": "user", "content": "Hello"}] + + await model.send_completion(messages, functions=["test"], stream=False) + + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + tools=[dict(type="function", function="test")], + temperature=0, + timeout=600, + cache_control_injection_points=ANY, + tool_choice=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_ollama_uses_existing_num_ctx(self, mock_completion): + model = Model("ollama/llama3") + model.extra_params = {"num_ctx": 4096} + + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + + # Should use provided num_ctx from extra_params + mock_completion.assert_called_once_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + num_ctx=4096, + timeout=600, + cache_control_injection_points=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_non_ollama_no_num_ctx(self, mock_completion): + model = Model("gpt-4") + model.extra_params = {} + messages = [{"role": "user", "content": "Hello"}] + + await model.send_completion(messages, functions=None, stream=False) + + # Regular models shouldn't get num_ctx + mock_completion.assert_called_once_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + timeout=600, + cache_control_injection_points=ANY, + ) + self.assertNotIn("num_ctx", mock_completion.call_args.kwargs) + + def test_use_temperature_settings(self): + # Test use_temperature=True (default) uses temperature=0 + model = Model("gpt-4") + self.assertTrue(model.use_temperature) + self.assertEqual(model.use_temperature, True) + + # Test use_temperature=False doesn't pass temperature + model = Model("github/o1-mini") + self.assertFalse(model.use_temperature) + + # Test use_temperature as float value + model = Model("gpt-4") + model.use_temperature = 0.7 + self.assertEqual(model.use_temperature, 0.7) + + @patch("aider.models.litellm.acompletion") + async def test_request_timeout_default(self, mock_completion): + # Test default timeout is used when not specified in extra_params + model = Model("gpt-4") + model.extra_params = {} + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + timeout=600, # Default timeout + cache_control_injection_points=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_request_timeout_from_extra_params(self, mock_completion): + # Test timeout from extra_params overrides default + model = Model("gpt-4") + model.extra_params = {"timeout": 300} # 5 minutes + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + timeout=300, # From extra_params + cache_control_injection_points=ANY, + ) + + @patch("aider.models.litellm.acompletion") + async def test_use_temperature_in_send_completion(self, mock_completion): + # Test use_temperature=True sends temperature=0 + model = Model("gpt-4") + model.extra_params = {} + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0, + timeout=600, + cache_control_injection_points=ANY, + ) + + # Test use_temperature=False doesn't send temperature + model = Model("github/o1-mini") + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + self.assertNotIn("temperature", mock_completion.call_args.kwargs) + + # Test use_temperature as float sends that value + model = Model("gpt-4") + model.extra_params = {} + model.use_temperature = 0.7 + messages = [{"role": "user", "content": "Hello"}] + await model.send_completion(messages, functions=None, stream=False) + mock_completion.assert_called_with( + model=model.name, + messages=ANY, + stream=False, + temperature=0.7, + timeout=600, + cache_control_injection_points=ANY, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/chat-history-search-replace-gold.txt b/tests/fixtures/chat-history-search-replace-gold.txt index de28f77fac2..9947ab3800b 100644 --- a/tests/fixtures/chat-history-search-replace-gold.txt +++ b/tests/fixtures/chat-history-search-replace-gold.txt @@ -2204,7 +2204,7 @@ Newer aider version v{latest_version} is available. To upgrade, run: if io.confirm_ask("Run pip install?"): success, output = utils.run_install(cmd) if success: - io.tool_output("Re-run aider to use new version.") + io.tool_output("Re-run aider-ce to use new version.") sys.exit() else: io.tool_error(output) @@ -2286,7 +2286,7 @@ Newer aider version v{latest_version} is available. To upgrade, run: if io.confirm_ask("Run pip install?"): success, output = utils.run_install(cmd) if success: - io.tool_output("Re-run aider to use new version.") + io.tool_output("Re-run aider-ce to use new version.") sys.exit() else: io.tool_error(output) diff --git a/tests/fixtures/chat-history.md b/tests/fixtures/chat-history.md index fdf4fd8202f..ae7a11113aa 100644 --- a/tests/fixtures/chat-history.md +++ b/tests/fixtures/chat-history.md @@ -6465,7 +6465,7 @@ Newer aider version v{latest_version} is available. To upgrade, run: if io.confirm_ask("Run pip install?"): success, output = utils.run_install(cmd) if success: - io.tool_output("Re-run aider to use new version.") + io.tool_output("Re-run aider-ce to use new version.") sys.exit() else: io.tool_error(output) @@ -6547,7 +6547,7 @@ Newer aider version v{latest_version} is available. To upgrade, run: if io.confirm_ask("Run pip install?"): success, output = utils.run_install(cmd) if success: - io.tool_output("Re-run aider to use new version.") + io.tool_output("Re-run aider-ce to use new version.") sys.exit() else: io.tool_error(output)