test_benchmark_runner.py 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096
  1. import asyncio
  2. from collections.abc import AsyncIterator
  3. from typing import Any
  4. import pytest
  5. from pydantic import ValidationError
  6. from agent_lab.application import benchmark
  7. from agent_lab.application.contracts import AgentParams
  8. from agent_lab.application.events import ResultPolicy
  9. from agent_lab.application.tools import ToolDefinition, ToolRegistry
  10. from agent_lab.domain.events import ToolCallEvent
  11. from agent_lab.domain.messages import ChatMessage, StreamItem
  12. from agent_lab.domain.messages import TokenUsage
  13. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  14. class ManualClock:
  15. def __init__(self) -> None:
  16. self.now = 0.0
  17. def __call__(self) -> float:
  18. return self.now
  19. class ScriptedTimingClient:
  20. def __init__(self, clock: ManualClock, *, fail: bool = False) -> None:
  21. self.clock = clock
  22. self.fail = fail
  23. self.closed = False
  24. async def stream_chat(
  25. self,
  26. messages: list[ChatMessage],
  27. tools: list[dict[str, Any]],
  28. params: AgentParams,
  29. tool_choice: dict[str, Any] | None = None,
  30. ) -> AsyncIterator[StreamItem]:
  31. del messages, tools, params, tool_choice
  32. self.clock.now = 0.011
  33. yield StreamItem.raw_response_chunk({"provider": "first"})
  34. if self.fail:
  35. self.clock.now = 0.023
  36. raise RuntimeError("stream failed")
  37. self.clock.now = 0.037
  38. yield StreamItem.message_delta("visible")
  39. self.clock.now = 0.041
  40. yield StreamItem.usage_item(TokenUsage(total_tokens=3))
  41. self.clock.now = 0.059
  42. yield StreamItem.usage_item(TokenUsage(total_tokens=5))
  43. self.clock.now = 0.071
  44. async def aclose(self) -> None:
  45. self.closed = True
  46. class WhitespaceTimingClient:
  47. def __init__(self, clock: ManualClock) -> None:
  48. self.clock = clock
  49. async def stream_chat(
  50. self,
  51. messages: list[ChatMessage],
  52. tools: list[dict[str, Any]],
  53. params: AgentParams,
  54. tool_choice: dict[str, Any] | None = None,
  55. ) -> AsyncIterator[StreamItem]:
  56. del messages, tools, params, tool_choice
  57. self.clock.now = 0.011
  58. yield StreamItem.raw_response_chunk({"provider": "first"})
  59. self.clock.now = 0.019
  60. yield StreamItem.message_delta("")
  61. self.clock.now = 0.023
  62. yield StreamItem.message_delta(" \n")
  63. self.clock.now = 0.037
  64. yield StreamItem.message_delta("visible")
  65. self.clock.now = 0.050
  66. yield StreamItem.usage_item(TokenUsage(total_tokens=5))
  67. async def aclose(self) -> None:
  68. return None
  69. class FallbackBenchmarkClient:
  70. def __init__(self) -> None:
  71. self.calls = 0
  72. self.closed = False
  73. async def stream_chat(
  74. self,
  75. messages: list[ChatMessage],
  76. tools: list[dict[str, Any]],
  77. params: AgentParams,
  78. tool_choice: dict[str, Any] | None = None,
  79. ) -> AsyncIterator[StreamItem]:
  80. del messages, tools, params
  81. self.calls += 1
  82. if self.calls == 1:
  83. assert tool_choice is None
  84. yield StreamItem.raw_response_chunk({"chat": 1})
  85. yield StreamItem.message_delta("I will set the volume to 30.")
  86. yield StreamItem.text_event(
  87. ToolCallEvent(
  88. id="volume-1",
  89. name="device.volume.adjust",
  90. arguments={"mode": "absolute"},
  91. raw_arguments='{"mode":"absolute"}',
  92. )
  93. )
  94. yield StreamItem.usage_item(TokenUsage(total_tokens=5))
  95. return
  96. if self.calls == 2:
  97. assert tool_choice is not None
  98. yield StreamItem.raw_response_chunk({"fallback": 1})
  99. yield StreamItem.provider_tool_call(
  100. ToolCallEvent(
  101. id="fallback-volume",
  102. name="device.volume.adjust",
  103. arguments={"mode": "absolute", "value": 30},
  104. raw_arguments='{"mode":"absolute","value":30}',
  105. )
  106. )
  107. yield StreamItem.usage_item(TokenUsage(total_tokens=7))
  108. return
  109. raise RuntimeError("unexpected fallback client call")
  110. async def aclose(self) -> None:
  111. self.closed = True
  112. class RecordingSQLiteSessionStore(SQLiteSessionStore):
  113. instances: list["RecordingSQLiteSessionStore"] = []
  114. def __init__(self, database_path: str) -> None:
  115. super().__init__(database_path)
  116. self.instances.append(self)
  117. class BlockingTimingClient:
  118. def __init__(self, clock: ManualClock) -> None:
  119. self.clock = clock
  120. self.blocked = asyncio.Event()
  121. self.closed = False
  122. async def stream_chat(
  123. self,
  124. messages: list[ChatMessage],
  125. tools: list[dict[str, Any]],
  126. params: AgentParams,
  127. tool_choice: dict[str, Any] | None = None,
  128. ) -> AsyncIterator[StreamItem]:
  129. del messages, tools, params, tool_choice
  130. self.clock.now = 0.010
  131. yield StreamItem.raw_response_chunk({"first": True})
  132. self.blocked.set()
  133. await asyncio.Event().wait()
  134. async def aclose(self) -> None:
  135. self.closed = True
  136. class NoAnswerClient:
  137. def __init__(self) -> None:
  138. self.closed = False
  139. async def stream_chat(
  140. self,
  141. messages: list[ChatMessage],
  142. tools: list[dict[str, Any]],
  143. params: AgentParams,
  144. tool_choice: dict[str, Any] | None = None,
  145. ) -> AsyncIterator[StreamItem]:
  146. del messages, tools, params, tool_choice
  147. yield StreamItem.raw_response_chunk({"no_answer": True})
  148. yield StreamItem.usage_item(TokenUsage(total_tokens=1))
  149. async def aclose(self) -> None:
  150. self.closed = True
  151. class ConcurrentTimingClient:
  152. def __init__(self) -> None:
  153. self.calls = 0
  154. self.first_started = asyncio.Event()
  155. self.release_first = asyncio.Event()
  156. async def stream_chat(
  157. self,
  158. messages: list[ChatMessage],
  159. tools: list[dict[str, Any]],
  160. params: AgentParams,
  161. tool_choice: dict[str, Any] | None = None,
  162. ) -> AsyncIterator[StreamItem]:
  163. del messages, tools, params, tool_choice
  164. self.calls += 1
  165. call_number = self.calls
  166. yield StreamItem.raw_response_chunk({"call": call_number})
  167. if call_number == 1:
  168. self.first_started.set()
  169. await self.release_first.wait()
  170. yield StreamItem.usage_item(TokenUsage(total_tokens=call_number))
  171. async def aclose(self) -> None:
  172. return None
  173. def test_benchmark_result_models_are_strict_and_allow_nullable_metrics():
  174. timing_type = getattr(benchmark, "BenchmarkModelCallTiming")
  175. result_type = getattr(benchmark, "BenchmarkRunResult")
  176. timing = timing_type(
  177. call_index=1,
  178. call_kind="chat_completion",
  179. first_item_kind="raw_chunk",
  180. provider_ttft_ms=11,
  181. visible_ttft_ms=37,
  182. elapsed_ms=59,
  183. usage=TokenUsage(
  184. prompt_tokens=2,
  185. completion_tokens=3,
  186. total_tokens=5,
  187. cached_tokens=1,
  188. ),
  189. )
  190. result = result_type(
  191. case_id=benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  192. mode=benchmark.BenchmarkMode.DUAL_AGENT,
  193. iteration=1,
  194. status="failed",
  195. initial_provider_ttft_ms=None,
  196. visible_ttft_ms=None,
  197. turn_wall_time_ms=None,
  198. prompt_tokens=None,
  199. completion_tokens=None,
  200. total_tokens=None,
  201. cached_tokens=None,
  202. model_call_count=None,
  203. fallback_count=None,
  204. tool_count=None,
  205. event_names=[],
  206. batch_event_names=[],
  207. tool_event_names=[],
  208. event_sources=[],
  209. tool_statuses=[],
  210. tool_latencies_ms=[],
  211. semantic_failures=["missing visible answer"],
  212. error=None,
  213. model_calls=[timing],
  214. )
  215. assert result.model_calls == [timing]
  216. assert result.initial_provider_ttft_ms is None
  217. with pytest.raises(ValidationError):
  218. timing_type(
  219. call_index="1",
  220. call_kind="chat_completion",
  221. first_item_kind=None,
  222. provider_ttft_ms=None,
  223. visible_ttft_ms=None,
  224. elapsed_ms=None,
  225. usage=None,
  226. )
  227. with pytest.raises(ValidationError):
  228. result_type.model_validate(result.model_dump() | {"unexpected": True})
  229. @pytest.mark.asyncio
  230. async def test_timing_client_keeps_provider_visible_and_elapsed_timings_separate():
  231. clock = ManualClock()
  232. inner = ScriptedTimingClient(clock)
  233. client = benchmark.TimingChatClient(inner, clock=clock)
  234. items = [
  235. item
  236. async for item in client.stream_chat(
  237. messages=[],
  238. tools=[],
  239. params=AgentParams(model="benchmark-model"),
  240. )
  241. ]
  242. assert [item.kind for item in items] == [
  243. "raw_chunk",
  244. "message_delta",
  245. "usage",
  246. "usage",
  247. ]
  248. assert client.timings == [
  249. benchmark.BenchmarkModelCallTiming(
  250. call_index=1,
  251. call_kind="chat_completion",
  252. first_item_kind="raw_chunk",
  253. provider_ttft_ms=11,
  254. visible_ttft_ms=37,
  255. elapsed_ms=71,
  256. usage=TokenUsage(total_tokens=5),
  257. )
  258. ]
  259. @pytest.mark.asyncio
  260. async def test_timing_client_ignores_blank_deltas_for_visible_ttft():
  261. clock = ManualClock()
  262. client = benchmark.TimingChatClient(
  263. WhitespaceTimingClient(clock),
  264. clock=clock,
  265. )
  266. async for _ in client.stream_chat(
  267. messages=[],
  268. tools=[],
  269. params=AgentParams(model="benchmark-model"),
  270. ):
  271. pass
  272. assert client.timings[0].provider_ttft_ms == 11
  273. assert client.timings[0].visible_ttft_ms == 37
  274. @pytest.mark.asyncio
  275. async def test_benchmark_output_queue_ignores_blank_deltas_for_visible_ttft():
  276. clock = ManualClock()
  277. queue = benchmark._BenchmarkOutputQueue(clock)
  278. clock.now = 0.019
  279. await queue.put({"type": "message_delta", "content": ""})
  280. clock.now = 0.023
  281. await queue.put({"type": "message_delta", "content": " \n\t "})
  282. assert queue.first_message_delta_at is None
  283. clock.now = 0.037
  284. await queue.put({"type": "message_delta", "content": "visible"})
  285. assert queue.first_message_delta_at == 0.037
  286. @pytest.mark.asyncio
  287. async def test_timing_client_records_failed_fallback_and_closes_inner_client():
  288. clock = ManualClock()
  289. inner = ScriptedTimingClient(clock, fail=True)
  290. client = benchmark.TimingChatClient(inner, clock=clock)
  291. with pytest.raises(RuntimeError, match="stream failed"):
  292. async for _ in client.stream_chat(
  293. messages=[],
  294. tools=[{"type": "function", "function": {"name": "mock"}}],
  295. params=AgentParams(model="benchmark-model"),
  296. tool_choice={"type": "function", "function": {"name": "mock"}},
  297. ):
  298. pass
  299. await client.aclose()
  300. assert inner.closed is True
  301. assert client.timings == [
  302. benchmark.BenchmarkModelCallTiming(
  303. call_index=1,
  304. call_kind="argument_fallback",
  305. first_item_kind="raw_chunk",
  306. provider_ttft_ms=11,
  307. visible_ttft_ms=None,
  308. elapsed_ms=23,
  309. usage=None,
  310. )
  311. ]
  312. @pytest.mark.asyncio
  313. async def test_timing_client_finishes_timing_when_stream_is_cancelled():
  314. clock = ManualClock()
  315. inner = BlockingTimingClient(clock)
  316. client = benchmark.TimingChatClient(inner, clock=clock)
  317. async def consume() -> None:
  318. async for _ in client.stream_chat(
  319. messages=[],
  320. tools=[],
  321. params=AgentParams(model="benchmark-model"),
  322. ):
  323. pass
  324. task = asyncio.create_task(consume())
  325. await inner.blocked.wait()
  326. clock.now = 0.025
  327. task.cancel()
  328. await asyncio.gather(task, return_exceptions=True)
  329. await client.aclose()
  330. assert client.timings[0].first_item_kind == "raw_chunk"
  331. assert client.timings[0].provider_ttft_ms == 10
  332. assert client.timings[0].visible_ttft_ms is None
  333. assert client.timings[0].elapsed_ms == 25
  334. assert inner.closed is True
  335. @pytest.mark.asyncio
  336. async def test_timing_client_indexes_concurrent_calls_by_start_order():
  337. inner = ConcurrentTimingClient()
  338. client = benchmark.TimingChatClient(inner)
  339. async def collect() -> None:
  340. async for _ in client.stream_chat(
  341. messages=[],
  342. tools=[],
  343. params=AgentParams(model="benchmark-model"),
  344. ):
  345. pass
  346. first = asyncio.create_task(collect())
  347. await inner.first_started.wait()
  348. await collect()
  349. inner.release_first.set()
  350. await first
  351. assert [timing.call_index for timing in client.timings] == [1, 2]
  352. assert [timing.usage.total_tokens for timing in client.timings] == [1, 2]
  353. @pytest.mark.asyncio
  354. @pytest.mark.parametrize("mode", benchmark.BENCHMARK_MODES)
  355. @pytest.mark.parametrize("case_id", benchmark.BENCHMARK_CASE_IDS)
  356. async def test_mock_benchmark_client_emits_raw_semantics_and_fixed_usage(
  357. case_id: benchmark.BenchmarkCaseId,
  358. mode: benchmark.BenchmarkMode,
  359. ):
  360. case = benchmark.BENCHMARK_CASE_CATALOG[case_id]
  361. client = benchmark.MockBenchmarkChatClient(case, mode)
  362. expected_rounds = benchmark.build_mock_rounds(case, mode)
  363. emitted_rounds = []
  364. for _ in expected_rounds:
  365. emitted_rounds.append(
  366. [
  367. item
  368. async for item in client.stream_chat(
  369. messages=[],
  370. tools=[],
  371. params=AgentParams(model="benchmark-model"),
  372. )
  373. ]
  374. )
  375. await client.aclose()
  376. assert [items[0].kind for items in emitted_rounds] == [
  377. "raw_chunk"
  378. ] * len(expected_rounds)
  379. assert [items[1:-1] for items in emitted_rounds] == expected_rounds
  380. assert [items[-1].usage for items in emitted_rounds] == [
  381. TokenUsage(
  382. prompt_tokens=10,
  383. completion_tokens=5,
  384. total_tokens=15,
  385. cached_tokens=2,
  386. )
  387. ] * len(expected_rounds)
  388. with pytest.raises(RuntimeError, match=f"mock benchmark stream exhausted: {case_id}"):
  389. async for _ in client.stream_chat(
  390. messages=[],
  391. tools=[],
  392. params=AgentParams(model="benchmark-model"),
  393. ):
  394. pass
  395. @pytest.mark.asyncio
  396. async def test_mock_runner_is_ordered_ignores_factory_and_reports_event_ledgers():
  397. config = benchmark.BenchmarkConfig(
  398. schema_version=1,
  399. base_url="https://provider.example/v1",
  400. model="benchmark-model",
  401. runs_per_case=2,
  402. cases=[
  403. benchmark.BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  404. benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  405. ],
  406. modes=[
  407. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  408. benchmark.BenchmarkMode.DUAL_AGENT,
  409. ],
  410. )
  411. factory_calls = 0
  412. def forbidden_factory(config: benchmark.BenchmarkConfig, api_key: str):
  413. del config, api_key
  414. nonlocal factory_calls
  415. factory_calls += 1
  416. raise AssertionError("mock mode must not call client_factory")
  417. results = await benchmark.BenchmarkRunner(
  418. config,
  419. api_key=None,
  420. mock=True,
  421. client_factory=forbidden_factory,
  422. ).run()
  423. assert factory_calls == 0
  424. assert [(item.case_id, item.mode, item.iteration) for item in results] == [
  425. (case_id, mode, iteration)
  426. for case_id in config.cases
  427. for mode in config.modes
  428. for iteration in range(1, 3)
  429. ]
  430. assert {item.status for item in results} == {"passed"}
  431. search_results = [
  432. item
  433. for item in results
  434. if item.case_id is benchmark.BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS
  435. ]
  436. assert {item.model_call_count for item in search_results} == {2}
  437. assert {item.fallback_count for item in search_results} == {0}
  438. assert {item.tool_count for item in search_results} == {1}
  439. assert {tuple(item.event_names) for item in search_results} == {
  440. ("knowledge.web.search",)
  441. }
  442. assert {tuple(item.batch_event_names) for item in search_results} == {
  443. ("knowledge.web.search",)
  444. }
  445. assert {tuple(item.tool_event_names) for item in search_results} == {
  446. ("knowledge.web.search",)
  447. }
  448. assert {tuple(item.tool_statuses) for item in search_results} == {("success",)}
  449. assert {
  450. tuple(item.event_sources) for item in search_results
  451. } == {("provider_resolved",), ("text_event",)}
  452. assert all(len(item.tool_latencies_ms) == 1 for item in search_results)
  453. assert all(
  454. latency is not None and latency >= 0
  455. for item in search_results
  456. for latency in item.tool_latencies_ms
  457. )
  458. ordinary_results = [
  459. item
  460. for item in results
  461. if item.case_id is benchmark.BenchmarkCaseId.ORDINARY_CHAT
  462. ]
  463. assert all(item.event_names == [] for item in ordinary_results)
  464. assert all(item.batch_event_names == [] for item in ordinary_results)
  465. assert all(item.tool_event_names == [] for item in ordinary_results)
  466. assert all(item.tool_count == 0 for item in ordinary_results)
  467. @pytest.mark.asyncio
  468. async def test_mock_runner_passes_all_catalog_cases_in_both_modes():
  469. config = benchmark.BenchmarkConfig(
  470. schema_version=1,
  471. base_url="https://provider.example/v1",
  472. model="benchmark-model",
  473. )
  474. results = await benchmark.BenchmarkRunner(config, api_key=None, mock=True).run()
  475. assert len(results) == 12
  476. assert all(result.status == "passed" for result in results)
  477. assert all(result.semantic_failures == [] for result in results)
  478. @pytest.mark.asyncio
  479. async def test_runner_uses_a_fresh_in_memory_store_and_unique_session_per_run(monkeypatch):
  480. RecordingSQLiteSessionStore.instances = []
  481. monkeypatch.setattr(benchmark, "SQLiteSessionStore", RecordingSQLiteSessionStore)
  482. config = benchmark.BenchmarkConfig(
  483. schema_version=1,
  484. base_url="https://provider.example/v1",
  485. model="benchmark-model",
  486. runs_per_case=2,
  487. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  488. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  489. )
  490. results = await benchmark.BenchmarkRunner(config, api_key=None, mock=True).run()
  491. assert len(results) == 2
  492. assert len(RecordingSQLiteSessionStore.instances) == 2
  493. sessions = [store.list_sessions() for store in RecordingSQLiteSessionStore.instances]
  494. assert all(store.database_path == ":memory:" for store in RecordingSQLiteSessionStore.instances)
  495. assert [len(records) for records in sessions] == [1, 1]
  496. assert len({records[0]["id"] for records in sessions}) == 2
  497. @pytest.mark.asyncio
  498. async def test_runner_keeps_provider_visible_and_turn_wall_time_distinct():
  499. clock = ManualClock()
  500. client = ScriptedTimingClient(clock)
  501. config = benchmark.BenchmarkConfig(
  502. schema_version=1,
  503. base_url="https://provider.example/v1",
  504. model="benchmark-model",
  505. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  506. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  507. )
  508. result = (
  509. await benchmark.BenchmarkRunner(
  510. config,
  511. api_key="test-key",
  512. client_factory=lambda config, api_key: client,
  513. clock=clock,
  514. ).run()
  515. )[0]
  516. assert result.status == "passed"
  517. assert result.initial_provider_ttft_ms == 11
  518. assert result.visible_ttft_ms == 37
  519. assert result.turn_wall_time_ms == 71
  520. assert result.model_calls[0].elapsed_ms == 71
  521. assert client.closed is True
  522. @pytest.mark.asyncio
  523. async def test_runner_continues_after_one_factory_failure():
  524. config = benchmark.BenchmarkConfig(
  525. schema_version=1,
  526. base_url="https://provider.example/v1",
  527. model="benchmark-model",
  528. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  529. modes=[
  530. benchmark.BenchmarkMode.DUAL_AGENT,
  531. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  532. ],
  533. )
  534. clients: list[ScriptedTimingClient] = []
  535. calls = 0
  536. def factory(config: benchmark.BenchmarkConfig, api_key: str):
  537. del config, api_key
  538. nonlocal calls
  539. calls += 1
  540. if calls == 1:
  541. raise RuntimeError("first factory failed")
  542. clock = ManualClock()
  543. client = ScriptedTimingClient(clock)
  544. clients.append(client)
  545. return client
  546. results = await benchmark.BenchmarkRunner(
  547. config,
  548. api_key="test-key",
  549. client_factory=factory,
  550. ).run()
  551. assert [result.status for result in results] == ["failed", "passed"]
  552. assert results[0].error == "first factory failed"
  553. assert results[0].model_call_count == 0
  554. assert results[0].fallback_count == 0
  555. assert results[0].prompt_tokens is None
  556. assert results[0].completion_tokens is None
  557. assert results[0].total_tokens is None
  558. assert results[0].cached_tokens is None
  559. assert results[1].error is None
  560. assert clients[0].closed is True
  561. @pytest.mark.asyncio
  562. async def test_runner_marks_semantic_mismatch_failed_and_continues():
  563. config = benchmark.BenchmarkConfig(
  564. schema_version=1,
  565. base_url="https://provider.example/v1",
  566. model="benchmark-model",
  567. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  568. modes=[
  569. benchmark.BenchmarkMode.DUAL_AGENT,
  570. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  571. ],
  572. )
  573. failed_client = NoAnswerClient()
  574. calls = 0
  575. def factory(config: benchmark.BenchmarkConfig, api_key: str):
  576. del config, api_key
  577. nonlocal calls
  578. calls += 1
  579. if calls == 1:
  580. return failed_client
  581. return benchmark.MockBenchmarkChatClient(
  582. benchmark.BENCHMARK_CASE_CATALOG[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  583. benchmark.BenchmarkMode.CHAT_AGENT_TOOLS,
  584. )
  585. results = await benchmark.BenchmarkRunner(
  586. config,
  587. api_key="test-key",
  588. client_factory=factory,
  589. ).run()
  590. assert [result.status for result in results] == ["failed", "passed"]
  591. assert results[0].error is None
  592. assert results[0].semantic_failures == ["answer_count expected 1, got 0"]
  593. assert failed_client.closed is True
  594. @pytest.mark.asyncio
  595. async def test_runner_reports_argument_fallback_model_and_tool_ledgers(monkeypatch):
  596. client = FallbackBenchmarkClient()
  597. registry = ToolRegistry(
  598. [
  599. ToolDefinition(
  600. name="device.volume.adjust",
  601. description="Force fallback for benchmark ledger coverage.",
  602. parameters={
  603. "type": "object",
  604. "properties": {
  605. "mode": {"type": "string"},
  606. "value": {"type": "integer"},
  607. },
  608. "required": ["mode", "value"],
  609. },
  610. handler=lambda event: {
  611. "tool": event.name,
  612. "status": "payload-status-is-not-kernel-status",
  613. },
  614. argument_resolver=lambda event, context: {},
  615. result_policy=ResultPolicy.SILENT_SUCCESS,
  616. )
  617. ]
  618. )
  619. monkeypatch.setattr(benchmark, "build_default_tool_registry", lambda: registry)
  620. config = benchmark.BenchmarkConfig(
  621. schema_version=1,
  622. base_url="https://provider.example/v1",
  623. model="benchmark-model",
  624. cases=[benchmark.BenchmarkCaseId.DEVICE_VOLUME_SILENT],
  625. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  626. )
  627. result = (
  628. await benchmark.BenchmarkRunner(
  629. config,
  630. api_key="test-key",
  631. client_factory=lambda config, api_key: client,
  632. ).run()
  633. )[0]
  634. assert result.status == "failed"
  635. assert result.model_call_count == 1
  636. assert result.fallback_count == 1
  637. assert result.tool_count == 1
  638. assert result.total_tokens == 12
  639. assert [timing.call_kind for timing in result.model_calls] == [
  640. "chat_completion",
  641. "argument_fallback",
  642. ]
  643. assert result.event_names == ["device.volume.adjust"]
  644. assert result.event_sources == ["text_event"]
  645. assert result.tool_statuses == ["success"]
  646. assert result.semantic_failures == [
  647. "fallback_count expected 0, ledger got 1",
  648. "fallback_count expected 0, timing got 1",
  649. ]
  650. assert client.calls == 2
  651. assert client.closed is True
  652. def test_live_runner_requires_an_api_key():
  653. config = benchmark.BenchmarkConfig(
  654. schema_version=1,
  655. base_url="https://provider.example/v1",
  656. model="benchmark-model",
  657. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  658. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  659. )
  660. with pytest.raises(ValueError, match="api_key"):
  661. benchmark.BenchmarkRunner(config, api_key=None)
  662. def test_visible_answer_order_ignores_empty_deltas_before_a_tool_result():
  663. answers, first_answer_index, first_tool_index = (
  664. benchmark.BenchmarkRunner._visible_answers(
  665. [
  666. {"type": "message_delta", "content": " "},
  667. {"type": "tool_result", "message": {}},
  668. {"type": "message_delta", "content": "late answer"},
  669. ]
  670. )
  671. )
  672. assert answers == ["late answer"]
  673. assert first_answer_index == 2
  674. assert first_tool_index == 1
  675. def _semantic_failures_for_counts(
  676. *,
  677. ledger_model_count: int,
  678. timing_model_count: int,
  679. ledger_fallback_count: int = 0,
  680. timing_fallback_count: int = 0,
  681. ) -> list[str]:
  682. config = benchmark.BenchmarkConfig(
  683. schema_version=1,
  684. base_url="https://provider.example/v1",
  685. model="benchmark-model",
  686. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  687. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  688. )
  689. runner = benchmark.BenchmarkRunner(config, api_key=None, mock=True)
  690. timings = [
  691. benchmark.BenchmarkModelCallTiming(
  692. call_index=index,
  693. call_kind=call_kind,
  694. first_item_kind="raw_chunk",
  695. provider_ttft_ms=0,
  696. visible_ttft_ms=0 if call_kind == "chat_completion" else None,
  697. elapsed_ms=0,
  698. usage=TokenUsage(),
  699. )
  700. for index, call_kind in enumerate(
  701. ["chat_completion"] * timing_model_count
  702. + ["argument_fallback"] * timing_fallback_count,
  703. start=1,
  704. )
  705. ]
  706. return runner._semantic_failures(
  707. case=benchmark.BENCHMARK_CASE_CATALOG[
  708. benchmark.BenchmarkCaseId.ORDINARY_CHAT
  709. ],
  710. mode=benchmark.BenchmarkMode.DUAL_AGENT,
  711. outputs=[{"type": "message_delta", "content": "Ordinary answer."}],
  712. audits=[],
  713. event_names=[],
  714. batch_event_names=[],
  715. tool_event_names=[],
  716. event_sources=[],
  717. tool_statuses=[],
  718. model_call_count=ledger_model_count,
  719. fallback_count=ledger_fallback_count,
  720. tool_count=0,
  721. timings=timings,
  722. check_mock_text=True,
  723. )
  724. @pytest.mark.parametrize("actual_count", [0, 2])
  725. def test_semantics_reject_missing_or_extra_model_calls_even_when_ledgers_agree(
  726. actual_count: int,
  727. ):
  728. failures = _semantic_failures_for_counts(
  729. ledger_model_count=actual_count,
  730. timing_model_count=actual_count,
  731. )
  732. assert failures == [
  733. f"model_call_count expected 1, ledger got {actual_count}",
  734. f"model_call_count expected 1, timing got {actual_count}",
  735. ]
  736. def test_semantics_reject_extra_fallback_calls_even_when_ledgers_agree():
  737. failures = _semantic_failures_for_counts(
  738. ledger_model_count=1,
  739. timing_model_count=1,
  740. ledger_fallback_count=1,
  741. timing_fallback_count=1,
  742. )
  743. assert failures == [
  744. "fallback_count expected 0, ledger got 1",
  745. "fallback_count expected 0, timing got 1",
  746. ]
  747. def test_semantics_keep_timing_vs_ledger_consistency_failure():
  748. failures = _semantic_failures_for_counts(
  749. ledger_model_count=1,
  750. timing_model_count=2,
  751. )
  752. assert failures == [
  753. "model_call_count expected 1, timing got 2",
  754. "model_call_count ledger=1, timing=2",
  755. ]
  756. def test_build_result_rejects_wrong_event_identity_in_all_ledgers_and_pairs_status():
  757. config = benchmark.BenchmarkConfig(
  758. schema_version=1,
  759. base_url="https://provider.example/v1",
  760. model="benchmark-model",
  761. cases=[benchmark.BenchmarkCaseId.DEVICE_VOLUME_SILENT],
  762. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  763. )
  764. runner = benchmark.BenchmarkRunner(config, api_key=None, mock=True)
  765. timing = benchmark.BenchmarkModelCallTiming(
  766. call_index=1,
  767. call_kind="chat_completion",
  768. first_item_kind="raw_chunk",
  769. provider_ttft_ms=1,
  770. visible_ttft_ms=2,
  771. elapsed_ms=3,
  772. usage=TokenUsage(total_tokens=5),
  773. )
  774. result = runner._build_result(
  775. case=benchmark.BENCHMARK_CASE_CATALOG[
  776. benchmark.BenchmarkCaseId.DEVICE_VOLUME_SILENT
  777. ],
  778. mode=benchmark.BenchmarkMode.DUAL_AGENT,
  779. iteration=1,
  780. outputs=[
  781. {
  782. "type": "message_delta",
  783. "content": "I will set the volume to 30.",
  784. },
  785. {"type": "tool_result", "message": {}},
  786. ],
  787. audits=[
  788. {
  789. "event": "chat_event_detected",
  790. "details": {
  791. "event_name": "wrong.detected",
  792. "event_source": "text_event",
  793. },
  794. },
  795. {
  796. "event": "event_batch_results",
  797. "details": {
  798. "results": [
  799. {"event_name": "wrong.batch", "status": "success"}
  800. ]
  801. },
  802. },
  803. ],
  804. usage={
  805. "calls": [
  806. {
  807. "call_kind": "chat_completion",
  808. "event_name": None,
  809. "tool_latency_ms": None,
  810. },
  811. {
  812. "call_kind": "tool_execution",
  813. "event_name": "wrong.tool",
  814. "tool_latency_ms": 4,
  815. },
  816. ],
  817. "session": {
  818. "prompt_tokens": 2,
  819. "completion_tokens": 3,
  820. "total_tokens": 5,
  821. "cached_tokens": 0,
  822. "fallback_count": 0,
  823. "tool_count": 1,
  824. "turn_wall_time_ms": 10,
  825. },
  826. },
  827. timings=[timing],
  828. visible_ttft_ms=2,
  829. runtime_error=None,
  830. )
  831. assert result.status == "failed"
  832. assert result.event_names == ["wrong.detected"]
  833. assert result.batch_event_names == ["wrong.batch"]
  834. assert result.tool_event_names == ["wrong.tool"]
  835. assert result.tool_statuses == ["success"]
  836. assert result.semantic_failures == [
  837. "event names expected ('device.volume.adjust',), got ('wrong.detected',)",
  838. "batch event names expected ('device.volume.adjust',), got ('wrong.batch',)",
  839. "tool event names expected ('device.volume.adjust',), got ('wrong.tool',)",
  840. "event batch statuses expected "
  841. "(('device.volume.adjust', 'success'),), got (('wrong.batch', 'success'),)",
  842. ]
  843. def test_build_result_reports_timing_attempt_counts_and_flags_ledger_mismatch():
  844. config = benchmark.BenchmarkConfig(
  845. schema_version=1,
  846. base_url="https://provider.example/v1",
  847. model="benchmark-model",
  848. cases=[benchmark.BenchmarkCaseId.ORDINARY_CHAT],
  849. modes=[benchmark.BenchmarkMode.DUAL_AGENT],
  850. )
  851. runner = benchmark.BenchmarkRunner(config, api_key=None, mock=True)
  852. timing = benchmark.BenchmarkModelCallTiming(
  853. call_index=1,
  854. call_kind="chat_completion",
  855. first_item_kind="raw_chunk",
  856. provider_ttft_ms=1,
  857. visible_ttft_ms=2,
  858. elapsed_ms=3,
  859. usage=TokenUsage(total_tokens=5),
  860. )
  861. result = runner._build_result(
  862. case=benchmark.BENCHMARK_CASE_CATALOG[
  863. benchmark.BenchmarkCaseId.ORDINARY_CHAT
  864. ],
  865. mode=benchmark.BenchmarkMode.DUAL_AGENT,
  866. iteration=1,
  867. outputs=[{"type": "message_delta", "content": "Ordinary answer."}],
  868. audits=[],
  869. usage={
  870. "calls": [],
  871. "session": {
  872. "prompt_tokens": 0,
  873. "completion_tokens": 0,
  874. "total_tokens": 0,
  875. "cached_tokens": 0,
  876. "fallback_count": 0,
  877. "tool_count": 0,
  878. "turn_wall_time_ms": 3,
  879. },
  880. },
  881. timings=[timing],
  882. visible_ttft_ms=2,
  883. runtime_error=None,
  884. )
  885. assert result.status == "failed"
  886. assert result.model_call_count == 1
  887. assert result.fallback_count == 0
  888. assert result.semantic_failures == [
  889. "model_call_count expected 1, ledger got 0",
  890. "model_call_count ledger=0, timing=1",
  891. ]
  892. def test_failed_result_uses_timing_attempt_counts_and_returned_usage():
  893. timings = [
  894. benchmark.BenchmarkModelCallTiming(
  895. call_index=1,
  896. call_kind="chat_completion",
  897. first_item_kind="raw_chunk",
  898. provider_ttft_ms=1,
  899. visible_ttft_ms=2,
  900. elapsed_ms=3,
  901. usage=TokenUsage(
  902. prompt_tokens=2,
  903. completion_tokens=3,
  904. total_tokens=5,
  905. cached_tokens=1,
  906. ),
  907. ),
  908. benchmark.BenchmarkModelCallTiming(
  909. call_index=2,
  910. call_kind="argument_fallback",
  911. first_item_kind="raw_chunk",
  912. provider_ttft_ms=4,
  913. visible_ttft_ms=None,
  914. elapsed_ms=6,
  915. usage=TokenUsage(
  916. prompt_tokens=7,
  917. completion_tokens=11,
  918. total_tokens=18,
  919. cached_tokens=2,
  920. ),
  921. ),
  922. ]
  923. result = benchmark.BenchmarkRunner._failed_result(
  924. benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  925. benchmark.BenchmarkMode.DUAL_AGENT,
  926. 1,
  927. error="failed before persistence",
  928. timings=timings,
  929. )
  930. assert result.model_call_count == 1
  931. assert result.fallback_count == 1
  932. assert (
  933. result.prompt_tokens,
  934. result.completion_tokens,
  935. result.total_tokens,
  936. result.cached_tokens,
  937. ) == (9, 14, 23, 3)
  938. assert result.batch_event_names == []
  939. assert result.tool_event_names == []
  940. def test_failed_result_keeps_tokens_nullable_when_no_timing_usage_returned():
  941. timing = benchmark.BenchmarkModelCallTiming(
  942. call_index=1,
  943. call_kind="chat_completion",
  944. first_item_kind="raw_chunk",
  945. provider_ttft_ms=1,
  946. visible_ttft_ms=None,
  947. elapsed_ms=2,
  948. usage=None,
  949. )
  950. result = benchmark.BenchmarkRunner._failed_result(
  951. benchmark.BenchmarkCaseId.ORDINARY_CHAT,
  952. benchmark.BenchmarkMode.DUAL_AGENT,
  953. 1,
  954. error="failed without usage",
  955. timings=[timing],
  956. )
  957. assert result.model_call_count == 1
  958. assert result.fallback_count == 0
  959. assert (
  960. result.prompt_tokens,
  961. result.completion_tokens,
  962. result.total_tokens,
  963. result.cached_tokens,
  964. ) == (None, None, None, None)