test_config.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import os
  2. import pytest
  3. from openhands.core.config import (
  4. AgentConfig,
  5. AppConfig,
  6. LLMConfig,
  7. UndefinedString,
  8. finalize_config,
  9. get_llm_config_arg,
  10. load_from_env,
  11. load_from_toml,
  12. )
  13. @pytest.fixture
  14. def setup_env():
  15. # Create old-style and new-style TOML files
  16. with open('old_style_config.toml', 'w') as f:
  17. f.write('[default]\nLLM_MODEL="GPT-4"\n')
  18. with open('new_style_config.toml', 'w') as f:
  19. f.write('[app]\nLLM_MODEL="GPT-3"\n')
  20. yield
  21. # Cleanup TOML files after the test
  22. os.remove('old_style_config.toml')
  23. os.remove('new_style_config.toml')
  24. @pytest.fixture
  25. def temp_toml_file(tmp_path):
  26. # Fixture to create a temporary directory and TOML file for testing
  27. tmp_toml_file = os.path.join(tmp_path, 'config.toml')
  28. yield tmp_toml_file
  29. @pytest.fixture
  30. def default_config(monkeypatch):
  31. # Fixture to provide a default AppConfig instance
  32. yield AppConfig()
  33. def test_compat_env_to_config(monkeypatch, setup_env):
  34. # Use `monkeypatch` to set environment variables for this specific test
  35. monkeypatch.setenv('WORKSPACE_BASE', '/repos/openhands/workspace')
  36. monkeypatch.setenv('LLM_API_KEY', 'sk-proj-rgMV0...')
  37. monkeypatch.setenv('LLM_MODEL', 'gpt-4o')
  38. monkeypatch.setenv('AGENT_MEMORY_MAX_THREADS', '4')
  39. monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True')
  40. monkeypatch.setenv('DEFAULT_AGENT', 'CodeActAgent')
  41. monkeypatch.setenv('SANDBOX_TIMEOUT', '10')
  42. config = AppConfig()
  43. load_from_env(config, os.environ)
  44. assert config.workspace_base == '/repos/openhands/workspace'
  45. assert isinstance(config.get_llm_config(), LLMConfig)
  46. assert config.get_llm_config().api_key == 'sk-proj-rgMV0...'
  47. assert config.get_llm_config().model == 'gpt-4o'
  48. assert isinstance(config.get_agent_config(), AgentConfig)
  49. assert isinstance(config.get_agent_config().memory_max_threads, int)
  50. assert config.get_agent_config().memory_max_threads == 4
  51. assert config.get_agent_config().memory_enabled is True
  52. assert config.default_agent == 'CodeActAgent'
  53. assert config.sandbox.timeout == 10
  54. def test_load_from_old_style_env(monkeypatch, default_config):
  55. # Test loading configuration from old-style environment variables using monkeypatch
  56. monkeypatch.setenv('LLM_API_KEY', 'test-api-key')
  57. monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True')
  58. monkeypatch.setenv('DEFAULT_AGENT', 'PlannerAgent')
  59. monkeypatch.setenv('WORKSPACE_BASE', '/opt/files/workspace')
  60. monkeypatch.setenv('SANDBOX_BASE_CONTAINER_IMAGE', 'custom_image')
  61. load_from_env(default_config, os.environ)
  62. assert default_config.get_llm_config().api_key == 'test-api-key'
  63. assert default_config.get_agent_config().memory_enabled is True
  64. assert default_config.default_agent == 'PlannerAgent'
  65. assert default_config.workspace_base == '/opt/files/workspace'
  66. assert (
  67. default_config.workspace_mount_path is UndefinedString.UNDEFINED
  68. ) # before finalize_config
  69. assert (
  70. default_config.workspace_mount_path_in_sandbox is not UndefinedString.UNDEFINED
  71. )
  72. assert default_config.sandbox.base_container_image == 'custom_image'
  73. def test_load_from_new_style_toml(default_config, temp_toml_file):
  74. # Test loading configuration from a new-style TOML file
  75. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  76. toml_file.write(
  77. """
  78. [llm]
  79. model = "test-model"
  80. api_key = "toml-api-key"
  81. [llm.cheap]
  82. model = "some-cheap-model"
  83. api_key = "cheap-model-api-key"
  84. [agent]
  85. memory_enabled = true
  86. [agent.BrowsingAgent]
  87. llm_config = "cheap"
  88. memory_enabled = false
  89. [sandbox]
  90. timeout = 1
  91. [core]
  92. workspace_base = "/opt/files2/workspace"
  93. default_agent = "TestAgent"
  94. """
  95. )
  96. load_from_toml(default_config, temp_toml_file)
  97. # default llm & agent configs
  98. assert default_config.default_agent == 'TestAgent'
  99. assert default_config.get_llm_config().model == 'test-model'
  100. assert default_config.get_llm_config().api_key == 'toml-api-key'
  101. assert default_config.get_agent_config().memory_enabled is True
  102. # undefined agent config inherits default ones
  103. assert (
  104. default_config.get_llm_config_from_agent('CodeActAgent')
  105. == default_config.get_llm_config()
  106. )
  107. assert default_config.get_agent_config('CodeActAgent').memory_enabled is True
  108. # defined agent config overrides default ones
  109. assert default_config.get_llm_config_from_agent(
  110. 'BrowsingAgent'
  111. ) == default_config.get_llm_config('cheap')
  112. assert (
  113. default_config.get_llm_config_from_agent('BrowsingAgent').model
  114. == 'some-cheap-model'
  115. )
  116. assert default_config.get_agent_config('BrowsingAgent').memory_enabled is False
  117. assert default_config.workspace_base == '/opt/files2/workspace'
  118. assert default_config.sandbox.timeout == 1
  119. # before finalize_config, workspace_mount_path is UndefinedString.UNDEFINED if it was not set
  120. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  121. assert (
  122. default_config.workspace_mount_path_in_sandbox is not UndefinedString.UNDEFINED
  123. )
  124. assert default_config.workspace_mount_path_in_sandbox == '/workspace'
  125. finalize_config(default_config)
  126. # after finalize_config, workspace_mount_path is set to the absolute path of workspace_base
  127. # if it was undefined
  128. assert default_config.workspace_mount_path == '/opt/files2/workspace'
  129. def test_compat_load_sandbox_from_toml(default_config: AppConfig, temp_toml_file: str):
  130. # test loading configuration from a new-style TOML file
  131. # uses a toml file with sandbox_vars instead of a sandbox section
  132. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  133. toml_file.write(
  134. """
  135. [llm]
  136. model = "test-model"
  137. [agent]
  138. memory_enabled = true
  139. [core]
  140. workspace_base = "/opt/files2/workspace"
  141. sandbox_timeout = 500
  142. sandbox_base_container_image = "node:14"
  143. sandbox_user_id = 1001
  144. default_agent = "TestAgent"
  145. """
  146. )
  147. load_from_toml(default_config, temp_toml_file)
  148. assert default_config.get_llm_config().model == 'test-model'
  149. assert default_config.get_llm_config_from_agent().model == 'test-model'
  150. assert default_config.default_agent == 'TestAgent'
  151. assert default_config.get_agent_config().memory_enabled is True
  152. assert default_config.workspace_base == '/opt/files2/workspace'
  153. assert default_config.sandbox.timeout == 500
  154. assert default_config.sandbox.base_container_image == 'node:14'
  155. assert default_config.sandbox.user_id == 1001
  156. assert default_config.workspace_mount_path_in_sandbox == '/workspace'
  157. finalize_config(default_config)
  158. # app config doesn't have fields sandbox_*
  159. assert not hasattr(default_config, 'sandbox_timeout')
  160. assert not hasattr(default_config, 'sandbox_base_container_image')
  161. assert not hasattr(default_config, 'sandbox_user_id')
  162. # after finalize_config, workspace_mount_path is set to the absolute path of workspace_base
  163. # if it was undefined
  164. assert default_config.workspace_mount_path == '/opt/files2/workspace'
  165. def test_env_overrides_compat_toml(monkeypatch, default_config, temp_toml_file):
  166. # test that environment variables override TOML values using monkeypatch
  167. # uses a toml file with sandbox_vars instead of a sandbox section
  168. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  169. toml_file.write("""
  170. [llm]
  171. model = "test-model"
  172. api_key = "toml-api-key"
  173. [core]
  174. workspace_base = "/opt/files3/workspace"
  175. disable_color = true
  176. sandbox_timeout = 500
  177. sandbox_user_id = 1001
  178. """)
  179. monkeypatch.setenv('LLM_API_KEY', 'env-api-key')
  180. monkeypatch.setenv('WORKSPACE_BASE', 'UNDEFINED')
  181. monkeypatch.setenv('SANDBOX_TIMEOUT', '1000')
  182. monkeypatch.setenv('SANDBOX_USER_ID', '1002')
  183. monkeypatch.delenv('LLM_MODEL', raising=False)
  184. load_from_toml(default_config, temp_toml_file)
  185. # before finalize_config, workspace_mount_path is UndefinedString.UNDEFINED if it was not set
  186. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  187. load_from_env(default_config, os.environ)
  188. assert os.environ.get('LLM_MODEL') is None
  189. assert default_config.get_llm_config().model == 'test-model'
  190. assert default_config.get_llm_config('llm').model == 'test-model'
  191. assert default_config.get_llm_config_from_agent().model == 'test-model'
  192. assert default_config.get_llm_config().api_key == 'env-api-key'
  193. # after we set workspace_base to 'UNDEFINED' in the environment,
  194. # workspace_base should be set to that
  195. # workspace_mount path is still UndefinedString.UNDEFINED
  196. assert default_config.workspace_base is not UndefinedString.UNDEFINED
  197. assert default_config.workspace_base == 'UNDEFINED'
  198. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  199. assert default_config.workspace_mount_path == 'UNDEFINED'
  200. assert default_config.disable_color is True
  201. assert default_config.sandbox.timeout == 1000
  202. assert default_config.sandbox.user_id == 1002
  203. finalize_config(default_config)
  204. # after finalize_config, workspace_mount_path is set to absolute path of workspace_base if it was undefined
  205. assert default_config.workspace_mount_path == os.getcwd() + '/UNDEFINED'
  206. def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file):
  207. # test that environment variables override TOML values using monkeypatch
  208. # uses a toml file with a sandbox section
  209. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  210. toml_file.write("""
  211. [llm]
  212. model = "test-model"
  213. api_key = "toml-api-key"
  214. [core]
  215. workspace_base = "/opt/files3/workspace"
  216. [sandbox]
  217. timeout = 500
  218. user_id = 1001
  219. """)
  220. monkeypatch.setenv('LLM_API_KEY', 'env-api-key')
  221. monkeypatch.setenv('WORKSPACE_BASE', 'UNDEFINED')
  222. monkeypatch.setenv('SANDBOX_TIMEOUT', '1000')
  223. monkeypatch.setenv('SANDBOX_USER_ID', '1002')
  224. monkeypatch.delenv('LLM_MODEL', raising=False)
  225. load_from_toml(default_config, temp_toml_file)
  226. # before finalize_config, workspace_mount_path is UndefinedString.UNDEFINED if it was not set
  227. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  228. # before load_from_env, values are set to the values from the toml file
  229. assert default_config.get_llm_config().api_key == 'toml-api-key'
  230. assert default_config.sandbox.timeout == 500
  231. assert default_config.sandbox.user_id == 1001
  232. load_from_env(default_config, os.environ)
  233. # values from env override values from toml
  234. assert os.environ.get('LLM_MODEL') is None
  235. assert default_config.get_llm_config().model == 'test-model'
  236. assert default_config.get_llm_config().api_key == 'env-api-key'
  237. assert default_config.sandbox.timeout == 1000
  238. assert default_config.sandbox.user_id == 1002
  239. finalize_config(default_config)
  240. # after finalize_config, workspace_mount_path is set to absolute path of workspace_base if it was undefined
  241. assert default_config.workspace_mount_path == os.getcwd() + '/UNDEFINED'
  242. def test_sandbox_config_from_toml(monkeypatch, default_config, temp_toml_file):
  243. # Test loading configuration from a new-style TOML file
  244. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  245. toml_file.write(
  246. """
  247. [core]
  248. workspace_base = "/opt/files/workspace"
  249. [llm]
  250. model = "test-model"
  251. [sandbox]
  252. timeout = 1
  253. base_container_image = "custom_image"
  254. user_id = 1001
  255. """
  256. )
  257. monkeypatch.setattr(os, 'environ', {})
  258. load_from_toml(default_config, temp_toml_file)
  259. load_from_env(default_config, os.environ)
  260. finalize_config(default_config)
  261. assert default_config.get_llm_config().model == 'test-model'
  262. assert default_config.sandbox.timeout == 1
  263. assert default_config.sandbox.base_container_image == 'custom_image'
  264. assert default_config.sandbox.user_id == 1001
  265. def test_defaults_dict_after_updates(default_config):
  266. # Test that `defaults_dict` retains initial values after updates.
  267. initial_defaults = default_config.defaults_dict
  268. assert (
  269. initial_defaults['workspace_mount_path']['default'] is UndefinedString.UNDEFINED
  270. )
  271. assert initial_defaults['default_agent']['default'] == 'CodeActAgent'
  272. updated_config = AppConfig()
  273. updated_config.get_llm_config().api_key = 'updated-api-key'
  274. updated_config.get_llm_config('llm').api_key = 'updated-api-key'
  275. updated_config.get_llm_config_from_agent('agent').api_key = 'updated-api-key'
  276. updated_config.get_llm_config_from_agent('PlannerAgent').api_key = 'updated-api-key'
  277. updated_config.default_agent = 'PlannerAgent'
  278. defaults_after_updates = updated_config.defaults_dict
  279. assert defaults_after_updates['default_agent']['default'] == 'CodeActAgent'
  280. assert (
  281. defaults_after_updates['workspace_mount_path']['default']
  282. is UndefinedString.UNDEFINED
  283. )
  284. assert defaults_after_updates['sandbox']['timeout']['default'] == 120
  285. assert (
  286. defaults_after_updates['sandbox']['base_container_image']['default']
  287. == 'nikolaik/python-nodejs:python3.11-nodejs22'
  288. )
  289. assert defaults_after_updates == initial_defaults
  290. def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config):
  291. # Invalid TOML format doesn't break the configuration
  292. monkeypatch.setenv('LLM_MODEL', 'gpt-5-turbo-1106')
  293. monkeypatch.setenv('WORKSPACE_MOUNT_PATH', '/home/user/project')
  294. monkeypatch.delenv('LLM_API_KEY', raising=False)
  295. with open(temp_toml_file, 'w', encoding='utf-8') as toml_file:
  296. toml_file.write('INVALID TOML CONTENT')
  297. load_from_toml(default_config)
  298. load_from_env(default_config, os.environ)
  299. default_config.jwt_secret = None # prevent leak
  300. for llm in default_config.llms.values():
  301. llm.api_key = None # prevent leak
  302. assert default_config.get_llm_config().model == 'gpt-5-turbo-1106'
  303. assert default_config.get_llm_config().custom_llm_provider is None
  304. assert default_config.workspace_mount_path == '/home/user/project'
  305. def test_finalize_config(default_config):
  306. # Test finalize config
  307. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  308. finalize_config(default_config)
  309. assert default_config.workspace_mount_path == os.path.abspath(
  310. default_config.workspace_base
  311. )
  312. # tests for workspace, mount path, path in sandbox, cache dir
  313. def test_workspace_mount_path_default(default_config):
  314. assert default_config.workspace_mount_path is UndefinedString.UNDEFINED
  315. finalize_config(default_config)
  316. assert default_config.workspace_mount_path == os.path.abspath(
  317. default_config.workspace_base
  318. )
  319. def test_workspace_mount_rewrite(default_config, monkeypatch):
  320. default_config.workspace_base = '/home/user/project'
  321. default_config.workspace_mount_rewrite = '/home/user:/sandbox'
  322. monkeypatch.setattr('os.getcwd', lambda: '/current/working/directory')
  323. finalize_config(default_config)
  324. assert default_config.workspace_mount_path == '/sandbox/project'
  325. def test_embedding_base_url_default(default_config):
  326. default_config.get_llm_config().base_url = 'https://api.exampleapi.com'
  327. finalize_config(default_config)
  328. assert (
  329. default_config.get_llm_config().embedding_base_url
  330. == 'https://api.exampleapi.com'
  331. )
  332. def test_cache_dir_creation(default_config, tmpdir):
  333. default_config.cache_dir = str(tmpdir.join('test_cache'))
  334. finalize_config(default_config)
  335. assert os.path.exists(default_config.cache_dir)
  336. def test_api_keys_repr_str():
  337. # Test LLMConfig
  338. llm_config = LLMConfig(
  339. api_key='my_api_key',
  340. aws_access_key_id='my_access_key',
  341. aws_secret_access_key='my_secret_key',
  342. )
  343. assert "api_key='******'" in repr(llm_config)
  344. assert "aws_access_key_id='******'" in repr(llm_config)
  345. assert "aws_secret_access_key='******'" in repr(llm_config)
  346. assert "api_key='******'" in str(llm_config)
  347. assert "aws_access_key_id='******'" in str(llm_config)
  348. assert "aws_secret_access_key='******'" in str(llm_config)
  349. # Check that no other attrs in LLMConfig have 'key' or 'token' in their name
  350. # This will fail when new attrs are added, and attract attention
  351. known_key_token_attrs_llm = [
  352. 'api_key',
  353. 'aws_access_key_id',
  354. 'aws_secret_access_key',
  355. 'input_cost_per_token',
  356. 'output_cost_per_token',
  357. ]
  358. for attr_name in dir(LLMConfig):
  359. if (
  360. not attr_name.startswith('__')
  361. and attr_name not in known_key_token_attrs_llm
  362. ):
  363. assert (
  364. 'key' not in attr_name.lower()
  365. ), f"Unexpected attribute '{attr_name}' contains 'key' in LLMConfig"
  366. assert (
  367. 'token' not in attr_name.lower() or 'tokens' in attr_name.lower()
  368. ), f"Unexpected attribute '{attr_name}' contains 'token' in LLMConfig"
  369. # Test AgentConfig
  370. # No attrs in AgentConfig have 'key' or 'token' in their name
  371. agent_config = AgentConfig(memory_enabled=True, memory_max_threads=4)
  372. for attr_name in dir(AgentConfig):
  373. if not attr_name.startswith('__'):
  374. assert (
  375. 'key' not in attr_name.lower()
  376. ), f"Unexpected attribute '{attr_name}' contains 'key' in AgentConfig"
  377. assert (
  378. 'token' not in attr_name.lower() or 'tokens' in attr_name.lower()
  379. ), f"Unexpected attribute '{attr_name}' contains 'token' in AgentConfig"
  380. # Test AppConfig
  381. app_config = AppConfig(
  382. llms={'llm': llm_config},
  383. agents={'agent': agent_config},
  384. e2b_api_key='my_e2b_api_key',
  385. jwt_secret='my_jwt_secret',
  386. )
  387. assert "e2b_api_key='******'" in repr(app_config)
  388. assert "e2b_api_key='******'" in str(app_config)
  389. assert "jwt_secret='******'" in repr(app_config)
  390. assert "jwt_secret='******'" in str(app_config)
  391. # Check that no other attrs in AppConfig have 'key' or 'token' in their name
  392. # This will fail when new attrs are added, and attract attention
  393. known_key_token_attrs_app = ['e2b_api_key']
  394. for attr_name in dir(AppConfig):
  395. if (
  396. not attr_name.startswith('__')
  397. and attr_name not in known_key_token_attrs_app
  398. ):
  399. assert (
  400. 'key' not in attr_name.lower()
  401. ), f"Unexpected attribute '{attr_name}' contains 'key' in AppConfig"
  402. assert (
  403. 'token' not in attr_name.lower() or 'tokens' in attr_name.lower()
  404. ), f"Unexpected attribute '{attr_name}' contains 'token' in AppConfig"
  405. def test_max_iterations_and_max_budget_per_task_from_toml(temp_toml_file):
  406. temp_toml = """
  407. [core]
  408. max_iterations = 42
  409. max_budget_per_task = 4.7
  410. """
  411. config = AppConfig()
  412. with open(temp_toml_file, 'w') as f:
  413. f.write(temp_toml)
  414. load_from_toml(config, temp_toml_file)
  415. assert config.max_iterations == 42
  416. assert config.max_budget_per_task == 4.7
  417. def test_get_llm_config_arg(temp_toml_file):
  418. temp_toml = """
  419. [core]
  420. max_iterations = 100
  421. max_budget_per_task = 4.0
  422. [llm.gpt3]
  423. model="gpt-3.5-turbo"
  424. api_key="redacted"
  425. embedding_model="openai"
  426. [llm.gpt4o]
  427. model="gpt-4o"
  428. api_key="redacted"
  429. embedding_model="openai"
  430. """
  431. with open(temp_toml_file, 'w') as f:
  432. f.write(temp_toml)
  433. llm_config = get_llm_config_arg('gpt3', temp_toml_file)
  434. assert llm_config.model == 'gpt-3.5-turbo'
  435. assert llm_config.embedding_model == 'openai'
  436. def test_get_agent_configs(default_config, temp_toml_file):
  437. temp_toml = """
  438. [core]
  439. max_iterations = 100
  440. max_budget_per_task = 4.0
  441. [agent.CodeActAgent]
  442. memory_enabled = true
  443. [agent.PlannerAgent]
  444. memory_max_threads = 10
  445. """
  446. with open(temp_toml_file, 'w') as f:
  447. f.write(temp_toml)
  448. load_from_toml(default_config, temp_toml_file)
  449. codeact_config = default_config.get_agent_configs().get('CodeActAgent')
  450. assert codeact_config.memory_enabled is True
  451. planner_config = default_config.get_agent_configs().get('PlannerAgent')
  452. assert planner_config.memory_max_threads == 10