test_config.py 20 KB

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