test_benchmark_runner.py 42 KB

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