casebench 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. casebench/__init__.py +5 -0
  2. casebench/capture/__init__.py +1 -0
  3. casebench/capture/assemble.py +92 -0
  4. casebench/capture/backend.py +47 -0
  5. casebench/capture/cli.py +36 -0
  6. casebench/capture/config.py +28 -0
  7. casebench/capture/conformance.py +33 -0
  8. casebench/capture/context.py +53 -0
  9. casebench/capture/corpus_routes.py +50 -0
  10. casebench/capture/functions.py +115 -0
  11. casebench/capture/interface.py +23 -0
  12. casebench/capture/otlp/__init__.py +1 -0
  13. casebench/capture/otlp/decode.py +82 -0
  14. casebench/capture/otlp/mapping.py +105 -0
  15. casebench/capture/parsers/__init__.py +1 -0
  16. casebench/capture/parsers/anthropic/__init__.py +86 -0
  17. casebench/capture/parsers/anthropic/casebench-plugin.json +5 -0
  18. casebench/capture/parsers/anthropic/samples/plain-with-tool-result.json +76 -0
  19. casebench/capture/parsers/anthropic/samples/streamed-tool-call.json +97 -0
  20. casebench/capture/parsers/gemini/__init__.py +77 -0
  21. casebench/capture/parsers/gemini/casebench-plugin.json +5 -0
  22. casebench/capture/parsers/gemini/samples/plain-with-function-response.json +86 -0
  23. casebench/capture/parsers/gemini/samples/streamed-function-call.json +86 -0
  24. casebench/capture/parsers/openai/__init__.py +106 -0
  25. casebench/capture/parsers/openai/casebench-plugin.json +5 -0
  26. casebench/capture/parsers/openai/samples/plain-with-tool-result.json +72 -0
  27. casebench/capture/parsers/openai/samples/streamed-tool-call.json +104 -0
  28. casebench/capture/proxy/__init__.py +1 -0
  29. casebench/capture/proxy/forward.py +143 -0
  30. casebench/capture/proxy/redact.py +18 -0
  31. casebench/capture/proxy/routes.py +45 -0
  32. casebench/capture/proxy/server.py +38 -0
  33. casebench/capture/proxy/sse.py +23 -0
  34. casebench/capture/read.py +17 -0
  35. casebench/capture/recorder.py +82 -0
  36. casebench/capture/redirect.py +83 -0
  37. casebench/capture/routing.py +16 -0
  38. casebench/capture/session.py +165 -0
  39. casebench/capture/sizes.py +11 -0
  40. casebench/capture/step.py +75 -0
  41. casebench/cases/__init__.py +1 -0
  42. casebench/cases/build.py +98 -0
  43. casebench/cases/cli.py +214 -0
  44. casebench/cases/draft.py +42 -0
  45. casebench/cases/resolve.py +39 -0
  46. casebench/cases/suite.py +31 -0
  47. casebench/cli.py +60 -0
  48. casebench/config.py +128 -0
  49. casebench/core/__init__.py +1 -0
  50. casebench/core/canonical.py +20 -0
  51. casebench/core/case.py +140 -0
  52. casebench/core/jurisdiction.py +57 -0
  53. casebench/core/model.py +15 -0
  54. casebench/core/score.py +95 -0
  55. casebench/corpus/__init__.py +1 -0
  56. casebench/corpus/add.py +115 -0
  57. casebench/corpus/add_cli.py +607 -0
  58. casebench/corpus/binding.py +122 -0
  59. casebench/corpus/bundled.py +75 -0
  60. casebench/corpus/cache.py +118 -0
  61. casebench/corpus/check.py +77 -0
  62. casebench/corpus/cli.py +105 -0
  63. casebench/corpus/conformance.py +43 -0
  64. casebench/corpus/connectors/__init__.py +1 -0
  65. casebench/corpus/connectors/folder/__init__.py +68 -0
  66. casebench/corpus/connectors/folder/casebench-plugin.json +5 -0
  67. casebench/corpus/connectors/folder/samples/README.md +4 -0
  68. casebench/corpus/connectors/folder/samples/config.json +1 -0
  69. casebench/corpus/connectors/folder/samples/corpus/sample-1.json +1 -0
  70. casebench/corpus/connectors/folder/samples/corpus/sample-2.json +1 -0
  71. casebench/corpus/connectors/http/__init__.py +86 -0
  72. casebench/corpus/connectors/mcp/__init__.py +170 -0
  73. casebench/corpus/corpora.py +76 -0
  74. casebench/corpus/from_profile.py +135 -0
  75. casebench/corpus/guard.py +39 -0
  76. casebench/corpus/ids.py +21 -0
  77. casebench/corpus/interface.py +56 -0
  78. casebench/corpus/mapping.py +24 -0
  79. casebench/corpus/passage.py +55 -0
  80. casebench/corpus/probe_cli.py +77 -0
  81. casebench/corpus/profile.py +55 -0
  82. casebench/corpus/profiles/README.md +13 -0
  83. casebench/corpus/record.py +27 -0
  84. casebench/corpus/results.py +56 -0
  85. casebench/corpus/routing.py +69 -0
  86. casebench/corpus/suggest.py +80 -0
  87. casebench/envfiles.py +94 -0
  88. casebench/exporters/__init__.py +1 -0
  89. casebench/exporters/interface.py +11 -0
  90. casebench/extract/__init__.py +1 -0
  91. casebench/extract/agreement.py +52 -0
  92. casebench/extract/cli.py +150 -0
  93. casebench/extract/compare.py +58 -0
  94. casebench/extract/config.py +19 -0
  95. casebench/extract/estimate.py +93 -0
  96. casebench/extract/prompt.py +30 -0
  97. casebench/extract/reading.py +54 -0
  98. casebench/extract/run.py +162 -0
  99. casebench/formats/__init__.py +1 -0
  100. casebench/formats/extensions.py +20 -0
  101. casebench/formats/files.py +28 -0
  102. casebench/formats/read.py +39 -0
  103. casebench/formats/registry.py +47 -0
  104. casebench/formats/schema.py +29 -0
  105. casebench/formats/schema_cli.py +38 -0
  106. casebench/formats/schemas/case/1.0.json +320 -0
  107. casebench/formats/schemas/document/1.0.json +57 -0
  108. casebench/formats/schemas/index.json +3 -0
  109. casebench/formats/schemas/profile/1.0.json +142 -0
  110. casebench/formats/schemas/report/1.0.json +819 -0
  111. casebench/formats/schemas/suite/1.0.json +84 -0
  112. casebench/formats/validate_cli.py +96 -0
  113. casebench/formats/version.py +62 -0
  114. casebench/judge/__init__.py +1 -0
  115. casebench/judge/budget.py +33 -0
  116. casebench/judge/client.py +85 -0
  117. casebench/judge/config.py +33 -0
  118. casebench/judge/judges.py +21 -0
  119. casebench/judge/memo.py +37 -0
  120. casebench/judge/prompts.py +81 -0
  121. casebench/judge/resolve.py +135 -0
  122. casebench/key_record.py +37 -0
  123. casebench/keys.py +48 -0
  124. casebench/loading.py +70 -0
  125. casebench/metrics/__init__.py +1 -0
  126. casebench/metrics/authorities.py +102 -0
  127. casebench/metrics/base.py +73 -0
  128. casebench/metrics/builtin/__init__.py +1 -0
  129. casebench/metrics/builtin/citation_integrity/__init__.py +102 -0
  130. casebench/metrics/builtin/criteria/__init__.py +53 -0
  131. casebench/metrics/builtin/outcome/__init__.py +45 -0
  132. casebench/metrics/builtin/precedent/__init__.py +8 -0
  133. casebench/metrics/builtin/statute/__init__.py +8 -0
  134. casebench/metrics/criteria.py +94 -0
  135. casebench/metrics/loading.py +31 -0
  136. casebench/plugins/__init__.py +1 -0
  137. casebench/plugins/cli.py +66 -0
  138. casebench/plugins/conformance.py +90 -0
  139. casebench/plugins/kinds.py +67 -0
  140. casebench/plugins/manifest.py +59 -0
  141. casebench/plugins/registry.py +67 -0
  142. casebench/plugins/samples.py +43 -0
  143. casebench/plugins/scaffold.py +111 -0
  144. casebench/plugins/templates.py +130 -0
  145. casebench/providers/__init__.py +1 -0
  146. casebench/providers/builtin/__init__.py +1 -0
  147. casebench/providers/builtin/anthropic/__init__.py +72 -0
  148. casebench/providers/builtin/anthropic/casebench-plugin.json +5 -0
  149. casebench/providers/builtin/anthropic/samples/config.json +1 -0
  150. casebench/providers/builtin/anthropic/samples/list.json +38 -0
  151. casebench/providers/builtin/azure/__init__.py +60 -0
  152. casebench/providers/builtin/bedrock/__init__.py +66 -0
  153. casebench/providers/builtin/google/__init__.py +65 -0
  154. casebench/providers/builtin/openai/__init__.py +64 -0
  155. casebench/providers/builtin/openai/casebench-plugin.json +5 -0
  156. casebench/providers/builtin/openai/samples/list.json +35 -0
  157. casebench/providers/call.py +44 -0
  158. casebench/providers/conformance.py +38 -0
  159. casebench/providers/interface.py +57 -0
  160. casebench/providers/send.py +86 -0
  161. casebench/providers/settings.py +31 -0
  162. casebench/py.typed +0 -0
  163. casebench/references/__init__.py +1 -0
  164. casebench/references/court.py +43 -0
  165. casebench/references/interface.py +12 -0
  166. casebench/report/__init__.py +1 -0
  167. casebench/report/agreement.py +32 -0
  168. casebench/report/baselines.py +24 -0
  169. casebench/report/citations.py +24 -0
  170. casebench/report/cli.py +89 -0
  171. casebench/report/comparable.py +71 -0
  172. casebench/report/compare.py +45 -0
  173. casebench/report/funnel.py +156 -0
  174. casebench/report/record.py +49 -0
  175. casebench/report/render.py +142 -0
  176. casebench/report/statistics.py +60 -0
  177. casebench/report/tables.py +99 -0
  178. casebench/runs/__init__.py +1 -0
  179. casebench/runs/cli.py +175 -0
  180. casebench/runs/dry_run.py +128 -0
  181. casebench/runs/log.py +136 -0
  182. casebench/runs/producer.py +74 -0
  183. casebench/runs/progress.py +32 -0
  184. casebench/runs/rescore.py +69 -0
  185. casebench/runs/runner.py +89 -0
  186. casebench/runs/scoring.py +152 -0
  187. casebench/setup/__init__.py +1 -0
  188. casebench/setup/advice.py +30 -0
  189. casebench/setup/catalog.py +112 -0
  190. casebench/setup/check_cli.py +74 -0
  191. casebench/setup/checks.py +264 -0
  192. casebench/setup/corpus_question.py +216 -0
  193. casebench/setup/guide.md +142 -0
  194. casebench/setup/guide_cli.py +38 -0
  195. casebench/setup/init_cli.py +437 -0
  196. casebench/setup/judge_question.py +89 -0
  197. casebench/setup/key_question.py +136 -0
  198. casebench/setup/keys_cli.py +58 -0
  199. casebench/setup/models.py +68 -0
  200. casebench/setup/models_cli.py +56 -0
  201. casebench/setup/picker.py +112 -0
  202. casebench/setup/routes.py +106 -0
  203. casebench/setup/scan.py +276 -0
  204. casebench/setup/screen.py +109 -0
  205. casebench/setup/sdks.py +76 -0
  206. casebench/setup/summary.py +79 -0
  207. casebench/setup/update.py +80 -0
  208. casebench/suites/__init__.py +1 -0
  209. casebench/suites/cli.py +133 -0
  210. casebench/suites/court.py +143 -0
  211. casebench/suites/freeze.py +147 -0
  212. casebench/suites/generate.py +41 -0
  213. casebench/suites/qualify.py +36 -0
  214. casebench/suites/resolve.py +52 -0
  215. casebench/targets/__init__.py +1 -0
  216. casebench/targets/answer.py +32 -0
  217. casebench/targets/builtin/__init__.py +1 -0
  218. casebench/targets/builtin/command/__init__.py +52 -0
  219. casebench/targets/builtin/command/casebench-plugin.json +5 -0
  220. casebench/targets/builtin/command/samples/config.json +1 -0
  221. casebench/targets/builtin/http/__init__.py +105 -0
  222. casebench/targets/builtin/http/casebench-plugin.json +5 -0
  223. casebench/targets/builtin/http/samples/config.json +1 -0
  224. casebench/targets/builtin/python/__init__.py +144 -0
  225. casebench/targets/builtin/python/casebench-plugin.json +5 -0
  226. casebench/targets/builtin/python/entry.py +30 -0
  227. casebench/targets/builtin/python/framing.py +31 -0
  228. casebench/targets/builtin/python/samples/config.json +1 -0
  229. casebench/targets/builtin/python/trace_context.py +21 -0
  230. casebench/targets/builtin/python/worker.py +57 -0
  231. casebench/targets/cli.py +53 -0
  232. casebench/targets/interface.py +22 -0
  233. casebench/targets/load.py +19 -0
  234. casebench/targets/placeholders.py +63 -0
  235. casebench/targets/sample.py +34 -0
  236. casebench/tasks/__init__.py +1 -0
  237. casebench/tasks/interface.py +16 -0
  238. casebench-0.1.0.dist-info/METADATA +82 -0
  239. casebench-0.1.0.dist-info/RECORD +242 -0
  240. casebench-0.1.0.dist-info/WHEEL +4 -0
  241. casebench-0.1.0.dist-info/entry_points.txt +32 -0
  242. casebench-0.1.0.dist-info/licenses/LICENSE +211 -0
