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.
- cost_per_task/__init__.py +3 -0
- cost_per_task/analysis.py +65 -0
- cost_per_task/cli.py +406 -0
- cost_per_task/importers/__init__.py +10 -0
- cost_per_task/importers/common.py +183 -0
- cost_per_task/importers/langfuse.py +106 -0
- cost_per_task/importers/litellm.py +80 -0
- cost_per_task/labels.py +86 -0
- cost_per_task/mcp_server.py +153 -0
- cost_per_task/metrics.py +314 -0
- cost_per_task/prices_hub.py +181 -0
- cost_per_task/pricing.py +167 -0
- cost_per_task/providers/__init__.py +0 -0
- cost_per_task/providers/anthropic.py +87 -0
- cost_per_task/providers/base.py +41 -0
- cost_per_task/providers/openai.py +145 -0
- cost_per_task/proxy.py +388 -0
- cost_per_task/report.py +287 -0
- cost_per_task/schema.py +75 -0
- cost_per_task/stats.py +97 -0
- cost_per_task-0.4.0.dist-info/METADATA +415 -0
- cost_per_task-0.4.0.dist-info/RECORD +25 -0
- cost_per_task-0.4.0.dist-info/WHEEL +4 -0
- cost_per_task-0.4.0.dist-info/entry_points.txt +2 -0
- cost_per_task-0.4.0.dist-info/licenses/LICENSE +21 -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)
|