cost-per-task 0.4.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.
@@ -0,0 +1,3 @@
1
+ """cost-per-task: measure the real cost per completed task of LLM agents."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,65 @@
1
+ """One entry point from files on disk to summaries, shared by the CLI and
2
+ the MCP server."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from .labels import load_labels
9
+ from .metrics import Attempt, GroupSummary, build_attempts, summarise
10
+ from .pricing import PricingTable
11
+ from .schema import read_jsonl
12
+
13
+
14
+ @dataclass
15
+ class Analysis:
16
+ table: PricingTable
17
+ attempts: list[Attempt]
18
+ summaries: list[GroupSummary]
19
+
20
+
21
+ def load_analysis(
22
+ *,
23
+ log: str,
24
+ labels: str,
25
+ prices: str | list[str],
26
+ by_task_type: bool = True,
27
+ task_type: str | None = None,
28
+ k: int | None = None,
29
+ retry_cap: int | None = None,
30
+ cleanup_cost: float | None = None,
31
+ leak_rate: float | None = None,
32
+ resamples: int = 10_000,
33
+ seed: int | None = None,
34
+ ) -> Analysis:
35
+ """Raises OSError or PricingError when inputs cannot be read. ``prices``
36
+ may be several tables (one per vendor) merged for mixed-vendor logs."""
37
+ paths = [prices] if isinstance(prices, str) else list(prices)
38
+ table = PricingTable.load_many(paths)
39
+ records = read_jsonl(log)
40
+ attempts = build_attempts(records, table, load_labels(labels))
41
+ if task_type:
42
+ attempts = [a for a in attempts if a.task_type == task_type]
43
+ summaries = summarise(
44
+ attempts,
45
+ by_task_type=by_task_type,
46
+ k=k,
47
+ retry_cap=retry_cap,
48
+ cleanup_cost=cleanup_cost,
49
+ leak_rate=leak_rate,
50
+ resamples=resamples,
51
+ seed=seed,
52
+ )
53
+ return Analysis(table=table, attempts=attempts, summaries=summaries)
54
+
55
+
56
+ def pick_model(summaries: list[GroupSummary], wanted: str) -> GroupSummary:
57
+ """Exact match first, then a unique prefix match (dated model ids)."""
58
+ exact = [s for s in summaries if s.model == wanted]
59
+ if len(exact) == 1:
60
+ return exact[0]
61
+ matches = [s for s in summaries if s.model.startswith(wanted)]
62
+ if len(matches) != 1:
63
+ names = ", ".join(s.model for s in summaries) or "none"
64
+ raise LookupError(f"'{wanted}' matches {len(matches)} models; available: {names}")
65
+ return matches[0]
cost_per_task/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """The cpt command line interface.
2
+
3
+ Commands:
4
+ cpt serve run the capture proxy in the foreground
5
+ cpt run run an agent command with the proxy set up and calls tagged
6
+ cpt label mark an attempt pass or fail (and optionally leaked), or import a CSV
7
+ cpt report cost per attempt and per solved task, per model and task type
8
+ cpt compare two-model comparison with the break-even cleanup cost K*
9
+ cpt import convert a Langfuse or LiteLLM usage export into a cpt log
10
+ cpt prices refresh a pricing table from the OptimNow AI Pricing Hub
11
+ cpt mcp serve the report over MCP (needs the [mcp] extra)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import secrets
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from .analysis import load_analysis, pick_model
28
+ from .importers import import_langfuse, import_litellm
29
+ from .importers.common import ImportError_
30
+ from .labels import Label, LabelError, append_label, import_csv, load_labels
31
+ from .prices_hub import HUB_URL, PROVIDERS, build_table, diff_tables, fetch_hub, load_hub, write_table
32
+ from .pricing import PricingError
33
+ from .proxy import DEFAULT_UPSTREAMS, create_proxy
34
+ from .report import render_comparison, render_json, render_report
35
+ from .schema import JsonlWriter, read_jsonl
36
+
37
+ DEFAULT_LOG = "cpt-log.jsonl"
38
+ DEFAULT_LABELS = "cpt-labels.jsonl"
39
+
40
+
41
+ def _new_attempt_id() -> str:
42
+ # Timestamp for readability, random suffix so two attempts started in the
43
+ # same second never share an id (which would merge them into one attempt).
44
+ stamp = datetime.now(timezone.utc).strftime("a%Y%m%dT%H%M%SZ")
45
+ return f"{stamp}-{secrets.token_hex(2)}"
46
+
47
+
48
+ def _add_proxy_options(parser: argparse.ArgumentParser) -> None:
49
+ parser.add_argument("--log", default=DEFAULT_LOG)
50
+ parser.add_argument("--task-type", help="free-text task category, e.g. coding")
51
+ parser.add_argument(
52
+ "--anthropic-upstream",
53
+ default=DEFAULT_UPSTREAMS["anthropic"],
54
+ help="base URL: scheme, host and optional path prefix",
55
+ )
56
+ parser.add_argument(
57
+ "--openai-upstream",
58
+ default=DEFAULT_UPSTREAMS["openai"],
59
+ help="base URL of OpenAI or any OpenAI-compatible gateway, e.g. https://openrouter.ai/api",
60
+ )
61
+ parser.add_argument(
62
+ "--no-inject-usage",
63
+ action="store_true",
64
+ help="do not add stream_options.include_usage (OpenAI) or usage.include (OpenRouter) to requests",
65
+ )
66
+
67
+
68
+ def _add_analysis_options(parser: argparse.ArgumentParser) -> None:
69
+ parser.add_argument("--log", default=DEFAULT_LOG)
70
+ parser.add_argument("--labels", default=DEFAULT_LABELS)
71
+ parser.add_argument(
72
+ "--prices", action="append", required=True, help="pricing table JSON; repeat to merge vendors"
73
+ )
74
+ parser.add_argument("--cleanup-cost", type=float, help="K: cost of one leaked failure")
75
+ parser.add_argument("--leak-rate", type=float, help="override L instead of using leak labels")
76
+ parser.add_argument("--retry-cap", type=int, help="N for p_N (default: max attempts per task)")
77
+ parser.add_argument("--k", type=int, help="k for pass^k (default: usual attempts per task)")
78
+ parser.add_argument("--resamples", type=int, default=10_000)
79
+ parser.add_argument("--seed", type=int, help="bootstrap seed for reproducible intervals")
80
+ parser.add_argument("--harness", help="harness name and version for the disclosure checklist")
81
+ parser.add_argument("--task-type", help="only include attempts of this task type")
82
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ parser = argparse.ArgumentParser(
87
+ prog="cpt", description="Measure the real cost per completed task of LLM agents."
88
+ )
89
+ sub = parser.add_subparsers(dest="command", required=True)
90
+
91
+ serve = sub.add_parser("serve", help="run the capture proxy in the foreground")
92
+ serve.add_argument("--port", type=int, default=4000)
93
+ serve.add_argument("--task-id", default=os.environ.get("CPT_TASK_ID", "unassigned"))
94
+ serve.add_argument("--attempt-id", default=os.environ.get("CPT_ATTEMPT_ID"))
95
+ _add_proxy_options(serve)
96
+
97
+ run = sub.add_parser(
98
+ "run", help="run a command through the proxy: cpt run --task-id T1 -- <command>"
99
+ )
100
+ run.add_argument("--task-id", required=True)
101
+ run.add_argument("--attempt-id")
102
+ _add_proxy_options(run)
103
+ run.add_argument("cmd", nargs=argparse.REMAINDER)
104
+
105
+ label = sub.add_parser("label", help="label an attempt pass or fail, or import a CSV")
106
+ label.add_argument("outcome", nargs="?", choices=("pass", "fail"))
107
+ label.add_argument("--task", help="task id (defaults to the latest task in the log)")
108
+ label.add_argument("--attempt", help="attempt id (defaults to the latest attempt of the task)")
109
+ label.add_argument("--leak", action="store_true", help="accepted output was actually wrong")
110
+ label.add_argument("--note")
111
+ label.add_argument("--import", dest="import_csv", metavar="CSV", help="bulk import labels")
112
+ label.add_argument("--log", default=DEFAULT_LOG)
113
+ label.add_argument("--labels", default=DEFAULT_LABELS)
114
+
115
+ report = sub.add_parser("report", help="summarise a JSONL log")
116
+ _add_analysis_options(report)
117
+ report.add_argument(
118
+ "--by-model-only", action="store_true", help="do not split groups by task type"
119
+ )
120
+
121
+ compare = sub.add_parser("compare", help="compare two models and print K*")
122
+ compare.add_argument("model_a", help="the cheaper, leakier model (A)")
123
+ compare.add_argument("model_b", help="the reliable model (B)")
124
+ _add_analysis_options(compare)
125
+
126
+ imp = sub.add_parser("import", help="convert a usage export into cpt step records")
127
+ imp.add_argument("source", choices=("langfuse", "litellm"))
128
+ imp.add_argument("file", help="export file: .csv, .json or .jsonl")
129
+ imp.add_argument("--log", default=DEFAULT_LOG, help="cpt log to append to")
130
+ imp.add_argument("--task-field", help="dotted field holding the task id")
131
+ imp.add_argument("--attempt-field", help="dotted field holding the attempt id")
132
+ imp.add_argument("--task-type")
133
+ imp.add_argument("--dry-run", action="store_true", help="show what would be imported")
134
+
135
+ prices = sub.add_parser("prices", help="pricing table maintenance")
136
+ prices_sub = prices.add_subparsers(dest="prices_command", required=True)
137
+ refresh = prices_sub.add_parser("refresh", help="diff or rewrite a table from the Pricing Hub")
138
+ refresh.add_argument("--provider", required=True, choices=sorted(PROVIDERS))
139
+ refresh.add_argument("--out", help="table path (default prices/<provider>.json)")
140
+ refresh.add_argument("--url", default=HUB_URL)
141
+ refresh.add_argument("--from-file", help="use a saved hub JSON instead of fetching")
142
+ refresh.add_argument(
143
+ "--map", action="append", default=[], metavar="HUB_ID=API_ID",
144
+ help="override the model id mapping, e.g. anthropic/claude-haiku-4.5=claude-haiku-4-5",
145
+ )
146
+ refresh.add_argument("--write", action="store_true", help="write the table (default: diff only)")
147
+
148
+ sub.add_parser("mcp", help="serve the report over MCP on stdio")
149
+
150
+ args = parser.parse_args(argv)
151
+ handlers = {
152
+ "serve": _cmd_serve,
153
+ "run": _cmd_run,
154
+ "label": _cmd_label,
155
+ "report": _cmd_report,
156
+ "compare": _cmd_compare,
157
+ "import": _cmd_import,
158
+ "prices": _cmd_prices,
159
+ "mcp": _cmd_mcp,
160
+ }
161
+ return handlers[args.command](args)
162
+
163
+
164
+ def _upstreams(args: argparse.Namespace) -> dict[str, str]:
165
+ return {"anthropic": args.anthropic_upstream, "openai": args.openai_upstream}
166
+
167
+
168
+ def _cmd_serve(args: argparse.Namespace) -> int:
169
+ attempt_id = args.attempt_id or _new_attempt_id()
170
+ server = create_proxy(
171
+ upstreams=_upstreams(args),
172
+ log_path=args.log,
173
+ task_id=args.task_id,
174
+ attempt_id=attempt_id,
175
+ task_type=args.task_type,
176
+ inject_usage=not args.no_inject_usage,
177
+ port=args.port,
178
+ )
179
+ host, port = server.server_address[:2]
180
+ print(f"cpt proxy on http://{host}:{port}")
181
+ print(f"log: {args.log} task: {args.task_id} attempt: {attempt_id}")
182
+ print(f"set ANTHROPIC_BASE_URL=http://{host}:{port} and OPENAI_BASE_URL=http://{host}:{port}/v1")
183
+ try:
184
+ server.serve_forever()
185
+ except KeyboardInterrupt:
186
+ pass
187
+ finally:
188
+ server.server_close()
189
+ return 0
190
+
191
+
192
+ def _cmd_run(args: argparse.Namespace) -> int:
193
+ cmd = list(args.cmd)
194
+ if cmd and cmd[0] == "--":
195
+ cmd = cmd[1:]
196
+ if not cmd:
197
+ print("cpt run: no command given; usage: cpt run --task-id T1 -- <command>", file=sys.stderr)
198
+ return 2
199
+ # Resolve through PATH so .cmd and .bat shims work on Windows without a shell.
200
+ resolved = shutil.which(cmd[0])
201
+ if resolved:
202
+ cmd[0] = resolved
203
+
204
+ attempt_id = args.attempt_id or _new_attempt_id()
205
+ server = create_proxy(
206
+ upstreams=_upstreams(args),
207
+ log_path=args.log,
208
+ task_id=args.task_id,
209
+ attempt_id=attempt_id,
210
+ task_type=args.task_type,
211
+ inject_usage=not args.no_inject_usage,
212
+ port=0,
213
+ )
214
+ port = server.server_address[1]
215
+ threading.Thread(target=server.serve_forever, daemon=True).start()
216
+
217
+ env = dict(os.environ)
218
+ env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
219
+ env["OPENAI_BASE_URL"] = f"http://127.0.0.1:{port}/v1"
220
+ env["CPT_TASK_ID"] = args.task_id
221
+ env["CPT_ATTEMPT_ID"] = attempt_id
222
+ print(
223
+ f"cpt: task {args.task_id} attempt {attempt_id}; "
224
+ f"proxy on port {port}; logging to {args.log}"
225
+ )
226
+ try:
227
+ return subprocess.run(cmd, env=env).returncode
228
+ finally:
229
+ server.shutdown()
230
+ server.server_close()
231
+ print(
232
+ f"cpt: captured {server.captured_count} steps "
233
+ f"(task {args.task_id}, attempt {attempt_id}) in {args.log}"
234
+ )
235
+
236
+
237
+ def _latest_attempt(records, task_id: str | None) -> tuple[str, str] | None:
238
+ # The log is append-only, so the last matching line is the latest attempt;
239
+ # timestamps have one-second resolution and cannot break ties.
240
+ for record in reversed(records):
241
+ if task_id is None or record.task_id == task_id:
242
+ return record.task_id, record.attempt_id
243
+ return None
244
+
245
+
246
+ def _cmd_label(args: argparse.Namespace) -> int:
247
+ if args.import_csv:
248
+ try:
249
+ count = import_csv(args.import_csv, args.labels)
250
+ except (OSError, LabelError, KeyError) as exc:
251
+ print(f"cpt label: import failed: {exc}", file=sys.stderr)
252
+ return 1
253
+ print(f"cpt: imported {count} labels into {args.labels}")
254
+ return 0
255
+ if not args.outcome:
256
+ print("cpt label: give an outcome (pass or fail) or --import CSV", file=sys.stderr)
257
+ return 2
258
+
259
+ task_id, attempt_id = args.task, args.attempt
260
+ if task_id is None or attempt_id is None:
261
+ try:
262
+ records = read_jsonl(args.log)
263
+ except OSError as exc:
264
+ print(f"cpt label: cannot read log to find the attempt: {exc}", file=sys.stderr)
265
+ return 1
266
+ found = _latest_attempt(records, task_id)
267
+ if found is None:
268
+ print("cpt label: no matching attempt in the log", file=sys.stderr)
269
+ return 1
270
+ task_id, attempt_id = found
271
+ label = Label(
272
+ task_id=task_id, attempt_id=attempt_id, outcome=args.outcome, leaked=args.leak, note=args.note
273
+ )
274
+ append_label(args.labels, label)
275
+ flag = " (leaked)" if args.leak else ""
276
+ print(f"cpt: labelled task {task_id} attempt {attempt_id} as {args.outcome}{flag}")
277
+ return 0
278
+
279
+
280
+ def _analysis(args: argparse.Namespace, *, by_task_type: bool):
281
+ try:
282
+ return load_analysis(
283
+ log=args.log,
284
+ labels=args.labels,
285
+ prices=args.prices,
286
+ by_task_type=by_task_type,
287
+ task_type=args.task_type,
288
+ k=args.k,
289
+ retry_cap=args.retry_cap,
290
+ cleanup_cost=args.cleanup_cost,
291
+ leak_rate=args.leak_rate,
292
+ resamples=args.resamples,
293
+ seed=args.seed,
294
+ )
295
+ except (OSError, PricingError) as exc:
296
+ print(f"cpt: {exc}", file=sys.stderr)
297
+ return None
298
+
299
+
300
+ def _cmd_report(args: argparse.Namespace) -> int:
301
+ analysis = _analysis(args, by_task_type=not args.by_model_only)
302
+ if analysis is None:
303
+ return 1
304
+ if args.json:
305
+ print(render_json(analysis.attempts, analysis.summaries, analysis.table, harness=args.harness))
306
+ else:
307
+ print(render_report(analysis.attempts, analysis.summaries, analysis.table, harness=args.harness))
308
+ return 0
309
+
310
+
311
+ def _cmd_compare(args: argparse.Namespace) -> int:
312
+ analysis = _analysis(args, by_task_type=False)
313
+ if analysis is None:
314
+ return 1
315
+ try:
316
+ a = pick_model(analysis.summaries, args.model_a)
317
+ b = pick_model(analysis.summaries, args.model_b)
318
+ except LookupError as exc:
319
+ print(f"cpt compare: {exc}", file=sys.stderr)
320
+ return 1
321
+ if args.json:
322
+ print(
323
+ render_json(
324
+ analysis.attempts, [a, b], analysis.table, harness=args.harness, comparison=(a, b)
325
+ )
326
+ )
327
+ else:
328
+ print(render_comparison(a, b, analysis.table, harness=args.harness))
329
+ return 0
330
+
331
+
332
+ def _cmd_import(args: argparse.Namespace) -> int:
333
+ importer = import_langfuse if args.source == "langfuse" else import_litellm
334
+ options = {"task_type": args.task_type}
335
+ if args.task_field:
336
+ options["task_field"] = args.task_field
337
+ if args.attempt_field:
338
+ options["attempt_field"] = args.attempt_field
339
+ try:
340
+ result = importer(args.file, **options)
341
+ except (OSError, ValueError, ImportError_) as exc:
342
+ print(f"cpt import: {exc}", file=sys.stderr)
343
+ return 1
344
+ for warning in result.warnings:
345
+ print(f"cpt import: warning: {warning}", file=sys.stderr)
346
+ attempts = {(r.task_id, r.attempt_id) for r in result.records}
347
+ tasks = {r.task_id for r in result.records}
348
+ if args.dry_run:
349
+ print(
350
+ f"cpt import: would append {len(result.records)} steps "
351
+ f"({len(attempts)} attempts over {len(tasks)} tasks) to {args.log}"
352
+ )
353
+ return 0
354
+ writer = JsonlWriter(args.log)
355
+ for record in result.records:
356
+ writer.append(record)
357
+ print(
358
+ f"cpt import: appended {len(result.records)} steps "
359
+ f"({len(attempts)} attempts over {len(tasks)} tasks) to {args.log}"
360
+ )
361
+ return 0
362
+
363
+
364
+ def _cmd_prices(args: argparse.Namespace) -> int:
365
+ overrides = {}
366
+ for mapping in args.map:
367
+ if "=" not in mapping:
368
+ print(f"cpt prices: --map expects HUB_ID=API_ID, got '{mapping}'", file=sys.stderr)
369
+ return 2
370
+ hub_id, api_id = mapping.split("=", 1)
371
+ overrides[hub_id.strip()] = api_id.strip()
372
+ try:
373
+ hub = load_hub(args.from_file) if args.from_file else fetch_hub(args.url)
374
+ except (OSError, ValueError) as exc:
375
+ print(f"cpt prices: cannot load the hub catalogue: {exc}", file=sys.stderr)
376
+ return 1
377
+ table, warnings = build_table(hub, args.provider, overrides)
378
+ out = Path(args.out or f"prices/{args.provider}.json")
379
+ existing = json.loads(out.read_text(encoding="utf-8")) if out.exists() else None
380
+
381
+ for warning in warnings:
382
+ print(f"cpt prices: warning: {warning}", file=sys.stderr)
383
+ changes = diff_tables(existing, table)
384
+ print(f"hub catalogue {table['as_of']}: {len(table['models'])} {args.provider} models priced")
385
+ if existing is None:
386
+ print(f"{out} does not exist yet; all models are new")
387
+ if changes:
388
+ print("\n".join(changes))
389
+ else:
390
+ print(f"no changes against {out}")
391
+ if args.write:
392
+ write_table(table, out)
393
+ print(f"wrote {out}")
394
+ elif changes or existing is None:
395
+ print("dry run; pass --write to update the table")
396
+ return 0
397
+
398
+
399
+ def _cmd_mcp(args: argparse.Namespace) -> int:
400
+ from .mcp_server import main as mcp_main
401
+
402
+ return mcp_main()
403
+
404
+
405
+ if __name__ == "__main__":
406
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """Importers turn usage exports from other tools into cpt step records, so
2
+ teams that already log usage get the cost-per-task maths without changing
3
+ their stack. Only usage metadata is read; prompt and completion content in
4
+ the export is never copied into the log."""
5
+
6
+ from .common import ImportResult, read_rows
7
+ from .langfuse import import_langfuse
8
+ from .litellm import import_litellm
9
+
10
+ __all__ = ["ImportResult", "import_langfuse", "import_litellm", "read_rows"]
@@ -0,0 +1,183 @@
1
+ """Shared plumbing for importers: reading export files, dotted field lookup,
2
+ and turning per-call rows into StepRecords grouped by task and attempt."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import csv
7
+ import json
8
+ from collections import defaultdict
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from ..schema import StepRecord
15
+
16
+
17
+ class ImportError_(ValueError):
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class ParsedCall:
23
+ """One API call as an importer understood it, before task grouping."""
24
+
25
+ model: str
26
+ provider: str
27
+ timestamp: str
28
+ input_tokens: int = 0
29
+ cache_read_tokens: int = 0
30
+ cache_write_tokens: int = 0
31
+ output_tokens: int = 0
32
+ reasoning_tokens: int | None = None
33
+ latency_ms: int = 0
34
+ tool_names: list[str] = field(default_factory=list)
35
+
36
+
37
+ @dataclass
38
+ class ImportResult:
39
+ records: list[StepRecord]
40
+ skipped: int
41
+ warnings: list[str]
42
+
43
+
44
+ def read_rows(path: str | Path) -> list[dict]:
45
+ """Read a CSV, JSON array, JSON object holding an array, or JSONL file."""
46
+ file = Path(path)
47
+ suffix = file.suffix.lower()
48
+ text = file.read_text(encoding="utf-8")
49
+ if suffix == ".csv":
50
+ return list(csv.DictReader(text.splitlines()))
51
+ if suffix == ".jsonl":
52
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
53
+ data = json.loads(text)
54
+ if isinstance(data, list):
55
+ return data
56
+ if isinstance(data, dict):
57
+ for value in data.values():
58
+ if isinstance(value, list):
59
+ return value
60
+ raise ImportError_(f"{file}: expected a list of rows, or an object containing one")
61
+
62
+
63
+ def get_field(row: dict, dotted: str):
64
+ """Look up ``a.b.c`` in a row; nested values stored as JSON strings (as CSV
65
+ exports do) are decoded on the way."""
66
+ current = row
67
+ for part in dotted.split("."):
68
+ if isinstance(current, str):
69
+ try:
70
+ current = json.loads(current)
71
+ except ValueError:
72
+ return None
73
+ if not isinstance(current, dict):
74
+ return None
75
+ current = current.get(part)
76
+ if current is None:
77
+ return None
78
+ return current
79
+
80
+
81
+ def as_dict(value) -> dict:
82
+ if isinstance(value, str):
83
+ try:
84
+ value = json.loads(value)
85
+ except ValueError:
86
+ return {}
87
+ return value if isinstance(value, dict) else {}
88
+
89
+
90
+ def to_int(value) -> int:
91
+ if value in (None, ""):
92
+ return 0
93
+ try:
94
+ return int(float(value))
95
+ except (TypeError, ValueError):
96
+ return 0
97
+
98
+
99
+ def parse_time(value) -> datetime | None:
100
+ if value in (None, ""):
101
+ return None
102
+ if isinstance(value, (int, float)):
103
+ seconds = value / 1000 if value > 1e11 else value
104
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
105
+ try:
106
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
107
+ except ValueError:
108
+ return None
109
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
110
+
111
+
112
+ def infer_provider(model: str) -> str:
113
+ lowered = (model or "").lower()
114
+ if lowered.startswith("claude"):
115
+ return "anthropic"
116
+ if lowered.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
117
+ return "openai"
118
+ if lowered.startswith("grok"):
119
+ return "xai"
120
+ if lowered.startswith("gemini"):
121
+ return "google"
122
+ return "unknown"
123
+
124
+
125
+ def rows_to_records(
126
+ rows: list[dict],
127
+ parse: Callable[[dict], ParsedCall | None],
128
+ *,
129
+ task_field: str,
130
+ attempt_field: str,
131
+ task_type: str | None,
132
+ ) -> ImportResult:
133
+ """Group parsed calls by (task, attempt), order them in time and number
134
+ the steps. Rows without a model, usage, task or attempt are skipped."""
135
+ grouped: dict[tuple[str, str], list[ParsedCall]] = defaultdict(list)
136
+ skipped = 0
137
+ missing_ids = 0
138
+ for row in rows:
139
+ task_id = get_field(row, task_field)
140
+ attempt_id = get_field(row, attempt_field)
141
+ call = parse(row)
142
+ if call is None:
143
+ skipped += 1
144
+ continue
145
+ if task_id in (None, "") or attempt_id in (None, ""):
146
+ missing_ids += 1
147
+ continue
148
+ grouped[(str(task_id), str(attempt_id))].append(call)
149
+
150
+ records: list[StepRecord] = []
151
+ for (task_id, attempt_id), calls in grouped.items():
152
+ calls.sort(key=lambda c: c.timestamp)
153
+ for step_id, call in enumerate(calls, start=1):
154
+ records.append(
155
+ StepRecord(
156
+ task_id=task_id,
157
+ attempt_id=attempt_id,
158
+ step_id=step_id,
159
+ timestamp=call.timestamp,
160
+ provider=call.provider,
161
+ model=call.model,
162
+ input_tokens=call.input_tokens,
163
+ cache_read_tokens=call.cache_read_tokens,
164
+ cache_write_tokens=call.cache_write_tokens,
165
+ reasoning_tokens=call.reasoning_tokens,
166
+ output_tokens=call.output_tokens,
167
+ tool_call_count=len(call.tool_names),
168
+ tool_names=call.tool_names,
169
+ latency_ms=call.latency_ms,
170
+ task_type=task_type,
171
+ )
172
+ )
173
+ records.sort(key=lambda r: (r.task_id, r.attempt_id, r.step_id))
174
+
175
+ warnings = []
176
+ if skipped:
177
+ warnings.append(f"{skipped} rows had no model or usage and were skipped")
178
+ if missing_ids:
179
+ warnings.append(
180
+ f"{missing_ids} rows had no '{task_field}' or '{attempt_field}' value and were "
181
+ "skipped; use --task-field / --attempt-field to point at the right columns"
182
+ )
183
+ return ImportResult(records=records, skipped=skipped + missing_ids, warnings=warnings)