diff --git a/.cursorrules b/.cursorrules index 1b81198..3df2128 100644 --- a/.cursorrules +++ b/.cursorrules @@ -17,16 +17,23 @@ Note all the tools are in python. So in the case you need to do batch processing You always have an LLM at your side to help you with the task. For simple tasks, you could invoke the LLM by running the following command: ``` -py310/bin/python ./tools/llm_api.py --prompt "What is the capital of France?" --provider "anthropic" +venv/bin/python ./tools/llm_api.py --prompt "What is the capital of France?" --provider "anthropic" ``` +The LLM API supports multiple providers: +- OpenAI (default, model: gpt-4o) +- DeepSeek (model: deepseek-chat) +- Anthropic (model: claude-3-sonnet-20240229) +- Gemini (model: gemini-pro) +- Local LLM (model: Qwen/Qwen2.5-32B-Instruct-AWQ) + But usually it's a better idea to check the content of the file and use the APIs in the `tools/llm_api.py` file to invoke the LLM if needed. ## Web browser You could use the `tools/web_scraper.py` file to scrape the web. ``` -py310/bin/python ./tools/web_scraper.py --max-concurrent 3 URL1 URL2 URL3 +venv/bin/python ./tools/web_scraper.py --max-concurrent 3 URL1 URL2 URL3 ``` This will output the content of the web pages. @@ -34,7 +41,7 @@ This will output the content of the web pages. You could use the `tools/search_engine.py` file to search the web. ``` -py310/bin/python ./tools/search_engine.py "your search keywords" +venv/bin/python ./tools/search_engine.py "your search keywords" ``` This will output the search results in the following format: ``` @@ -48,7 +55,7 @@ If needed, you can further use the `web_scraper.py` file to scrape the web page ## User Specified Lessons -- You have a python venv in ./py310. +- You have a python venv in ./venv. - Include info useful for debugging in the program output. - Read the file before you try to edit it. - Use LLM to perform flexible text understanding tasks. First test on a few files. After success, make it parallel. diff --git a/.gitignore b/.gitignore index 444cca9..bd36c75 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ yarn-error.log* # local env files .env*.local +.env +.env.example # vercel .vercel @@ -44,4 +46,7 @@ credentials.json **/__pycache__/ # python virtual environment -/py310/ +/venv/ + +# pytest +.pytest_cache/ diff --git a/tests/test_llm_api.py b/tests/test_llm_api.py index d51d7d1..60b1ed9 100644 --- a/tests/test_llm_api.py +++ b/tests/test_llm_api.py @@ -1,7 +1,10 @@ import unittest -from unittest.mock import patch, MagicMock -from tools.llm_api import create_llm_client, query_llm +from unittest.mock import patch, MagicMock, mock_open +from tools.llm_api import create_llm_client, query_llm, load_environment import os +import google.generativeai as genai +import io +import sys def is_llm_configured(): """Check if LLM is configured by trying to connect to the server""" @@ -16,69 +19,225 @@ def is_llm_configured(): skip_llm_tests = not is_llm_configured() skip_message = "Skipping LLM tests as LLM is not configured. This is normal if you haven't set up a local LLM server." +class TestEnvironmentLoading(unittest.TestCase): + def setUp(self): + # Save original environment + self.original_env = dict(os.environ) + # Clear environment variables we're testing + for key in ['TEST_VAR']: + if key in os.environ: + del os.environ[key] + + def tearDown(self): + # Restore original environment + os.environ.clear() + os.environ.update(self.original_env) + + @patch('pathlib.Path.exists') + @patch('tools.llm_api.load_dotenv') + @patch('builtins.open') + def test_environment_loading_precedence(self, mock_open, mock_load_dotenv, mock_exists): + # Mock all env files exist + mock_exists.return_value = True + + # Mock file contents + mock_file = MagicMock() + mock_file.__enter__.return_value = io.StringIO('TEST_VAR=value\n') + mock_open.return_value = mock_file + + # Mock different values for TEST_VAR in different files + def load_dotenv_side_effect(dotenv_path, **kwargs): + if '.env.local' in str(dotenv_path): + os.environ['TEST_VAR'] = 'local' + elif '.env' in str(dotenv_path): + if 'TEST_VAR' not in os.environ: # Only set if not already set + os.environ['TEST_VAR'] = 'default' + elif '.env.example' in str(dotenv_path): + if 'TEST_VAR' not in os.environ: # Only set if not already set + os.environ['TEST_VAR'] = 'example' + mock_load_dotenv.side_effect = load_dotenv_side_effect + + # Load environment + load_environment() + + # Verify precedence (.env.local should win) + self.assertEqual(os.environ.get('TEST_VAR'), 'local') + + # Verify order of loading + calls = mock_load_dotenv.call_args_list + self.assertEqual(len(calls), 3) + self.assertTrue(str(calls[0][1]['dotenv_path']).endswith('.env.local')) + self.assertTrue(str(calls[1][1]['dotenv_path']).endswith('.env')) + self.assertTrue(str(calls[2][1]['dotenv_path']).endswith('.env.example')) + + @patch('pathlib.Path.exists') + @patch('tools.llm_api.load_dotenv') + def test_environment_loading_no_files(self, mock_load_dotenv, mock_exists): + # Mock no env files exist + mock_exists.return_value = False + + # Load environment + load_environment() + + # Verify load_dotenv was not called + mock_load_dotenv.assert_not_called() + class TestLLMAPI(unittest.TestCase): def setUp(self): - # Create a mock OpenAI client - self.mock_client = MagicMock() - self.mock_response = MagicMock() - self.mock_choice = MagicMock() - self.mock_message = MagicMock() + # Create mock clients for different providers + self.mock_openai_client = MagicMock() + self.mock_anthropic_client = MagicMock() + self.mock_gemini_client = MagicMock() - # Set up the mock response structure - self.mock_message.content = "Test response" - self.mock_choice.message = self.mock_message - self.mock_response.choices = [self.mock_choice] + # Set up OpenAI-style response + self.mock_openai_response = MagicMock() + self.mock_openai_choice = MagicMock() + self.mock_openai_message = MagicMock() + self.mock_openai_message.content = "Test OpenAI response" + self.mock_openai_choice.message = self.mock_openai_message + self.mock_openai_response.choices = [self.mock_openai_choice] + self.mock_openai_client.chat.completions.create.return_value = self.mock_openai_response - # Set up the mock client's chat.completions.create method - self.mock_client.chat.completions.create.return_value = self.mock_response + # Set up Anthropic-style response + self.mock_anthropic_response = MagicMock() + self.mock_anthropic_content = MagicMock() + self.mock_anthropic_content.text = "Test Anthropic response" + self.mock_anthropic_response.content = [self.mock_anthropic_content] + self.mock_anthropic_client.messages.create.return_value = self.mock_anthropic_response + + # Set up Gemini-style response + self.mock_gemini_model = MagicMock() + self.mock_gemini_response = MagicMock() + self.mock_gemini_response.text = "Test Gemini response" + self.mock_gemini_model.generate_content.return_value = self.mock_gemini_response + self.mock_gemini_client.GenerativeModel.return_value = self.mock_gemini_model + + # Mock environment variables + self.env_patcher = patch.dict('os.environ', { + 'OPENAI_API_KEY': 'test-openai-key', + 'DEEPSEEK_API_KEY': 'test-deepseek-key', + 'ANTHROPIC_API_KEY': 'test-anthropic-key', + 'GOOGLE_API_KEY': 'test-google-key' + }) + self.env_patcher.start() + + def tearDown(self): + self.env_patcher.stop() @unittest.skipIf(skip_llm_tests, skip_message) @patch('tools.llm_api.OpenAI') - def test_create_llm_client(self, mock_openai): - # Test client creation with default provider (openai) - mock_openai.return_value = self.mock_client - client = create_llm_client() # 使用預設 provider - - # Verify OpenAI was called with correct parameters + def test_create_openai_client(self, mock_openai): + mock_openai.return_value = self.mock_openai_client + client = create_llm_client("openai") + mock_openai.assert_called_once_with(api_key='test-openai-key') + self.assertEqual(client, self.mock_openai_client) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.OpenAI') + def test_create_deepseek_client(self, mock_openai): + mock_openai.return_value = self.mock_openai_client + client = create_llm_client("deepseek") mock_openai.assert_called_once_with( - api_key=os.getenv('OPENAI_API_KEY') # 使用環境變數中的 API key + api_key='test-deepseek-key', + base_url="https://api.deepseek.com/v1" ) - - self.assertEqual(client, self.mock_client) + self.assertEqual(client, self.mock_openai_client) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.Anthropic') + def test_create_anthropic_client(self, mock_anthropic): + mock_anthropic.return_value = self.mock_anthropic_client + client = create_llm_client("anthropic") + mock_anthropic.assert_called_once_with(api_key='test-anthropic-key') + self.assertEqual(client, self.mock_anthropic_client) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.genai') + def test_create_gemini_client(self, mock_genai): + client = create_llm_client("gemini") + mock_genai.configure.assert_called_once_with(api_key='test-google-key') + self.assertEqual(client, mock_genai) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.OpenAI') + def test_create_local_client(self, mock_openai): + mock_openai.return_value = self.mock_openai_client + client = create_llm_client("local") + mock_openai.assert_called_once_with( + base_url="http://192.168.180.137:8006/v1", + api_key="not-needed" + ) + self.assertEqual(client, self.mock_openai_client) + + @unittest.skipIf(skip_llm_tests, skip_message) + def test_create_invalid_provider(self): + with self.assertRaises(ValueError): + create_llm_client("invalid_provider") @unittest.skipIf(skip_llm_tests, skip_message) @patch('tools.llm_api.create_llm_client') - def test_query_llm_success(self, mock_create_client): - # Set up mock - mock_create_client.return_value = self.mock_client - - # Test query with default provider - response = query_llm("Test prompt") # 使用預設 provider - - # Verify response - self.assertEqual(response, "Test response") - - # Verify client was called correctly - self.mock_client.chat.completions.create.assert_called_once_with( - model="gpt-3.5-turbo", # 使用 OpenAI 的預設模型 + def test_query_openai(self, mock_create_client): + mock_create_client.return_value = self.mock_openai_client + response = query_llm("Test prompt", provider="openai") + self.assertEqual(response, "Test OpenAI response") + self.mock_openai_client.chat.completions.create.assert_called_once_with( + model="gpt-4o", messages=[{"role": "user", "content": "Test prompt"}], temperature=0.7 ) @unittest.skipIf(skip_llm_tests, skip_message) @patch('tools.llm_api.create_llm_client') - def test_query_llm_with_custom_model(self, mock_create_client): - # Set up mock - mock_create_client.return_value = self.mock_client - - # Test query with custom model + def test_query_deepseek(self, mock_create_client): + mock_create_client.return_value = self.mock_openai_client + response = query_llm("Test prompt", provider="deepseek") + self.assertEqual(response, "Test OpenAI response") + self.mock_openai_client.chat.completions.create.assert_called_once_with( + model="deepseek-chat", + messages=[{"role": "user", "content": "Test prompt"}], + temperature=0.7 + ) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.create_llm_client') + def test_query_anthropic(self, mock_create_client): + mock_create_client.return_value = self.mock_anthropic_client + response = query_llm("Test prompt", provider="anthropic") + self.assertEqual(response, "Test Anthropic response") + self.mock_anthropic_client.messages.create.assert_called_once_with( + model="claude-3-sonnet-20240229", + max_tokens=1000, + messages=[{"role": "user", "content": "Test prompt"}] + ) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.create_llm_client') + def test_query_gemini(self, mock_create_client): + mock_create_client.return_value = self.mock_gemini_client + response = query_llm("Test prompt", provider="gemini") + self.assertEqual(response, "Test Gemini response") + self.mock_gemini_client.GenerativeModel.assert_called_once_with("gemini-pro") + self.mock_gemini_model.generate_content.assert_called_once_with("Test prompt") + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.create_llm_client') + def test_query_local(self, mock_create_client): + mock_create_client.return_value = self.mock_openai_client + response = query_llm("Test prompt", provider="local") + self.assertEqual(response, "Test OpenAI response") + self.mock_openai_client.chat.completions.create.assert_called_once_with( + model="Qwen/Qwen2.5-32B-Instruct-AWQ", + messages=[{"role": "user", "content": "Test prompt"}], + temperature=0.7 + ) + + @unittest.skipIf(skip_llm_tests, skip_message) + @patch('tools.llm_api.create_llm_client') + def test_query_with_custom_model(self, mock_create_client): + mock_create_client.return_value = self.mock_openai_client response = query_llm("Test prompt", model="custom-model") - - # Verify response - self.assertEqual(response, "Test response") - - # Verify client was called with custom model - self.mock_client.chat.completions.create.assert_called_once_with( + self.assertEqual(response, "Test OpenAI response") + self.mock_openai_client.chat.completions.create.assert_called_once_with( model="custom-model", messages=[{"role": "user", "content": "Test prompt"}], temperature=0.7 @@ -86,27 +245,17 @@ def test_query_llm_with_custom_model(self, mock_create_client): @unittest.skipIf(skip_llm_tests, skip_message) @patch('tools.llm_api.create_llm_client') - def test_query_llm_with_existing_client(self, mock_create_client): - # Test query with provided client - response = query_llm("Test prompt", client=self.mock_client) - - # Verify response - self.assertEqual(response, "Test response") - - # Verify create_client was not called + def test_query_with_existing_client(self, mock_create_client): + response = query_llm("Test prompt", client=self.mock_openai_client) + self.assertEqual(response, "Test OpenAI response") mock_create_client.assert_not_called() @unittest.skipIf(skip_llm_tests, skip_message) @patch('tools.llm_api.create_llm_client') - def test_query_llm_error(self, mock_create_client): - # Set up mock to raise an exception - self.mock_client.chat.completions.create.side_effect = Exception("Test error") - mock_create_client.return_value = self.mock_client - - # Test query with error + def test_query_error(self, mock_create_client): + self.mock_openai_client.chat.completions.create.side_effect = Exception("Test error") + mock_create_client.return_value = self.mock_openai_client response = query_llm("Test prompt") - - # Verify error handling self.assertIsNone(response) if __name__ == '__main__': diff --git a/tools/llm_api.py b/tools/llm_api.py index 7d9ed4f..41c6875 100644 --- a/tools/llm_api.py +++ b/tools/llm_api.py @@ -7,9 +7,41 @@ import os from dotenv import load_dotenv from pathlib import Path +import sys -env_path = Path('.') / '.env.local' -load_dotenv(dotenv_path=env_path) +def load_environment(): + """Load environment variables from .env files in order of precedence""" + # Order of precedence: + # 1. System environment variables (already loaded) + # 2. .env.local (user-specific overrides) + # 3. .env (project defaults) + # 4. .env.example (example configuration) + + env_files = ['.env.local', '.env', '.env.example'] + env_loaded = False + + print("Current working directory:", Path('.').absolute(), file=sys.stderr) + print("Looking for environment files:", env_files, file=sys.stderr) + + for env_file in env_files: + env_path = Path('.') / env_file + print(f"Checking {env_path.absolute()}", file=sys.stderr) + if env_path.exists(): + print(f"Found {env_file}, loading variables...", file=sys.stderr) + load_dotenv(dotenv_path=env_path) + env_loaded = True + print(f"Loaded environment variables from {env_file}", file=sys.stderr) + # Print loaded keys (but not values for security) + with open(env_path) as f: + keys = [line.split('=')[0].strip() for line in f if '=' in line and not line.startswith('#')] + print(f"Keys loaded from {env_file}: {keys}", file=sys.stderr) + + if not env_loaded: + print("Warning: No .env files found. Using system environment variables only.", file=sys.stderr) + print("Available system environment variables:", list(os.environ.keys()), file=sys.stderr) + +# Load environment variables at module import +load_environment() def create_llm_client(provider="openai"): if provider == "openai": @@ -56,7 +88,7 @@ def query_llm(prompt, client=None, model=None, provider="openai"): # Set default model if model is None: if provider == "openai": - model = "gpt-3.5-turbo" + model = "gpt-4o" elif provider == "deepseek": model = "deepseek-chat" elif provider == "anthropic":