test_benchmark_config.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import json
  2. from enum import StrEnum
  3. import pytest
  4. from pydantic import ValidationError
  5. from agent_lab.application.benchmark import (
  6. BENCHMARK_CASE_CATALOG,
  7. BENCHMARK_CASE_IDS,
  8. BENCHMARK_MODES,
  9. BenchmarkCaseId,
  10. BenchmarkConfig,
  11. BenchmarkExpectation,
  12. BenchmarkMode,
  13. BenchmarkTarget,
  14. build_benchmark_request,
  15. build_mock_rounds,
  16. )
  17. VALID_CONFIG = {
  18. "schema_version": 1,
  19. "base_url": "https://provider.example/v1",
  20. "model": "benchmark-model",
  21. "runs_per_case": 1,
  22. "modes": ["dual_agent", "chat_agent_tools"],
  23. "cases": [
  24. "ordinary_chat",
  25. "device_volume_silent",
  26. "calendar_schedule_template",
  27. "web_search_two_answers",
  28. "session_terminate",
  29. "parallel_volume_schedule",
  30. ],
  31. }
  32. def test_benchmark_ids_are_ordered_string_compatible_enums():
  33. assert isinstance(BenchmarkMode, type)
  34. assert issubclass(BenchmarkMode, StrEnum)
  35. assert isinstance(BenchmarkCaseId, type)
  36. assert issubclass(BenchmarkCaseId, StrEnum)
  37. assert tuple(BenchmarkMode) == BENCHMARK_MODES
  38. assert tuple(BenchmarkCaseId) == BENCHMARK_CASE_IDS
  39. assert tuple(str(mode) for mode in BENCHMARK_MODES) == (
  40. "dual_agent",
  41. "chat_agent_tools",
  42. )
  43. assert tuple(str(case_id) for case_id in BENCHMARK_CASE_IDS) == (
  44. "ordinary_chat",
  45. "device_volume_silent",
  46. "calendar_schedule_template",
  47. "web_search_two_answers",
  48. "session_terminate",
  49. "parallel_volume_schedule",
  50. )
  51. def test_benchmark_config_accepts_the_versioned_single_target_schema():
  52. config = BenchmarkConfig.model_validate_json(json.dumps(VALID_CONFIG))
  53. python_config = BenchmarkConfig.model_validate(VALID_CONFIG)
  54. assert config.schema_version == 1
  55. assert config.base_url == "https://provider.example/v1"
  56. assert config.model == "benchmark-model"
  57. assert config.runs_per_case == 1
  58. assert tuple(config.modes) == BENCHMARK_MODES
  59. assert tuple(config.cases) == BENCHMARK_CASE_IDS
  60. assert all(isinstance(mode, StrEnum) for mode in config.modes)
  61. assert all(isinstance(case_id, StrEnum) for case_id in config.cases)
  62. assert python_config.modes == config.modes
  63. assert python_config.cases == config.cases
  64. assert config.model_dump(mode="json")["modes"] == VALID_CONFIG["modes"]
  65. assert config.model_dump(mode="json")["cases"] == VALID_CONFIG["cases"]
  66. def test_benchmark_target_uses_the_same_strict_url_and_model_validation():
  67. target = BenchmarkTarget(
  68. base_url="http://localhost:8000/v1",
  69. model="local-model",
  70. )
  71. assert target.model_dump() == {
  72. "base_url": "http://localhost:8000/v1",
  73. "model": "local-model",
  74. }
  75. @pytest.mark.parametrize("schema_version", [0, 2, "1", True, 1.0])
  76. def test_benchmark_config_rejects_unknown_or_coerced_schema_versions(
  77. schema_version: object,
  78. ):
  79. payload = {**VALID_CONFIG, "schema_version": schema_version}
  80. with pytest.raises(ValidationError, match="schema_version"):
  81. BenchmarkConfig.model_validate(payload)
  82. @pytest.mark.parametrize(
  83. "base_url",
  84. [
  85. "ftp://provider.example/v1",
  86. "provider.example/v1",
  87. "https:///v1",
  88. "https://user:pass@provider.example/v1",
  89. "https://provider.example/v1?debug=true",
  90. "https://provider.example/v1#fragment",
  91. ],
  92. )
  93. def test_benchmark_config_rejects_unsafe_or_non_http_base_urls(base_url: str):
  94. payload = {**VALID_CONFIG, "base_url": base_url}
  95. with pytest.raises(ValidationError, match="base_url"):
  96. BenchmarkConfig.model_validate(payload)
  97. @pytest.mark.parametrize(
  98. "base_url",
  99. [
  100. pytest.param("https://🚀.example/v1", id="invalid-idna-host"),
  101. pytest.param("https://xn--.example/v1", id="malformed-a-label"),
  102. pytest.param(
  103. "https://provider.example:not-a-port/v1",
  104. id="invalid-port",
  105. ),
  106. pytest.param(
  107. "https://provider.example:65536/v1",
  108. id="out-of-range-port",
  109. ),
  110. pytest.param("https://[v1.invalid]/v1", id="invalid-ip-literal"),
  111. ],
  112. )
  113. def test_benchmark_target_rejects_urls_incompatible_with_httpx(base_url: str):
  114. with pytest.raises(ValidationError, match="base_url"):
  115. BenchmarkTarget(base_url=base_url, model="benchmark-model")
  116. @pytest.mark.parametrize(
  117. "character",
  118. [
  119. pytest.param(chr(0), id="nul-cc"),
  120. pytest.param("\r\n", id="crlf-cc"),
  121. pytest.param("\t", id="tab-cc"),
  122. pytest.param(chr(0x200B), id="zero-width-space-cf"),
  123. pytest.param(chr(0x202E), id="right-to-left-override-cf"),
  124. pytest.param(chr(0xE000), id="private-use-co"),
  125. pytest.param(chr(0xD800), id="surrogate-cs"),
  126. pytest.param(chr(0x0378), id="unassigned-cn"),
  127. ],
  128. )
  129. def test_benchmark_config_rejects_unicode_category_c_in_base_url(character: str):
  130. payload = {
  131. **VALID_CONFIG,
  132. "base_url": f"https://provider.example/v1{character}segment",
  133. }
  134. with pytest.raises(ValidationError, match="base_url"):
  135. BenchmarkConfig.model_validate(payload)
  136. def test_benchmark_config_accepts_printable_internationalized_base_url():
  137. base_url = "https://例え.テスト/路径/模型-🚀"
  138. config = BenchmarkConfig.model_validate({**VALID_CONFIG, "base_url": base_url})
  139. assert config.base_url == base_url
  140. @pytest.mark.parametrize("model", ["", " "])
  141. def test_benchmark_config_rejects_empty_models(model: str):
  142. payload = {**VALID_CONFIG, "model": model}
  143. with pytest.raises(ValidationError, match="model"):
  144. BenchmarkConfig.model_validate(payload)
  145. def test_benchmark_config_rejects_non_positive_runs_per_case():
  146. payload = {**VALID_CONFIG, "runs_per_case": 0}
  147. with pytest.raises(ValidationError, match="runs_per_case"):
  148. BenchmarkConfig.model_validate(payload)
  149. @pytest.mark.parametrize(
  150. "modes",
  151. [[], ["dual_agent", "dual_agent"], ["unsupported"]],
  152. )
  153. def test_benchmark_config_rejects_empty_duplicate_or_unknown_modes(
  154. modes: list[str],
  155. ):
  156. payload = {**VALID_CONFIG, "modes": modes}
  157. with pytest.raises(ValidationError, match="modes"):
  158. BenchmarkConfig.model_validate(payload)
  159. @pytest.mark.parametrize(
  160. "cases",
  161. [[], ["ordinary_chat", "ordinary_chat"], ["custom_case"]],
  162. )
  163. def test_benchmark_config_rejects_empty_duplicate_or_unknown_cases(
  164. cases: list[str],
  165. ):
  166. payload = {**VALID_CONFIG, "cases": cases}
  167. with pytest.raises(ValidationError, match="cases"):
  168. BenchmarkConfig.model_validate(payload)
  169. @pytest.mark.parametrize("field", ["api_key", "token", "unknown_field"])
  170. def test_benchmark_config_rejects_secrets_and_unknown_json_fields(field: str):
  171. secret_value = "must-not-appear-in-validation-errors"
  172. payload = {**VALID_CONFIG, field: secret_value}
  173. with pytest.raises(ValidationError) as exc_info:
  174. BenchmarkConfig.model_validate_json(json.dumps(payload))
  175. assert field in str(exc_info.value)
  176. assert secret_value not in str(exc_info.value)
  177. def test_builtin_case_catalog_is_complete_and_immutable():
  178. assert all(isinstance(case_id, StrEnum) for case_id in BENCHMARK_CASE_CATALOG)
  179. assert tuple(BENCHMARK_CASE_CATALOG) == BENCHMARK_CASE_IDS
  180. assert set(BENCHMARK_CASE_CATALOG) == set(BenchmarkCaseId)
  181. assert tuple(
  182. case.case_id for case in BENCHMARK_CASE_CATALOG.values()
  183. ) == BENCHMARK_CASE_IDS
  184. with pytest.raises(TypeError):
  185. BENCHMARK_CASE_CATALOG["ordinary_chat"] = BENCHMARK_CASE_CATALOG[
  186. "ordinary_chat"
  187. ]
  188. with pytest.raises(ValidationError, match="frozen"):
  189. BENCHMARK_CASE_CATALOG["ordinary_chat"].user_message = "changed"
  190. first_event = BENCHMARK_CASE_CATALOG["device_volume_silent"].mock_events[0]
  191. with pytest.raises(TypeError):
  192. first_event.arguments[0] = ("mode", "relative")
  193. def test_builtin_case_catalog_declares_expected_model_and_fallback_counts():
  194. assert {
  195. case_id: (
  196. case.expectation.expected_model_call_count,
  197. case.expectation.expected_fallback_count,
  198. )
  199. for case_id, case in BENCHMARK_CASE_CATALOG.items()
  200. } == {
  201. BenchmarkCaseId.ORDINARY_CHAT: (1, 0),
  202. BenchmarkCaseId.DEVICE_VOLUME_SILENT: (1, 0),
  203. BenchmarkCaseId.CALENDAR_SCHEDULE_TEMPLATE: (1, 0),
  204. BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS: (2, 0),
  205. BenchmarkCaseId.SESSION_TERMINATE: (1, 0),
  206. BenchmarkCaseId.PARALLEL_VOLUME_SCHEDULE: (1, 0),
  207. }
  208. @pytest.mark.parametrize(
  209. ("expected_model_call_count", "expected_fallback_count"),
  210. [(0, 0), (1, -1)],
  211. )
  212. def test_benchmark_expectation_rejects_invalid_expected_call_counts(
  213. expected_model_call_count: int,
  214. expected_fallback_count: int,
  215. ):
  216. with pytest.raises(ValidationError):
  217. BenchmarkExpectation(
  218. visible_messages=("answer",),
  219. event_names=(),
  220. answer_count=1,
  221. terminal=False,
  222. first_reply_before_tool=False,
  223. expected_model_call_count=expected_model_call_count,
  224. expected_fallback_count=expected_fallback_count,
  225. )
  226. def test_builtin_cases_own_semantics_and_deterministic_mock_data():
  227. actual = {
  228. case_id: {
  229. "user_message": case.user_message,
  230. "enabled_tools": case.enabled_tools,
  231. "events": case.expectation.event_names,
  232. "answer_count": case.expectation.answer_count,
  233. "terminal": case.expectation.terminal,
  234. "first_reply_before_tool": case.expectation.first_reply_before_tool,
  235. "expected_visible": case.expectation.visible_messages,
  236. "mock_visible": case.mock_visible_messages,
  237. "mock_events": tuple(
  238. (event.id, event.name, dict(event.arguments))
  239. for event in case.mock_events
  240. ),
  241. }
  242. for case_id, case in BENCHMARK_CASE_CATALOG.items()
  243. }
  244. assert actual == {
  245. "ordinary_chat": {
  246. "user_message": "Hello.",
  247. "enabled_tools": (
  248. "session.terminate",
  249. "device.volume.adjust",
  250. "calendar.schedule.create",
  251. "knowledge.web.search",
  252. ),
  253. "events": (),
  254. "answer_count": 1,
  255. "terminal": False,
  256. "first_reply_before_tool": False,
  257. "expected_visible": ("Ordinary answer.",),
  258. "mock_visible": ("Ordinary answer.",),
  259. "mock_events": (),
  260. },
  261. "device_volume_silent": {
  262. "user_message": "set volume to 30",
  263. "enabled_tools": ("device.volume.adjust",),
  264. "events": ("device.volume.adjust",),
  265. "answer_count": 1,
  266. "terminal": False,
  267. "first_reply_before_tool": True,
  268. "expected_visible": ("I will set the volume to 30.",),
  269. "mock_visible": ("I will set the volume to 30.",),
  270. "mock_events": (
  271. (
  272. "volume-1",
  273. "device.volume.adjust",
  274. {"mode": "absolute", "value": 30},
  275. ),
  276. ),
  277. },
  278. "calendar_schedule_template": {
  279. "user_message": (
  280. 'schedule "Design review" at 2026-07-14T09:30:00+08:00 '
  281. "timezone Asia/Shanghai"
  282. ),
  283. "enabled_tools": ("calendar.schedule.create",),
  284. "events": ("calendar.schedule.create",),
  285. "answer_count": 2,
  286. "terminal": False,
  287. "first_reply_before_tool": True,
  288. "expected_visible": (
  289. "I will schedule that.",
  290. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  291. "(Asia/Shanghai).",
  292. ),
  293. "mock_visible": ("I will schedule that.",),
  294. "mock_events": (
  295. (
  296. "schedule-1",
  297. "calendar.schedule.create",
  298. {
  299. "title": "Design review",
  300. "start_at": "2026-07-14T09:30:00+08:00",
  301. "timezone": "Asia/Shanghai",
  302. },
  303. ),
  304. ),
  305. },
  306. "web_search_two_answers": {
  307. "user_message": "event batch safety",
  308. "enabled_tools": ("knowledge.web.search",),
  309. "events": ("knowledge.web.search",),
  310. "answer_count": 2,
  311. "terminal": False,
  312. "first_reply_before_tool": True,
  313. "expected_visible": ("I will check.", "Grounded search update."),
  314. "mock_visible": ("I will check.", "Grounded search update."),
  315. "mock_events": (
  316. (
  317. "search-1",
  318. "knowledge.web.search",
  319. {"query": "event batch safety"},
  320. ),
  321. ),
  322. },
  323. "session_terminate": {
  324. "user_message": "end this session",
  325. "enabled_tools": ("session.terminate",),
  326. "events": ("session.terminate",),
  327. "answer_count": 1,
  328. "terminal": True,
  329. "first_reply_before_tool": True,
  330. "expected_visible": ("Goodbye.",),
  331. "mock_visible": ("Goodbye.",),
  332. "mock_events": (("terminate-1", "session.terminate", {}),),
  333. },
  334. "parallel_volume_schedule": {
  335. "user_message": (
  336. 'set volume to 30 and schedule "Design review" at '
  337. "2026-07-14T09:30:00+08:00 timezone Asia/Shanghai"
  338. ),
  339. "enabled_tools": (
  340. "device.volume.adjust",
  341. "calendar.schedule.create",
  342. ),
  343. "events": (
  344. "device.volume.adjust",
  345. "calendar.schedule.create",
  346. ),
  347. "answer_count": 2,
  348. "terminal": False,
  349. "first_reply_before_tool": True,
  350. "expected_visible": (
  351. "I will set the volume to 30 and schedule that.",
  352. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  353. "(Asia/Shanghai).",
  354. ),
  355. "mock_visible": (
  356. "I will set the volume to 30 and schedule that.",
  357. ),
  358. "mock_events": (
  359. (
  360. "parallel-volume",
  361. "device.volume.adjust",
  362. {"mode": "absolute", "value": 30},
  363. ),
  364. (
  365. "parallel-schedule",
  366. "calendar.schedule.create",
  367. {
  368. "title": "Design review",
  369. "start_at": "2026-07-14T09:30:00+08:00",
  370. "timezone": "Asia/Shanghai",
  371. },
  372. ),
  373. ),
  374. },
  375. }
  376. @pytest.mark.parametrize("case_id", BENCHMARK_CASE_IDS)
  377. def test_build_benchmark_request_uses_fixed_fairness_controls(
  378. case_id: BenchmarkCaseId,
  379. ):
  380. case = BENCHMARK_CASE_CATALOG[case_id]
  381. requests = {
  382. mode: build_benchmark_request(case, mode, "comparison-model")
  383. for mode in BENCHMARK_MODES
  384. }
  385. dual = requests["dual_agent"]
  386. tools = requests["chat_agent_tools"]
  387. assert dual.model_dump(exclude={"tool_invocation_mode"}) == tools.model_dump(
  388. exclude={"tool_invocation_mode"}
  389. )
  390. assert dual.user_message == case.user_message
  391. assert dual.system_prompts == ["Exercise the requested business scenario."]
  392. assert dual.pre_messages == []
  393. assert dual.chat_agent.model == "comparison-model"
  394. assert dual.chat_agent.temperature == 0.0
  395. assert dual.chat_agent.max_tokens == 128
  396. assert dual.event_agent.model == "comparison-model"
  397. assert dual.event_agent.temperature == 0.0
  398. assert dual.event_agent.max_tokens == 128
  399. assert tuple(dual.event_agent.enabled_tools) == case.enabled_tools
  400. assert dual.event_agent.max_event_loops == 1
  401. assert dual.event_agent.max_parallel_events == 2
  402. assert dual.event_agent.batch_timeout_seconds == 1.0
  403. assert dual.tool_invocation_mode == "dual_agent"
  404. assert tools.tool_invocation_mode == "chat_agent_tools"
  405. @pytest.mark.parametrize("mode", BENCHMARK_MODES)
  406. def test_benchmark_helpers_accept_enum_and_string_modes(mode: BenchmarkMode):
  407. assert isinstance(mode, StrEnum)
  408. case = BENCHMARK_CASE_CATALOG["ordinary_chat"]
  409. enum_request = build_benchmark_request(case, mode, "comparison-model")
  410. string_request = build_benchmark_request(case, mode.value, "comparison-model")
  411. assert enum_request == string_request
  412. assert enum_request.tool_invocation_mode == mode.value
  413. assert type(enum_request.tool_invocation_mode) is str
  414. assert build_mock_rounds(case, mode) == build_mock_rounds(case, mode.value)
  415. def test_build_benchmark_request_rejects_an_empty_model():
  416. case = BENCHMARK_CASE_CATALOG["ordinary_chat"]
  417. with pytest.raises(ValueError, match="model"):
  418. build_benchmark_request(case, "dual_agent", " ")
  419. @pytest.mark.parametrize("mode", BENCHMARK_MODES)
  420. @pytest.mark.parametrize("case_id", BENCHMARK_CASE_IDS)
  421. def test_mock_rounds_are_mode_correct_and_derived_from_catalog(
  422. mode: BenchmarkMode,
  423. case_id: BenchmarkCaseId,
  424. ):
  425. case = BENCHMARK_CASE_CATALOG[case_id]
  426. rounds = build_mock_rounds(case, mode)
  427. items = tuple(item for round_items in rounds for item in round_items)
  428. message_items = tuple(item for item in items if item.kind == "message_delta")
  429. event_items = tuple(item for item in items if item.event is not None)
  430. assert tuple(item.content for item in message_items) == case.mock_visible_messages
  431. assert tuple(item.event.name for item in event_items) == case.expectation.event_names
  432. assert tuple(item.event.id for item in event_items) == tuple(
  433. event.id for event in case.mock_events
  434. )
  435. assert tuple(item.event.arguments for item in event_items) == tuple(
  436. dict(event.arguments) for event in case.mock_events
  437. )
  438. expected_kind = "text_event" if mode == "dual_agent" else "provider_tool_call"
  439. assert {item.kind for item in event_items} <= {expected_kind}
  440. assert len(rounds) == max(1, len(case.mock_visible_messages))