@@ -0,0 +1,105 @@
1
+ """Turn spans in the GenAI or OpenInference conventions into steps (decision 0023)."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from casebench.capture.otlp.decode import RawSpan
8
+ from casebench.capture.step import NO_DIAGNOSTIC, Step, StepKind
9
+
10
+ LLM_OPERATIONS = {"chat", "text_completion", "generate_content"}
11
+
12
+
13
+ def _indexed_ids(attributes: dict[str, Any], prefix: str) -> list[str]:
14
+ """OpenInference flattens lists: `<prefix>.0.document.id`, `<prefix>.1.document.id`."""
15
+ pattern = re.compile(rf"^{re.escape(prefix)}\.(\d+)\.document\.id$")
16
+ found = sorted(
17
+ (int(m.group(1)), str(v)) for k, v in attributes.items() if (m := pattern.match(k))
18
+ )
19
+ return [value for _, value in found]
20
+
21
+
22
+ def _documents(value: Any) -> list[str]:
23
+ """`gen_ai.retrieval.documents`: a list of objects with `id`, possibly sent as JSON text."""
24
+ if isinstance(value, str):
25
+ try:
26
+ value = json.loads(value)
27
+ except json.JSONDecodeError:
28
+ return []
29
+ return [str(item["id"]) for item in value or [] if isinstance(item, dict) and "id" in item]
30
+
31
+
32
+ def _kind(attributes: dict[str, Any]) -> StepKind | None:
33
+ operation = attributes.get("gen_ai.operation.name")
34
+ span_kind = str(attributes.get("openinference.span.kind", "")).upper()
35
+ if operation in LLM_OPERATIONS or span_kind == "LLM":
36
+ return StepKind.LLM
37
+ if operation == "execute_tool" or span_kind == "TOOL":
38
+ return StepKind.TOOL
39
+ if operation == "retrieval" or span_kind == "RETRIEVER":
40
+ return StepKind.RETRIEVAL
41
+ if (
42
+ operation == "rerank"
43
+ or span_kind == "RERANKER"
44
+ or "casebench.rerank.output_ids" in attributes
45
+ ):
46
+ return StepKind.RERANK
47
+ return None
48
+
49
+
50
+ def _ids(kind: StepKind, a: dict[str, Any]) -> tuple[list[str], list[str]]:
51
+ if kind is StepKind.RETRIEVAL:
52
+ return [], _documents(a.get("gen_ai.retrieval.documents")) or _indexed_ids(
53
+ a, "retrieval.documents"
54
+ )
55
+ if kind is StepKind.RERANK:
56
+ ids_in = [str(i) for i in a.get("casebench.rerank.input_ids") or []]
57
+ ids_out = [str(i) for i in a.get("casebench.rerank.output_ids") or []]
58
+ return (
59
+ ids_in or _indexed_ids(a, "reranker.input_documents"),
60
+ ids_out or _indexed_ids(a, "reranker.output_documents"),
61
+ )
62
+ return [], []
63
+
64
+
65
+ def _int(value: Any) -> int | None:
66
+ return int(value) if value is not None else None
67
+
68
+
69
+ def to_step(span: RawSpan) -> Step | None:
70
+ a = span.attributes
71
+ kind = _kind(a)
72
+ if kind is None:
73
+ return None
74
+ ids_in, ids_out = _ids(kind, a)
75
+ searching = kind in (StepKind.RETRIEVAL, StepKind.RERANK)
76
+ return Step(
77
+ kind=kind,
78
+ name=a.get("gen_ai.tool.name") or a.get("tool.name") or span.name,
79
+ provider=a.get("gen_ai.provider.name") or a.get("gen_ai.system") or a.get("llm.provider"),
80
+ model=a.get("gen_ai.response.model")
81
+ or a.get("gen_ai.request.model")
82
+ or a.get("llm.model_name"),
83
+ input_tokens=_int(a.get("gen_ai.usage.input_tokens", a.get("llm.token_count.prompt"))),
84
+ output_tokens=_int(
85
+ a.get("gen_ai.usage.output_tokens", a.get("llm.token_count.completion"))
86
+ ),
87
+ doc_ids_in=ids_in,
88
+ doc_ids_out=ids_out,
89
+ started=span.started,
90
+ ended=span.ended,
91
+ duration_ms=(span.ended - span.started) * 1000,
92
+ note=NO_DIAGNOSTIC if searching and not ids_out else None,
93
+ )
94
+
95
+
96
+ def without_duplicates(span_steps: list[Step], proxied: list[Step]) -> list[Step]:
97
+ """A model call the proxy saw is kept from the proxy; the span describing it is dropped."""
98
+ calls = [s for s in proxied if s.kind is StepKind.LLM and s.started is not None]
99
+
100
+ def seen_by_proxy(step: Step) -> bool:
101
+ if step.kind is not StepKind.LLM or step.started is None or step.ended is None:
102
+ return False
103
+ return any(step.started <= c.started <= step.ended for c in calls if c.started is not None)
104
+
105
+ return [step for step in span_steps if not seen_by_proxy(step)]
@@ -0,0 +1 @@
1
+ """Built-in capture parsers, one per upstream API."""
@@ -0,0 +1,86 @@
1
+ """Read Anthropic Messages API calls, streamed or not, into model-call steps."""
2
+
3
+ import json
4
+ from typing import Any, ClassVar
5
+
6
+ from casebench.capture.sizes import content_size
7
+ from casebench.capture.step import HttpExchange, Step, StepKind, ToolCall, ToolResult
8
+
9
+
10
+ def _tool_results(request: Any) -> list[ToolResult]:
11
+ results: list[ToolResult] = []
12
+ for message in (request or {}).get("messages", []):
13
+ if message.get("role") != "user" or not isinstance(message.get("content"), list):
14
+ continue
15
+ for block in message["content"]:
16
+ if isinstance(block, dict) and block.get("type") == "tool_result":
17
+ size = content_size(block.get("content", ""))
18
+ results.append(ToolResult(id=block["tool_use_id"], size=size))
19
+ return results
20
+
21
+
22
+ def _from_message(message: dict[str, Any]) -> tuple[str | None, int | None, int | None, list]:
23
+ usage = message.get("usage") or {}
24
+ tools = [
25
+ ToolCall(id=block["id"], name=block["name"], arguments=block.get("input"))
26
+ for block in message.get("content") or []
27
+ if block.get("type") == "tool_use"
28
+ ]
29
+ return message.get("model"), usage.get("input_tokens"), usage.get("output_tokens"), tools
30
+
31
+
32
+ def _from_events(events: list[Any]) -> tuple[str | None, int | None, int | None, list]:
33
+ model = input_tokens = output_tokens = None
34
+ blocks: dict[int, dict[str, Any]] = {}
35
+ for event in events:
36
+ kind = event.get("type")
37
+ if kind == "message_start":
38
+ model = event["message"].get("model")
39
+ input_tokens = (event["message"].get("usage") or {}).get("input_tokens")
40
+ elif kind == "content_block_start" and event["content_block"].get("type") == "tool_use":
41
+ block = event["content_block"]
42
+ blocks[event["index"]] = {"id": block["id"], "name": block["name"], "json": ""}
43
+ elif kind == "content_block_delta" and event["delta"].get("type") == "input_json_delta":
44
+ if event["index"] in blocks:
45
+ blocks[event["index"]]["json"] += event["delta"].get("partial_json", "")
46
+ elif kind == "message_delta":
47
+ output_tokens = (event.get("usage") or {}).get("output_tokens", output_tokens)
48
+ tools = [
49
+ ToolCall(id=b["id"], name=b["name"], arguments=json.loads(b["json"]) if b["json"] else {})
50
+ for _, b in sorted(blocks.items())
51
+ ]
52
+ return model, input_tokens, output_tokens, tools
53
+
54
+
55
+ class Parser:
56
+ hosts: ClassVar[list[str]] = ["api.anthropic.com"]
57
+ packages: ClassVar[list[str]] = ["anthropic", "@anthropic-ai/sdk"]
58
+ # The client classes whose calls this parser reads, for `casebench init` (decision 0040).
59
+ clients: ClassVar[list[str]] = ["anthropic.Anthropic", "anthropic.AsyncAnthropic"]
60
+ default_route: ClassVar[dict[str, str]] = {
61
+ "name": "anthropic",
62
+ "upstream": "https://api.anthropic.com",
63
+ "env": "ANTHROPIC_BASE_URL",
64
+ }
65
+
66
+ def parse(self, exchange: HttpExchange) -> list[Step]:
67
+ if not exchange.url.rstrip("/").endswith("/v1/messages"):
68
+ return []
69
+ if exchange.response_events is not None:
70
+ model, tokens_in, tokens_out, tools = _from_events(exchange.response_events)
71
+ else:
72
+ model, tokens_in, tokens_out, tools = _from_message(exchange.response_body or {})
73
+ return [
74
+ Step(
75
+ kind=StepKind.LLM,
76
+ provider="anthropic",
77
+ model=model or (exchange.request_body or {}).get("model"),
78
+ input_tokens=tokens_in,
79
+ output_tokens=tokens_out,
80
+ requested_tools=tools,
81
+ tool_results=_tool_results(exchange.request_body),
82
+ started=exchange.started,
83
+ ended=exchange.ended,
84
+ duration_ms=exchange.duration_ms,
85
+ )
86
+ ]
@@ -0,0 +1,5 @@
1
+ {
2
+ "kind": "parser",
3
+ "name": "anthropic",
4
+ "object": "casebench.capture.parsers.anthropic:Parser"
5
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "exchange": {
3
+ "method": "POST",
4
+ "url": "https://api.anthropic.com/v1/messages",
5
+ "status": 200,
6
+ "request_body": {
7
+ "model": "claude-sonnet-5-5",
8
+ "max_tokens": 1024,
9
+ "messages": [
10
+ {
11
+ "role": "user",
12
+ "content": "Is the dismissal valid?"
13
+ },
14
+ {
15
+ "role": "assistant",
16
+ "content": [
17
+ {
18
+ "type": "tool_use",
19
+ "id": "toolu_1",
20
+ "name": "search",
21
+ "input": {
22
+ "query": "dismissal"
23
+ }
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "role": "user",
29
+ "content": [
30
+ {
31
+ "type": "tool_result",
32
+ "tool_use_id": "toolu_1",
33
+ "content": "3 decisions found"
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ },
39
+ "response_body": {
40
+ "type": "message",
41
+ "model": "claude-sonnet-5-5",
42
+ "content": [
43
+ {
44
+ "type": "text",
45
+ "text": "No."
46
+ }
47
+ ],
48
+ "usage": {
49
+ "input_tokens": 200,
50
+ "output_tokens": 40
51
+ }
52
+ },
53
+ "route": "anthropic",
54
+ "started": 1760000002.5,
55
+ "ended": 1760000003.5,
56
+ "duration_ms": 1000.0
57
+ },
58
+ "expected": [
59
+ {
60
+ "kind": "llm",
61
+ "provider": "anthropic",
62
+ "model": "claude-sonnet-5-5",
63
+ "input_tokens": 200,
64
+ "output_tokens": 40,
65
+ "tool_results": [
66
+ {
67
+ "id": "toolu_1",
68
+ "size": 17
69
+ }
70
+ ],
71
+ "started": 1760000002.5,
72
+ "ended": 1760000003.5,
73
+ "duration_ms": 1000.0
74
+ }
75
+ ]
76
+ }
@@ -0,0 +1,97 @@
1
+ {
2
+ "exchange": {
3
+ "method": "POST",
4
+ "url": "https://api.anthropic.com/v1/messages",
5
+ "status": 200,
6
+ "request_body": {
7
+ "model": "claude-sonnet-5-5",
8
+ "max_tokens": 1024,
9
+ "stream": true,
10
+ "messages": [
11
+ {
12
+ "role": "user",
13
+ "content": "Is the dismissal valid?"
14
+ }
15
+ ]
16
+ },
17
+ "response_events": [
18
+ {
19
+ "type": "message_start",
20
+ "message": {
21
+ "model": "claude-sonnet-5-5",
22
+ "usage": {
23
+ "input_tokens": 120,
24
+ "output_tokens": 1
25
+ }
26
+ }
27
+ },
28
+ {
29
+ "type": "content_block_start",
30
+ "index": 0,
31
+ "content_block": {
32
+ "type": "tool_use",
33
+ "id": "toolu_1",
34
+ "name": "search",
35
+ "input": {}
36
+ }
37
+ },
38
+ {
39
+ "type": "content_block_delta",
40
+ "index": 0,
41
+ "delta": {
42
+ "type": "input_json_delta",
43
+ "partial_json": "{\"query\": "
44
+ }
45
+ },
46
+ {
47
+ "type": "content_block_delta",
48
+ "index": 0,
49
+ "delta": {
50
+ "type": "input_json_delta",
51
+ "partial_json": "\"dismissal\"}"
52
+ }
53
+ },
54
+ {
55
+ "type": "content_block_stop",
56
+ "index": 0
57
+ },
58
+ {
59
+ "type": "message_delta",
60
+ "delta": {
61
+ "stop_reason": "tool_use"
62
+ },
63
+ "usage": {
64
+ "output_tokens": 30
65
+ }
66
+ },
67
+ {
68
+ "type": "message_stop"
69
+ }
70
+ ],
71
+ "route": "anthropic",
72
+ "started": 1760000000.0,
73
+ "ended": 1760000001.5,
74
+ "duration_ms": 1500.0
75
+ },
76
+ "expected": [
77
+ {
78
+ "kind": "llm",
79
+ "provider": "anthropic",
80
+ "model": "claude-sonnet-5-5",
81
+ "input_tokens": 120,
82
+ "output_tokens": 30,
83
+ "requested_tools": [
84
+ {
85
+ "id": "toolu_1",
86
+ "name": "search",
87
+ "arguments": {
88
+ "query": "dismissal"
89
+ }
90
+ }
91
+ ],
92
+ "started": 1760000000.0,
93
+ "ended": 1760000001.5,
94
+ "duration_ms": 1500.0
95
+ }
96
+ ]
97
+ }
@@ -0,0 +1,77 @@
1
+ """Read Gemini API generateContent calls, streamed or not, into model-call steps.
2
+
3
+ Gemini may omit function-call ids; a call without one is paired with its result by the
4
+ function's name.
5
+ """
6
+
7
+ import re
8
+ from typing import Any, ClassVar
9
+
10
+ from casebench.capture.sizes import content_size
11
+ from casebench.capture.step import HttpExchange, Step, StepKind, ToolCall, ToolResult
12
+
13
+ METHOD = re.compile(r"/models/([^/:]+):(generateContent|streamGenerateContent)")
14
+
15
+
16
+ def _tool_results(request: Any) -> list[ToolResult]:
17
+ results: list[ToolResult] = []
18
+ for content in (request or {}).get("contents", []):
19
+ for part in content.get("parts") or []:
20
+ answer = part.get("functionResponse")
21
+ if answer:
22
+ key = answer.get("id") or answer["name"]
23
+ results.append(ToolResult(id=key, size=content_size(answer.get("response", {}))))
24
+ return results
25
+
26
+
27
+ def _read(chunks: list[Any]) -> tuple[str | None, int | None, int | None, list[ToolCall]]:
28
+ model = prompt = completion = None
29
+ tools: list[ToolCall] = []
30
+ for chunk in chunks:
31
+ model = chunk.get("modelVersion") or model
32
+ usage = chunk.get("usageMetadata") or {}
33
+ prompt = usage.get("promptTokenCount", prompt)
34
+ completion = usage.get("candidatesTokenCount", completion)
35
+ for candidate in chunk.get("candidates") or []:
36
+ for part in (candidate.get("content") or {}).get("parts") or []:
37
+ call = part.get("functionCall")
38
+ if call:
39
+ key = call.get("id") or call["name"]
40
+ tools.append(ToolCall(id=key, name=call["name"], arguments=call.get("args")))
41
+ return model, prompt, completion, tools
42
+
43
+
44
+ class Parser:
45
+ hosts: ClassVar[list[str]] = ["generativelanguage.googleapis.com"]
46
+ packages: ClassVar[list[str]] = ["google-genai", "google-generativeai", "@google/genai"]
47
+ # The client classes whose calls this parser reads, for `casebench init` (decision 0040).
48
+ clients: ClassVar[list[str]] = ["google.genai.Client"]
49
+ default_route: ClassVar[dict[str, str]] = {
50
+ "name": "gemini",
51
+ "upstream": "https://generativelanguage.googleapis.com",
52
+ "env": "GOOGLE_GEMINI_BASE_URL",
53
+ }
54
+
55
+ def parse(self, exchange: HttpExchange) -> list[Step]:
56
+ method = METHOD.search(exchange.url)
57
+ if method is None:
58
+ return []
59
+ if exchange.response_events is not None:
60
+ chunks = exchange.response_events
61
+ else:
62
+ chunks = [exchange.response_body or {}]
63
+ model, prompt, completion, tools = _read(chunks)
64
+ return [
65
+ Step(
66
+ kind=StepKind.LLM,
67
+ provider="gemini",
68
+ model=model or method.group(1),
69
+ input_tokens=prompt,
70
+ output_tokens=completion,
71
+ requested_tools=tools,
72
+ tool_results=_tool_results(exchange.request_body),
73
+ started=exchange.started,
74
+ ended=exchange.ended,
75
+ duration_ms=exchange.duration_ms,
76
+ )
77
+ ]
@@ -0,0 +1,5 @@
1
+ {
2
+ "kind": "parser",
3
+ "name": "gemini",
4
+ "object": "casebench.capture.parsers.gemini:Parser"
5
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "exchange": {
3
+ "method": "POST",
4
+ "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:generateContent",
5
+ "status": 200,
6
+ "request_body": {
7
+ "contents": [
8
+ {
9
+ "role": "user",
10
+ "parts": [
11
+ {
12
+ "text": "Is the dismissal valid?"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "role": "model",
18
+ "parts": [
19
+ {
20
+ "functionCall": {
21
+ "name": "search",
22
+ "args": {
23
+ "query": "dismissal"
24
+ }
25
+ }
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "role": "user",
31
+ "parts": [
32
+ {
33
+ "functionResponse": {
34
+ "name": "search",
35
+ "response": {
36
+ "found": 3
37
+ }
38
+ }
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ },
44
+ "response_body": {
45
+ "candidates": [
46
+ {
47
+ "content": {
48
+ "role": "model",
49
+ "parts": [
50
+ {
51
+ "text": "No."
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ ],
57
+ "usageMetadata": {
58
+ "promptTokenCount": 120,
59
+ "candidatesTokenCount": 4
60
+ },
61
+ "modelVersion": "gemini-3-pro"
62
+ },
63
+ "route": "gemini",
64
+ "started": 1760000002.0,
65
+ "ended": 1760000003.0,
66
+ "duration_ms": 1000.0
67
+ },
68
+ "expected": [
69
+ {
70
+ "kind": "llm",
71
+ "provider": "gemini",
72
+ "model": "gemini-3-pro",
73
+ "input_tokens": 120,
74
+ "output_tokens": 4,
75
+ "tool_results": [
76
+ {
77
+ "id": "search",
78
+ "size": 12
79
+ }
80
+ ],
81
+ "started": 1760000002.0,
82
+ "ended": 1760000003.0,
83
+ "duration_ms": 1000.0
84
+ }
85
+ ]
86
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "exchange": {
3
+ "method": "POST",
4
+ "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:streamGenerateContent?alt=sse",
5
+ "status": 200,
6
+ "request_body": {
7
+ "contents": [
8
+ {
9
+ "role": "user",
10
+ "parts": [
11
+ {
12
+ "text": "Is the dismissal valid?"
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ },
18
+ "response_events": [
19
+ {
20
+ "candidates": [
21
+ {
22
+ "content": {
23
+ "role": "model",
24
+ "parts": [
25
+ {
26
+ "functionCall": {
27
+ "name": "search",
28
+ "args": {
29
+ "query": "dismissal"
30
+ }
31
+ }
32
+ }
33
+ ]
34
+ }
35
+ }
36
+ ],
37
+ "modelVersion": "gemini-3-pro"
38
+ },
39
+ {
40
+ "candidates": [
41
+ {
42
+ "content": {
43
+ "role": "model",
44
+ "parts": [
45
+ {
46
+ "text": ""
47
+ }
48
+ ]
49
+ },
50
+ "finishReason": "STOP"
51
+ }
52
+ ],
53
+ "usageMetadata": {
54
+ "promptTokenCount": 80,
55
+ "candidatesTokenCount": 9
56
+ },
57
+ "modelVersion": "gemini-3-pro"
58
+ }
59
+ ],
60
+ "route": "gemini",
61
+ "started": 1760000000.0,
62
+ "ended": 1760000001.0,
63
+ "duration_ms": 1000.0
64
+ },
65
+ "expected": [
66
+ {
67
+ "kind": "llm",
68
+ "provider": "gemini",
69
+ "model": "gemini-3-pro",
70
+ "input_tokens": 80,
71
+ "output_tokens": 9,
72
+ "requested_tools": [
73
+ {
74
+ "id": "search",
75
+ "name": "search",
76
+ "arguments": {
77
+ "query": "dismissal"
78
+ }
79
+ }
80
+ ],
81
+ "started": 1760000000.0,
82
+ "ended": 1760000001.0,
83
+ "duration_ms": 1000.0
84
+ }
85
+ ]
86
+ }