test_model_settings.py 5.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. import importlib.util
  2. import json
  3. from contextlib import nullcontext
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. from unittest.mock import Mock, patch
  7. import unittest
  8. spec = importlib.util.spec_from_file_location('bot_model_settings_test', Path(__file__).parents[1] / 'model_settings.py')
  9. models = importlib.util.module_from_spec(spec)
  10. spec.loader.exec_module(models)
  11. class BotModelSettingsTests(unittest.TestCase):
  12. def setUp(self):
  13. self.config = {"agent": {"reasoning_effort": "medium", "service_tier": "auto"}, "model": {"api_key": "fixture-secret"}}
  14. self.ctx = SimpleNamespace(current_provider='openai', current_model='model-a', current_base_url='https://api.openai.com/v1', user_providers={}, custom_providers=[])
  15. self.switch = Mock(side_effect=lambda **kw: SimpleNamespace(success=True, target_provider=kw['explicit_provider'], new_model=kw['raw_input'], base_url=self.ctx.current_base_url, api_key='fixture-secret', model_info=None))
  16. self.warning = Mock(return_value=None)
  17. self.meta = Mock(return_value=SimpleNamespace(supports_reasoning=True))
  18. self.fast = Mock(side_effect=lambda model, **kw: {'service_tier': 'priority'} if kw['base_url'].startswith('https://api.openai.com/') else None)
  19. self.reader = Mock(return_value=None)
  20. modules = {
  21. 'hermes_constants': SimpleNamespace(VALID_REASONING_EFFORTS=['low','medium','high','ultra'], parse_reasoning_effort=lambda value: {} if value in ['none','low','medium','high','ultra'] else None),
  22. 'hermes_cli.config': SimpleNamespace(load_config=lambda: self.config),
  23. 'hermes_cli.inventory': SimpleNamespace(load_picker_context=lambda: self.ctx, _reasoning_catalog_reader=lambda _: self.reader,
  24. build_model_options_payload=lambda *a, **kw: {'providers': [{'slug':'openai','models':['model-a'], 'capabilities': {'model-a': {'reasoning': True}}}]}),
  25. 'hermes_cli.models': SimpleNamespace(resolve_fast_mode_overrides=self.fast, github_model_reasoning_efforts=lambda _: ['low','high']),
  26. 'hermes_cli.model_switch': SimpleNamespace(switch_model=self.switch),
  27. 'hermes_cli.model_selection_guards': SimpleNamespace(combined_selection_warning=self.warning),
  28. 'agent.models_dev': SimpleNamespace(get_model_capabilities=self.meta),
  29. }
  30. self.scoped = patch.object(models, 'profile_scope', side_effect=lambda _: nullcontext())
  31. self.modules = patch.dict('sys.modules', modules)
  32. self.scoped.start(); self.modules.start()
  33. self.addCleanup(self.scoped.stop); self.addCleanup(self.modules.stop)
  34. def test_catalogue_preserves_defaults_modes_and_does_not_disclose_credentials(self):
  35. value = models.options('default')
  36. self.assertEqual(value['defaults']['fastMode'], 'auto')
  37. self.assertEqual(value['models'][0]['fastModes'], ['normal','fast','auto','cold'])
  38. self.assertNotIn('fixture-secret', json.dumps(value))
  39. def test_inheritance_and_explicit_none_are_distinct_and_resolution_never_writes(self):
  40. value = models.resolve('default', {'provider': None, 'model': None, 'reasoningEffort': 'none', 'fastMode': 'cold'})
  41. self.assertEqual(value['effective'], {'provider':'openai','model':'model-a','reasoningEffort':'none','fastMode':'cold'})
  42. self.assertFalse(self.switch.call_args.kwargs['is_global'])
  43. self.assertEqual(self.switch.call_args.kwargs['current_api_key'], 'fixture-secret')
  44. self.assertNotIn('fixture-secret', json.dumps(value))
  45. self.config['agent'].update(reasoning_effort=False, service_tier='normal')
  46. self.assertEqual(models.resolve('default', None)['effective']['reasoningEffort'], 'none')
  47. def test_proxy_route_rejects_all_accelerated_modes(self):
  48. self.ctx.current_base_url = 'https://proxy.invalid/v1'
  49. for speed in ['fast','auto','cold']:
  50. with self.subTest(speed=speed), self.assertRaises(models.ModelSettingsError):
  51. models.resolve('default', {'fastMode': speed})
  52. self.assertEqual(models.resolve('default', {'fastMode':'normal'})['effective']['fastMode'], 'normal')
  53. def test_copilot_and_mandatory_reasoning_restrict_levels(self):
  54. with self.assertRaises(models.ModelSettingsError):
  55. models.resolve('default', {'provider':'copilot','model':'model-a','reasoningEffort':'ultra'})
  56. self.reader.return_value = {'supports_reasoning':True,'mandatory':True}
  57. with self.assertRaises(models.ModelSettingsError):
  58. models.resolve('default', {'reasoningEffort':'none'})
  59. def test_unknown_capabilities_remain_unknown(self):
  60. self.meta.return_value = None
  61. value = models.options('default')['models'][0]
  62. self.assertFalse(value['reasoningKnown'])
  63. self.assertIn('ultra', value['reasoningEfforts'])
  64. def test_invalid_pair_and_switch_failure_stop_resolution_without_secrets(self):
  65. for settings in [{'model':'model-b'}, {'provider':'openai','model':'bad --global'}, {'fastMode':'turbo'}, {'config':{}}]:
  66. with self.subTest(settings=settings), self.assertRaises(models.ModelSettingsError): models.resolve('default',settings)
  67. self.switch.side_effect = None
  68. self.switch.return_value = SimpleNamespace(success=False, error_message='fixture-secret')
  69. with self.assertRaises(models.ModelSettingsError) as exc: models.resolve('default',None)
  70. self.assertNotIn('fixture-secret', str(exc.exception))
  71. def test_confirmation_is_returned_without_applying_any_change(self):
  72. self.warning.return_value = SimpleNamespace(message='模型费用较高')
  73. self.assertEqual(models.resolve('default',{})['confirmationMessage'], '模型费用较高')
  74. if __name__ == '__main__': unittest.main()