from pathlib import Path from unittest.mock import MagicMock, PropertyMock, mock_open, patch import toml from openhands.cli.tui import UsageMetrics from openhands.cli.utils import ( add_local_config_trusted_dir, extract_model_and_provider, get_local_config_trusted_dirs, is_number, organize_models_and_providers, read_file, split_is_actually_version, update_usage_metrics, write_to_file, ) from openhands.events.event import Event from openhands.llm.metrics import Metrics, TokenUsage class TestGetLocalConfigTrustedDirs: @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') def test_config_file_does_not_exist(self, mock_config_path): mock_config_path.exists.return_value = False result = get_local_config_trusted_dirs() assert result == [] mock_config_path.exists.assert_called_once() @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch('builtins.open', new_callable=mock_open, read_data='invalid toml') @patch( 'openhands.cli.utils.toml.load', side_effect=toml.TomlDecodeError('error', 'doc', 0), ) def test_config_file_invalid_toml( self, mock_toml_load, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True result = get_local_config_trusted_dirs() assert result == [] mock_config_path.exists.assert_called_once() mock_open_file.assert_called_once_with(mock_config_path, 'r') mock_toml_load.assert_called_once() @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/path/one']}}), ) @patch('openhands.cli.utils.toml.load') def test_config_file_valid(self, mock_toml_load, mock_open_file, mock_config_path): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/path/one']}} result = get_local_config_trusted_dirs() assert result == ['/path/one'] mock_config_path.exists.assert_called_once() mock_open_file.assert_called_once_with(mock_config_path, 'r') mock_toml_load.assert_called_once() @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'other_section': {}}), ) @patch('openhands.cli.utils.toml.load') def test_config_file_missing_sandbox( self, mock_toml_load, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'other_section': {}} result = get_local_config_trusted_dirs() assert result == [] mock_config_path.exists.assert_called_once() mock_open_file.assert_called_once_with(mock_config_path, 'r') mock_toml_load.assert_called_once() @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'sandbox': {'other_key': []}}), ) @patch('openhands.cli.utils.toml.load') def test_config_file_missing_trusted_dirs( self, mock_toml_load, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'sandbox': {'other_key': []}} result = get_local_config_trusted_dirs() assert result == [] mock_config_path.exists.assert_called_once() mock_open_file.assert_called_once_with(mock_config_path, 'r') mock_toml_load.assert_called_once() class TestAddLocalConfigTrustedDir: @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch('builtins.open', new_callable=mock_open) @patch('openhands.cli.utils.toml.dump') @patch('openhands.cli.utils.toml.load') def test_add_to_non_existent_file( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = False mock_parent = MagicMock(spec=Path) mock_config_path.parent = mock_parent add_local_config_trusted_dir('/new/path') mock_config_path.exists.assert_called_once() mock_parent.mkdir.assert_called_once_with(parents=True, exist_ok=True) mock_open_file.assert_called_once_with(mock_config_path, 'w') expected_config = {'sandbox': {'trusted_dirs': ['/new/path']}} mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) mock_toml_load.assert_not_called() @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}), ) @patch('openhands.cli.utils.toml.dump') @patch('openhands.cli.utils.toml.load') def test_add_to_existing_file( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}} add_local_config_trusted_dir('/new/path') mock_config_path.exists.assert_called_once() assert mock_open_file.call_count == 2 # Once for read, once for write mock_open_file.assert_any_call(mock_config_path, 'r') mock_open_file.assert_any_call(mock_config_path, 'w') mock_toml_load.assert_called_once() expected_config = {'sandbox': {'trusted_dirs': ['/old/path', '/new/path']}} mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'sandbox': {'trusted_dirs': ['/old/path']}}), ) @patch('openhands.cli.utils.toml.dump') @patch('openhands.cli.utils.toml.load') def test_add_existing_dir( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'sandbox': {'trusted_dirs': ['/old/path']}} add_local_config_trusted_dir('/old/path') mock_config_path.exists.assert_called_once() mock_toml_load.assert_called_once() expected_config = { 'sandbox': {'trusted_dirs': ['/old/path']} } # Should not change mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch('builtins.open', new_callable=mock_open, read_data='invalid toml') @patch('openhands.cli.utils.toml.dump') @patch( 'openhands.cli.utils.toml.load', side_effect=toml.TomlDecodeError('error', 'doc', 0), ) def test_add_to_invalid_toml( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True add_local_config_trusted_dir('/new/path') mock_config_path.exists.assert_called_once() mock_toml_load.assert_called_once() expected_config = { 'sandbox': {'trusted_dirs': ['/new/path']} } # Should reset to default + new path mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'other_section': {}}), ) @patch('openhands.cli.utils.toml.dump') @patch('openhands.cli.utils.toml.load') def test_add_to_missing_sandbox( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'other_section': {}} add_local_config_trusted_dir('/new/path') mock_config_path.exists.assert_called_once() mock_toml_load.assert_called_once() expected_config = { 'other_section': {}, 'sandbox': {'trusted_dirs': ['/new/path']}, } mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) @patch('openhands.cli.utils._LOCAL_CONFIG_FILE_PATH') @patch( 'builtins.open', new_callable=mock_open, read_data=toml.dumps({'sandbox': {'other_key': []}}), ) @patch('openhands.cli.utils.toml.dump') @patch('openhands.cli.utils.toml.load') def test_add_to_missing_trusted_dirs( self, mock_toml_load, mock_toml_dump, mock_open_file, mock_config_path ): mock_config_path.exists.return_value = True mock_toml_load.return_value = {'sandbox': {'other_key': []}} add_local_config_trusted_dir('/new/path') mock_config_path.exists.assert_called_once() mock_toml_load.assert_called_once() expected_config = {'sandbox': {'other_key': [], 'trusted_dirs': ['/new/path']}} mock_toml_dump.assert_called_once_with(expected_config, mock_open_file()) class TestUpdateUsageMetrics: def test_update_usage_metrics_no_llm_metrics(self): event = Event() usage_metrics = UsageMetrics() # Store original metrics object for comparison original_metrics = usage_metrics.metrics update_usage_metrics(event, usage_metrics) # Metrics should remain unchanged assert usage_metrics.metrics is original_metrics # Same object reference assert usage_metrics.metrics.accumulated_cost == 0.0 # Default value def test_update_usage_metrics_with_cost(self): event = Event() # Create a mock Metrics object metrics = MagicMock(spec=Metrics) # Mock the accumulated_cost property type(metrics).accumulated_cost = PropertyMock(return_value=1.25) event.llm_metrics = metrics usage_metrics = UsageMetrics() update_usage_metrics(event, usage_metrics) # Test that the metrics object was updated to the one from the event assert usage_metrics.metrics is metrics # Should be the same object reference # Test that we can access the accumulated_cost through the metrics property assert usage_metrics.metrics.accumulated_cost == 1.25 def test_update_usage_metrics_with_tokens(self): event = Event() # Create mock token usage token_usage = MagicMock(spec=TokenUsage) token_usage.prompt_tokens = 100 token_usage.completion_tokens = 50 token_usage.cache_read_tokens = 20 token_usage.cache_write_tokens = 30 # Create mock metrics metrics = MagicMock(spec=Metrics) # Set the mock properties type(metrics).accumulated_cost = PropertyMock(return_value=1.5) type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage) event.llm_metrics = metrics usage_metrics = UsageMetrics() update_usage_metrics(event, usage_metrics) # Test that the metrics object was updated to the one from the event assert usage_metrics.metrics is metrics # Should be the same object reference # Test we can access metrics values through the metrics property assert usage_metrics.metrics.accumulated_cost == 1.5 assert usage_metrics.metrics.accumulated_token_usage is token_usage assert usage_metrics.metrics.accumulated_token_usage.prompt_tokens == 100 assert usage_metrics.metrics.accumulated_token_usage.completion_tokens == 50 assert usage_metrics.metrics.accumulated_token_usage.cache_read_tokens == 20 assert usage_metrics.metrics.accumulated_token_usage.cache_write_tokens == 30 def test_update_usage_metrics_with_invalid_types(self): event = Event() # Create mock token usage with invalid types token_usage = MagicMock(spec=TokenUsage) token_usage.prompt_tokens = 'not an int' token_usage.completion_tokens = 'not an int' token_usage.cache_read_tokens = 'not an int' token_usage.cache_write_tokens = 'not an int' # Create mock metrics metrics = MagicMock(spec=Metrics) # Set the mock properties type(metrics).accumulated_cost = PropertyMock(return_value='not a float') type(metrics).accumulated_token_usage = PropertyMock(return_value=token_usage) event.llm_metrics = metrics usage_metrics = UsageMetrics() update_usage_metrics(event, usage_metrics) # Test that the metrics object was still updated to the one from the event # Even though the values are invalid types, the metrics object reference should be updated assert usage_metrics.metrics is metrics # Should be the same object reference # We can verify that we can access the properties through the metrics object # The invalid types are preserved since our update_usage_metrics function # simply assigns the metrics object without validation assert usage_metrics.metrics.accumulated_cost == 'not a float' assert usage_metrics.metrics.accumulated_token_usage is token_usage class TestModelAndProviderFunctions: def test_extract_model_and_provider_slash_format(self): model = 'openai/gpt-4o' result = extract_model_and_provider(model) assert result['provider'] == 'openai' assert result['model'] == 'gpt-4o' assert result['separator'] == '/' def test_extract_model_and_provider_dot_format(self): model = 'anthropic.claude-3-7' result = extract_model_and_provider(model) assert result['provider'] == 'anthropic' assert result['model'] == 'claude-3-7' assert result['separator'] == '.' def test_extract_model_and_provider_openai_implicit(self): model = 'gpt-4o' result = extract_model_and_provider(model) assert result['provider'] == 'openai' assert result['model'] == 'gpt-4o' assert result['separator'] == '/' def test_extract_model_and_provider_anthropic_implicit(self): model = 'claude-sonnet-4-20250514' result = extract_model_and_provider(model) assert result['provider'] == 'anthropic' assert result['model'] == 'claude-sonnet-4-20250514' assert result['separator'] == '/' def test_extract_model_and_provider_versioned(self): model = 'deepseek.deepseek-coder-1.3b' result = extract_model_and_provider(model) assert result['provider'] == 'deepseek' assert result['model'] == 'deepseek-coder-1.3b' assert result['separator'] == '.' def test_extract_model_and_provider_unknown(self): model = 'unknown-model' result = extract_model_and_provider(model) assert result['provider'] == '' assert result['model'] == 'unknown-model' assert result['separator'] == '' def test_organize_models_and_providers(self): models = [ 'openai/gpt-4o', 'anthropic/claude-sonnet-4-20250514', 'o3-mini', 'anthropic.claude-3-5', # Should be ignored as it uses dot separator for anthropic 'unknown-model', ] result = organize_models_and_providers(models) assert 'openai' in result assert 'anthropic' in result assert 'other' in result assert len(result['openai']['models']) == 2 assert 'gpt-4o' in result['openai']['models'] assert 'o3-mini' in result['openai']['models'] assert len(result['anthropic']['models']) == 1 assert 'claude-sonnet-4-20250514' in result['anthropic']['models'] assert len(result['other']['models']) == 1 assert 'unknown-model' in result['other']['models'] class TestUtilityFunctions: def test_is_number_with_digit(self): assert is_number('1') is True assert is_number('9') is True def test_is_number_with_letter(self): assert is_number('a') is False assert is_number('Z') is False def test_is_number_with_special_char(self): assert is_number('.') is False assert is_number('-') is False def test_split_is_actually_version_true(self): split = ['model', '1.0'] assert split_is_actually_version(split) is True def test_split_is_actually_version_false(self): split = ['model', 'version'] assert split_is_actually_version(split) is False def test_split_is_actually_version_single_item(self): split = ['model'] assert split_is_actually_version(split) is False class TestFileOperations: def test_read_file(self): mock_content = 'test file content' with patch('builtins.open', mock_open(read_data=mock_content)): result = read_file('test.txt') assert result == mock_content def test_write_to_file(self): mock_content = 'test file content' mock_file = mock_open() with patch('builtins.open', mock_file): write_to_file('test.txt', mock_content) mock_file.assert_called_once_with('test.txt', 'w') handle = mock_file() handle.write.assert_called_once_with(mock_content)