test_model_settings.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import importlib.util
  2. import json
  3. from contextlib import contextmanager, nullcontext
  4. from contextvars import ContextVar
  5. from concurrent.futures import ThreadPoolExecutor
  6. from threading import Barrier
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. from unittest.mock import Mock, patch
  10. import unittest
  11. spec = importlib.util.spec_from_file_location('bot_model_settings_test', Path(__file__).parents[1] / 'model_settings.py')
  12. models = importlib.util.module_from_spec(spec)
  13. spec.loader.exec_module(models)
  14. class BotModelSettingsTests(unittest.TestCase):
  15. def setUp(self):
  16. self.config = {"agent": {"reasoning_effort": "medium", "service_tier": "auto"}, "model": {"api_key": "fixture-secret"}}
  17. self.ctx = SimpleNamespace(current_provider='openai', current_model='model-a', current_base_url='https://api.openai.com/v1', user_providers={}, custom_providers=[])
  18. 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))
  19. self.warning = Mock(return_value=None)
  20. self.meta = Mock(return_value=SimpleNamespace(supports_reasoning=True))
  21. self.fast = Mock(side_effect=lambda model, **kw: {'service_tier': 'priority'} if kw['base_url'].startswith('https://api.openai.com/') else None)
  22. self.reader = Mock(return_value=None)
  23. modules = {
  24. '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),
  25. 'hermes_cli.config': SimpleNamespace(load_config=lambda: self.config),
  26. 'hermes_cli.inventory': SimpleNamespace(load_picker_context=lambda: self.ctx, _reasoning_catalog_reader=lambda _: self.reader,
  27. build_model_options_payload=lambda *a, **kw: {'providers': [{'slug':'openai','models':['model-a'], 'capabilities': {'model-a': {'reasoning': True}}}]}),
  28. 'hermes_cli.models': SimpleNamespace(resolve_fast_mode_overrides=self.fast, github_model_reasoning_efforts=lambda _: ['low','high']),
  29. 'hermes_cli.model_switch': SimpleNamespace(switch_model=self.switch),
  30. 'hermes_cli.model_selection_guards': SimpleNamespace(combined_selection_warning=self.warning),
  31. 'agent.models_dev': SimpleNamespace(get_model_capabilities=self.meta),
  32. }
  33. self.scoped = patch.object(models, 'profile_scope', side_effect=lambda _: nullcontext())
  34. self.modules = patch.dict('sys.modules', modules)
  35. self.scoped.start(); self.modules.start()
  36. self.addCleanup(self.scoped.stop); self.addCleanup(self.modules.stop)
  37. def native_scope_fixture(self):
  38. self.scoped.stop()
  39. home = ContextVar('test_model_home', default='caller')
  40. secret = ContextVar('test_model_secret', default=None)
  41. calls = []
  42. @contextmanager
  43. def scope(profile):
  44. calls.append(profile)
  45. home_token = home.set(profile)
  46. secret_token = secret.set(profile + '-credential')
  47. try:
  48. yield
  49. finally:
  50. secret.reset(secret_token)
  51. home.reset(home_token)
  52. def picker():
  53. if secret.get() != home.get() + '-credential':
  54. raise RuntimeError('UnscopedSecretError: missing Profile secret scope')
  55. return self.ctx
  56. import sys
  57. constants = sys.modules['hermes_constants']
  58. constants.set_hermes_home_override = home.set
  59. constants.reset_hermes_home_override = home.reset
  60. sys.modules['hermes_cli.inventory'].load_picker_context = picker
  61. modules = {
  62. 'hermes_cli.profiles': SimpleNamespace(profile_exists=lambda p: p in ('default', 'writer'), get_profile_dir=lambda p: p),
  63. 'hermes_cli.web_server_profiles': SimpleNamespace(_config_profile_scope=scope),
  64. }
  65. patcher = patch.dict('sys.modules', modules)
  66. patcher.start(); self.addCleanup(patcher.stop)
  67. return home, secret, calls
  68. def test_catalogue_and_resolution_bind_profile_secrets_in_multiplex_mode(self):
  69. home, secret, calls = self.native_scope_fixture()
  70. for profile in ('default', 'writer'):
  71. self.assertEqual(models.options(profile)['version'], 1)
  72. self.assertEqual(models.resolve(profile, None)['version'], 1)
  73. self.assertEqual(calls, ['default', 'default', 'writer', 'writer'])
  74. self.assertEqual(home.get(), 'caller')
  75. self.assertIsNone(secret.get())
  76. def test_scopes_are_thread_local_and_restore_the_caller_even_on_error(self):
  77. home, secret, _ = self.native_scope_fixture()
  78. barrier = Barrier(2)
  79. def read(profile):
  80. with models.profile_scope(profile):
  81. barrier.wait(timeout=5)
  82. self.assertEqual(home.get(), profile)
  83. self.assertEqual(secret.get(), profile + '-credential')
  84. with models.profile_scope('default'):
  85. self.assertEqual(secret.get(), 'default-credential')
  86. self.assertEqual(secret.get(), profile + '-credential')
  87. return home.get(), secret.get()
  88. with ThreadPoolExecutor(max_workers=2) as pool:
  89. self.assertEqual(list(pool.map(read, ('default', 'writer'))), [('caller', None), ('caller', None)])
  90. with self.assertRaisesRegex(ValueError, 'fixture failure'):
  91. with models.profile_scope('writer'):
  92. raise ValueError('fixture failure')
  93. self.assertEqual(home.get(), 'caller')
  94. self.assertIsNone(secret.get())
  95. def test_invalid_profiles_never_enter_a_credential_scope(self):
  96. _, _, calls = self.native_scope_fixture()
  97. for profile in ('../writer', 'missing', None, ''):
  98. with self.subTest(profile=profile), self.assertRaises(models.ModelSettingsError):
  99. models.options(profile)
  100. self.assertEqual(calls, [])
  101. def test_older_hermes_uses_its_native_scope_without_home_only_fallback(self):
  102. home, secret, calls = self.native_scope_fixture()
  103. import sys
  104. native = sys.modules['hermes_cli.web_server_profiles']
  105. with patch.dict('sys.modules', {'hermes_cli.web_server_profiles': None, 'hermes_cli.web_server': native}):
  106. self.assertEqual(models.options('writer')['version'], 1)
  107. self.assertEqual(calls, ['writer'])
  108. self.assertEqual(home.get(), 'caller')
  109. self.assertIsNone(secret.get())
  110. def test_catalogue_preserves_defaults_modes_and_does_not_disclose_credentials(self):
  111. value = models.options('default')
  112. self.assertEqual(value['defaults']['fastMode'], 'auto')
  113. self.assertEqual(value['models'][0]['fastModes'], ['normal','fast','auto','cold'])
  114. self.assertNotIn('fixture-secret', json.dumps(value))
  115. def test_inheritance_and_explicit_none_are_distinct_and_resolution_never_writes(self):
  116. value = models.resolve('default', {'provider': None, 'model': None, 'reasoningEffort': 'none', 'fastMode': 'cold'})
  117. self.assertEqual(value['effective'], {'provider':'openai','model':'model-a','reasoningEffort':'none','fastMode':'cold'})
  118. self.assertFalse(self.switch.call_args.kwargs['is_global'])
  119. self.assertEqual(self.switch.call_args.kwargs['current_api_key'], 'fixture-secret')
  120. self.assertNotIn('fixture-secret', json.dumps(value))
  121. self.config['agent'].update(reasoning_effort=False, service_tier='normal')
  122. self.assertEqual(models.resolve('default', None)['effective']['reasoningEffort'], 'none')
  123. def test_proxy_route_rejects_all_accelerated_modes(self):
  124. self.ctx.current_base_url = 'https://proxy.invalid/v1'
  125. for speed in ['fast','auto','cold']:
  126. with self.subTest(speed=speed), self.assertRaises(models.ModelSettingsError):
  127. models.resolve('default', {'fastMode': speed})
  128. self.assertEqual(models.resolve('default', {'fastMode':'normal'})['effective']['fastMode'], 'normal')
  129. def test_copilot_and_mandatory_reasoning_restrict_levels(self):
  130. with self.assertRaises(models.ModelSettingsError):
  131. models.resolve('default', {'provider':'copilot','model':'model-a','reasoningEffort':'ultra'})
  132. self.reader.return_value = {'supports_reasoning':True,'mandatory':True}
  133. with self.assertRaises(models.ModelSettingsError):
  134. models.resolve('default', {'reasoningEffort':'none'})
  135. def test_unknown_capabilities_remain_unknown(self):
  136. self.meta.return_value = None
  137. value = models.options('default')['models'][0]
  138. self.assertFalse(value['reasoningKnown'])
  139. self.assertIn('ultra', value['reasoningEfforts'])
  140. def test_invalid_pair_and_switch_failure_stop_resolution_without_secrets(self):
  141. for settings in [{'model':'model-b'}, {'provider':'openai','model':'bad --global'}, {'fastMode':'turbo'}, {'config':{}}]:
  142. with self.subTest(settings=settings), self.assertRaises(models.ModelSettingsError): models.resolve('default',settings)
  143. self.switch.side_effect = None
  144. self.switch.return_value = SimpleNamespace(success=False, error_message='fixture-secret')
  145. with self.assertRaises(models.ModelSettingsError) as exc: models.resolve('default',None)
  146. self.assertNotIn('fixture-secret', str(exc.exception))
  147. def test_confirmation_is_returned_without_applying_any_change(self):
  148. self.warning.return_value = SimpleNamespace(message='模型费用较高')
  149. self.assertEqual(models.resolve('default',{})['confirmationMessage'], '模型费用较高')
  150. if __name__ == '__main__': unittest.main()