test_benchmark_runner.py 46 KB

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