claude-dev-env 8.14.0 → 8.15.0

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,603 @@
1
+ """Check task usage reporting through its command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from decimal import Decimal
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ SCRIPT_PATH = Path(__file__).with_name("task_usage_report.py")
14
+ CODEX_STREAM_FIXTURE = (
15
+ Path(__file__).resolve().parent.parent
16
+ / "_shared"
17
+ / "pr-loop"
18
+ / "scripts"
19
+ / "fixtures"
20
+ / "success_stream_v0.144.3.jsonl"
21
+ )
22
+ RUN_ARGUMENTS = [
23
+ "run",
24
+ "--task-id",
25
+ "task-7",
26
+ "--run-id",
27
+ "run-1",
28
+ "--repository",
29
+ "team/project",
30
+ "--outcome",
31
+ "pass",
32
+ ]
33
+
34
+
35
+ def invoke_cli(
36
+ arguments: list[str], stdin_text: str
37
+ ) -> subprocess.CompletedProcess[str]:
38
+ return subprocess.run(
39
+ [sys.executable, str(SCRIPT_PATH), *arguments],
40
+ input=stdin_text,
41
+ capture_output=True,
42
+ text=True,
43
+ check=False,
44
+ )
45
+
46
+
47
+ def report_for(source_format: str, source_payload: object) -> dict[str, object]:
48
+ if source_format == "codex-json":
49
+ stdin_text = "\n".join(
50
+ map(
51
+ json.dumps,
52
+ [{"type": "thread.started", "thread_id": "thread-1"}, source_payload],
53
+ )
54
+ )
55
+ else:
56
+ stdin_text = json.dumps(source_payload)
57
+ completed = invoke_cli(
58
+ [*RUN_ARGUMENTS, "--source-format", source_format], stdin_text
59
+ )
60
+ assert completed.returncode == 0, completed.stderr
61
+ return json.loads(completed.stdout)
62
+
63
+
64
+ def test_claude_json_emits_only_allowlisted_fields_and_uses_model_usage_once() -> None:
65
+ source_payload = {
66
+ "type": "result",
67
+ "result": "SECRET_PROMPT_AND_ANSWER",
68
+ "session_id": "SECRET_SESSION",
69
+ "total_cost_usd": 0.125,
70
+ "usage": {
71
+ "input_tokens": 17,
72
+ "cache_creation_input_tokens": 11,
73
+ "cache_read_input_tokens": 31,
74
+ "output_tokens": 7,
75
+ },
76
+ "modelUsage": {
77
+ "claude-opus": {
78
+ "inputTokens": 17,
79
+ "cacheCreationInputTokens": 11,
80
+ "cacheReadInputTokens": 31,
81
+ "outputTokens": 7,
82
+ "costUSD": 0.125,
83
+ }
84
+ },
85
+ }
86
+ report = report_for("claude-json", source_payload)
87
+ serialized_report = json.dumps(report)
88
+ assert "SECRET_PROMPT_AND_ANSWER" not in serialized_report
89
+ assert "SECRET_SESSION" not in serialized_report
90
+ assert report["uncached_input_tokens"] == 17
91
+ assert report["cache_write_tokens"] == 11
92
+ assert report["cache_read_tokens"] == 31
93
+ assert report["output_tokens"] == 7
94
+ assert report["cost_usd"] == "0.125"
95
+ assert report["cost_basis"] == "provider_estimate"
96
+ assert report["models"] == ["claude-opus"]
97
+ assert report["event_count"] == 1
98
+
99
+
100
+ def test_claude_json_keeps_missing_usage_unknown() -> None:
101
+ report = report_for(
102
+ "claude-json", {"type": "result", "total_cost_usd": 0.03, "result": "secret"}
103
+ )
104
+ assert report["uncached_input_tokens"] is None
105
+ assert report["cache_write_tokens"] is None
106
+ assert report["cost_usd"] == "0.03"
107
+
108
+
109
+ def test_claude_otel_deduplicates_request_ids_and_keeps_models_separate() -> None:
110
+ first_event = {
111
+ "body": "claude_code.api_request",
112
+ "attributes": {
113
+ "request_id": "request-1",
114
+ "session.id": "session-1",
115
+ "model": "claude-opus",
116
+ "input_tokens": 2,
117
+ "cache_creation_tokens": 3,
118
+ "cache_read_tokens": 5,
119
+ "output_tokens": 7,
120
+ "cost_usd_micros": 12_500,
121
+ "prompt": "SECRET_PROMPT",
122
+ },
123
+ }
124
+ second_event = {
125
+ "body": "claude_code.api_request",
126
+ "attributes": {
127
+ "request_id": "request-2",
128
+ "session.id": "session-1",
129
+ "model": "claude-sonnet",
130
+ "input_tokens": 11,
131
+ "cache_creation_tokens": 13,
132
+ "cache_read_tokens": 17,
133
+ "output_tokens": 19,
134
+ "cost_usd": 0.0375,
135
+ },
136
+ }
137
+ stdin_text = "\n".join(map(json.dumps, [first_event, first_event, second_event]))
138
+ completed = invoke_cli(
139
+ [*RUN_ARGUMENTS, "--source-format", "claude-otel"], stdin_text
140
+ )
141
+ assert completed.returncode == 0, completed.stderr
142
+ report = json.loads(completed.stdout)
143
+ assert report["event_count"] == 2
144
+ assert report["uncached_input_tokens"] == 13
145
+ assert report["cache_write_tokens"] == 16
146
+ assert report["cache_read_tokens"] == 22
147
+ assert report["output_tokens"] == 26
148
+ assert Decimal(report["cost_usd"]) == Decimal("0.0500")
149
+ assert report["models"] == ["claude-opus", "claude-sonnet"]
150
+ assert "SECRET_PROMPT" not in completed.stdout
151
+
152
+
153
+ def test_claude_otel_accepts_typed_attributes_and_ignores_other_events() -> None:
154
+ source_payload = {
155
+ "resourceLogs": [
156
+ {
157
+ "scopeLogs": [
158
+ {
159
+ "logRecords": [
160
+ {
161
+ "body": {"stringValue": "claude_code.user_prompt"},
162
+ "attributes": [
163
+ {
164
+ "key": "session.id",
165
+ "value": {"stringValue": "session-1"},
166
+ },
167
+ {
168
+ "key": "prompt",
169
+ "value": {"stringValue": "SECRET_PROMPT"},
170
+ },
171
+ ],
172
+ },
173
+ {
174
+ "body": {"stringValue": "claude_code.api_request"},
175
+ "attributes": [
176
+ {
177
+ "key": "request_id",
178
+ "value": {"stringValue": "r1"},
179
+ },
180
+ {"key": "model", "value": {"stringValue": "opus"}},
181
+ {
182
+ "key": "session.id",
183
+ "value": {"stringValue": "session-1"},
184
+ },
185
+ {"key": "input_tokens", "value": {"intValue": "4"}},
186
+ {
187
+ "key": "cache_creation_tokens",
188
+ "value": {"intValue": "0"},
189
+ },
190
+ {
191
+ "key": "cache_read_tokens",
192
+ "value": {"intValue": "8"},
193
+ },
194
+ {
195
+ "key": "output_tokens",
196
+ "value": {"intValue": "2"},
197
+ },
198
+ ],
199
+ },
200
+ ]
201
+ }
202
+ ]
203
+ }
204
+ ]
205
+ }
206
+ report = report_for("claude-otel", source_payload)
207
+ assert report["event_count"] == 1
208
+ assert report["uncached_input_tokens"] == 4
209
+ assert report["cache_read_tokens"] == 8
210
+ assert report["cost_usd"] is None
211
+ assert "SECRET_PROMPT" not in json.dumps(report)
212
+ assert "session-1" not in json.dumps(report)
213
+
214
+
215
+ def test_codex_uses_cached_and_reasoning_counts_as_subsets() -> None:
216
+ source_payload = [
217
+ {"type": "thread.started", "thread_id": "SECRET_THREAD"},
218
+ {
219
+ "type": "turn.completed",
220
+ "turn_id": "turn-1",
221
+ "model": "gpt-x",
222
+ "usage": {
223
+ "input_tokens": 100,
224
+ "cached_input_tokens": 20,
225
+ "cache_write_input_tokens": 5,
226
+ "output_tokens": 30,
227
+ "reasoning_output_tokens": 10,
228
+ },
229
+ "text": "SECRET_ANSWER",
230
+ },
231
+ ]
232
+ stdin_text = "\n".join(map(json.dumps, source_payload))
233
+ completed = invoke_cli(
234
+ [
235
+ *RUN_ARGUMENTS,
236
+ "--source-format",
237
+ "codex-json",
238
+ "--rate",
239
+ "gpt-x=1,2,0.1,4",
240
+ "--rate-source",
241
+ "vendor-price-card",
242
+ "--rate-effective-date",
243
+ "2026-09-24",
244
+ ],
245
+ stdin_text,
246
+ )
247
+ assert completed.returncode == 0, completed.stderr
248
+ report = json.loads(completed.stdout)
249
+ assert report["uncached_input_tokens"] == 75
250
+ assert report["cache_write_tokens"] == 5
251
+ assert report["cache_read_tokens"] == 20
252
+ assert report["output_tokens"] == 30
253
+ assert report["reasoning_output_tokens"] == 10
254
+ assert report["cost_usd"] == "0.000207"
255
+ assert report["cost_basis"] == "caller_rates"
256
+ assert report["rate_source"] == "vendor-price-card"
257
+ assert report["rate_effective_date"] == "2026-09-24"
258
+ assert "SECRET_THREAD" not in completed.stdout
259
+ assert "SECRET_ANSWER" not in completed.stdout
260
+
261
+
262
+ def test_codex_deduplicates_turn_ids_and_requires_rates_for_cost() -> None:
263
+ turn_event = {
264
+ "type": "turn.completed",
265
+ "turn_id": "turn-1",
266
+ "usage": {
267
+ "input_tokens": 20,
268
+ "cached_input_tokens": 5,
269
+ "cache_write_input_tokens": 0,
270
+ "output_tokens": 3,
271
+ },
272
+ }
273
+ stdin_text = "\n".join(
274
+ map(
275
+ json.dumps,
276
+ [
277
+ {"type": "thread.started", "thread_id": "thread-1"},
278
+ turn_event,
279
+ turn_event,
280
+ ],
281
+ )
282
+ )
283
+ completed = invoke_cli(
284
+ [*RUN_ARGUMENTS, "--source-format", "codex-json"], stdin_text
285
+ )
286
+ assert completed.returncode == 0, completed.stderr
287
+ report = json.loads(completed.stdout)
288
+ assert report["event_count"] == 1
289
+ assert report["uncached_input_tokens"] == 15
290
+ assert report["cost_usd"] is None
291
+ assert report["cost_basis"] == "unknown"
292
+
293
+
294
+ def test_codex_missing_usage_does_not_become_zero() -> None:
295
+ report = report_for("codex-json", {"type": "turn.completed"})
296
+ assert report["event_count"] == 1
297
+ assert report["uncached_input_tokens"] is None
298
+ assert report["output_tokens"] is None
299
+ assert report["cost_usd"] is None
300
+
301
+
302
+ @pytest.mark.parametrize("bad_count", [-1, 1.5, "oops", None, True])
303
+ def test_malformed_numeric_usage_fails_without_echoing_event(bad_count: object) -> None:
304
+ source_payload = {
305
+ "type": "turn.completed",
306
+ "usage": {
307
+ "input_tokens": bad_count,
308
+ "cached_input_tokens": 0,
309
+ "cache_write_input_tokens": 0,
310
+ "output_tokens": 1,
311
+ },
312
+ "text": "SECRET_ANSWER",
313
+ }
314
+ completed = invoke_cli(
315
+ [*RUN_ARGUMENTS, "--source-format", "codex-json"],
316
+ "\n".join(
317
+ map(
318
+ json.dumps,
319
+ [{"type": "thread.started", "thread_id": "thread-1"}, source_payload],
320
+ )
321
+ ),
322
+ )
323
+ assert completed.returncode != 0
324
+ assert completed.stdout == ""
325
+ assert "SECRET_ANSWER" not in completed.stderr
326
+
327
+
328
+ def test_codex_rejects_cached_input_exceeding_total_input() -> None:
329
+ source_payload = {
330
+ "type": "turn.completed",
331
+ "usage": {
332
+ "input_tokens": 10,
333
+ "cached_input_tokens": 11,
334
+ "cache_write_input_tokens": 0,
335
+ "output_tokens": 1,
336
+ },
337
+ }
338
+ completed = invoke_cli(
339
+ [*RUN_ARGUMENTS, "--source-format", "codex-json"],
340
+ "\n".join(
341
+ map(
342
+ json.dumps,
343
+ [{"type": "thread.started", "thread_id": "thread-1"}, source_payload],
344
+ )
345
+ ),
346
+ )
347
+ assert completed.returncode != 0
348
+
349
+
350
+ def test_summary_groups_resumed_runs_and_includes_failed_attempt_cost() -> None:
351
+ first_report = {
352
+ "task_id": "task-7",
353
+ "run_id": "run-1",
354
+ "repository": "team/project",
355
+ "outcome": "fail",
356
+ "cost_usd": "0.20",
357
+ }
358
+ second_report = {
359
+ "task_id": "task-7",
360
+ "run_id": "run-2",
361
+ "repository": "team/project",
362
+ "outcome": "pass",
363
+ "cost_usd": "0.30",
364
+ }
365
+ stdin_text = "\n".join(map(json.dumps, [first_report, second_report]))
366
+ completed = invoke_cli(["summary"], stdin_text)
367
+ assert completed.returncode == 0, completed.stderr
368
+ summary = json.loads(completed.stdout)
369
+ assert summary["task_count"] == 1
370
+ assert summary["passed_task_count"] == 1
371
+ assert Decimal(summary["total_cost_usd"]) == Decimal("0.50")
372
+ assert Decimal(summary["cost_per_passed_task_usd"]) == Decimal("0.50")
373
+ assert summary["tasks"][0]["run_ids"] == ["run-1", "run-2"]
374
+
375
+
376
+ @pytest.mark.parametrize("missing_field", ["cost_usd", "outcome"])
377
+ def test_summary_marks_missing_cost_or_outcome_unknown(missing_field: str) -> None:
378
+ source_report = {
379
+ "task_id": "task-7",
380
+ "run_id": "run-1",
381
+ "repository": "team/project",
382
+ "outcome": "pass",
383
+ "cost_usd": "0.20",
384
+ }
385
+ del source_report[missing_field]
386
+ completed = invoke_cli(["summary"], json.dumps(source_report))
387
+ assert completed.returncode == 0, completed.stderr
388
+ summary = json.loads(completed.stdout)
389
+ assert summary["cost_per_passed_task_usd"] is None
390
+ assert summary["status"] == "unknown"
391
+
392
+
393
+ def test_summary_deduplicates_identical_run_rows() -> None:
394
+ source_report = {
395
+ "task_id": "task-7",
396
+ "run_id": "run-1",
397
+ "repository": "team/project",
398
+ "outcome": "pass",
399
+ "cost_usd": "0.20",
400
+ }
401
+ completed = invoke_cli(
402
+ ["summary"], "\n".join(map(json.dumps, [source_report, source_report]))
403
+ )
404
+ assert completed.returncode == 0, completed.stderr
405
+ summary = json.loads(completed.stdout)
406
+ assert Decimal(summary["total_cost_usd"]) == Decimal("0.20")
407
+ assert summary["tasks"][0]["run_ids"] == ["run-1"]
408
+
409
+
410
+ def test_claude_otel_accepts_flat_event_attributes() -> None:
411
+ source_payload = {
412
+ "event.name": "api_request",
413
+ "session.id": "session-1",
414
+ "model": "claude-sonnet",
415
+ "input_tokens": 4,
416
+ "cache_creation_tokens": 2,
417
+ "cache_read_tokens": 3,
418
+ "output_tokens": 1,
419
+ "cost_usd": 0.002,
420
+ }
421
+ report = report_for("claude-otel", source_payload)
422
+ assert report["uncached_input_tokens"] == 4
423
+ assert report["cache_write_tokens"] == 2
424
+ assert report["cost_usd"] == "0.002"
425
+
426
+
427
+ def test_codex_mixed_models_need_each_models_rate() -> None:
428
+ all_events = [
429
+ {
430
+ "type": "turn.completed",
431
+ "turn_id": f"turn-{each_index}",
432
+ "model": each_model,
433
+ "usage": {
434
+ "input_tokens": 1_000_000,
435
+ "cached_input_tokens": 0,
436
+ "cache_write_input_tokens": 0,
437
+ "output_tokens": 0,
438
+ },
439
+ }
440
+ for each_index, each_model in enumerate(("gpt-small", "gpt-large"))
441
+ ]
442
+ stdin_text = "\n".join(
443
+ map(
444
+ json.dumps,
445
+ [{"type": "thread.started", "thread_id": "thread-1"}, *all_events],
446
+ )
447
+ )
448
+ base_arguments = [*RUN_ARGUMENTS, "--source-format", "codex-json"]
449
+ rate_provenance = [
450
+ "--rate-source",
451
+ "vendor-price-card",
452
+ "--rate-effective-date",
453
+ "2026-09-24",
454
+ ]
455
+ partial = invoke_cli(
456
+ [*base_arguments, "--rate", "gpt-small=1,0,0,0", *rate_provenance],
457
+ stdin_text,
458
+ )
459
+ complete = invoke_cli(
460
+ [
461
+ *base_arguments,
462
+ "--rate",
463
+ "gpt-small=1,0,0,0",
464
+ "--rate",
465
+ "gpt-large=3,0,0,0",
466
+ *rate_provenance,
467
+ ],
468
+ stdin_text,
469
+ )
470
+ assert partial.returncode == 0, partial.stderr
471
+ assert complete.returncode == 0, complete.stderr
472
+ assert json.loads(partial.stdout)["cost_usd"] is None
473
+ complete_report = json.loads(complete.stdout)
474
+ assert complete_report["cost_usd"] == "4"
475
+ assert complete_report["models"] == ["gpt-large", "gpt-small"]
476
+
477
+
478
+ def test_otel_rejects_mixed_sessions_without_leaking_identifiers() -> None:
479
+ all_events = [
480
+ {
481
+ "body": "claude_code.api_request",
482
+ "attributes": {
483
+ "session.id": each_session,
484
+ "request_id": f"request-{each_index}",
485
+ "model": "claude-opus",
486
+ "input_tokens": 1,
487
+ "cache_creation_tokens": 0,
488
+ "cache_read_tokens": 0,
489
+ "output_tokens": 1,
490
+ "cost_usd": 0.01,
491
+ },
492
+ }
493
+ for each_index, each_session in enumerate(
494
+ ("SECRET_SESSION_A", "SECRET_SESSION_B")
495
+ )
496
+ ]
497
+ completed = invoke_cli(
498
+ [*RUN_ARGUMENTS, "--source-format", "claude-otel"],
499
+ "\n".join(map(json.dumps, all_events)),
500
+ )
501
+ assert completed.returncode != 0
502
+ assert completed.stdout == ""
503
+ assert "SECRET_SESSION" not in completed.stderr
504
+
505
+
506
+ def test_otel_rejects_non_api_record_from_another_session() -> None:
507
+ all_records = [
508
+ {
509
+ "body": "claude_code.api_request",
510
+ "attributes": {"session.id": "SECRET_SESSION_A"},
511
+ },
512
+ {
513
+ "body": "claude_code.user_prompt",
514
+ "attributes": {
515
+ "session.id": "SECRET_SESSION_B",
516
+ "prompt": "SECRET_PROMPT",
517
+ },
518
+ },
519
+ ]
520
+ completed = invoke_cli(
521
+ [*RUN_ARGUMENTS, "--source-format", "claude-otel"], json.dumps(all_records)
522
+ )
523
+ assert completed.returncode != 0
524
+ assert completed.stdout == ""
525
+ assert "one session ID" in completed.stderr
526
+ assert "SECRET_SESSION" not in completed.stderr
527
+ assert "SECRET_PROMPT" not in completed.stderr
528
+
529
+
530
+ def test_otel_rejects_capture_without_api_request() -> None:
531
+ source_payload = {
532
+ "body": "claude_code.user_prompt",
533
+ "attributes": {"session.id": "session-1"},
534
+ }
535
+ completed = invoke_cli(
536
+ [*RUN_ARGUMENTS, "--source-format", "claude-otel"], json.dumps(source_payload)
537
+ )
538
+ assert completed.returncode != 0
539
+ assert completed.stdout == ""
540
+
541
+
542
+ def test_codex_rejects_concatenated_threads() -> None:
543
+ all_events = [
544
+ {"type": "thread.started", "thread_id": "thread-1"},
545
+ {"type": "turn.completed", "usage": {"input_tokens": 1}},
546
+ {"type": "thread.started", "thread_id": "thread-2"},
547
+ {"type": "turn.completed", "usage": {"input_tokens": 1}},
548
+ ]
549
+ completed = invoke_cli(
550
+ [*RUN_ARGUMENTS, "--source-format", "codex-json"],
551
+ "\n".join(map(json.dumps, all_events)),
552
+ )
553
+ assert completed.returncode != 0
554
+ assert completed.stdout == ""
555
+
556
+
557
+ def test_codex_rate_requires_source_and_effective_date() -> None:
558
+ all_events = [
559
+ {"type": "thread.started", "thread_id": "thread-1"},
560
+ {
561
+ "type": "turn.completed",
562
+ "model": "gpt-x",
563
+ "usage": {
564
+ "input_tokens": 1,
565
+ "cached_input_tokens": 0,
566
+ "cache_write_input_tokens": 0,
567
+ "output_tokens": 1,
568
+ },
569
+ },
570
+ ]
571
+ completed = invoke_cli(
572
+ [*RUN_ARGUMENTS, "--source-format", "codex-json", "--rate", "gpt-x=1,0,0,1"],
573
+ "\n".join(map(json.dumps, all_events)),
574
+ )
575
+ assert completed.returncode != 0
576
+ assert completed.stdout == ""
577
+
578
+
579
+ def test_codex_fixture_without_cache_write_field_uses_zero_write_cost() -> None:
580
+ completed = invoke_cli(
581
+ [
582
+ *RUN_ARGUMENTS,
583
+ "--source-format",
584
+ "codex-json",
585
+ "--model",
586
+ "gpt-x",
587
+ "--rate",
588
+ "gpt-x=1,2,0.1,4",
589
+ "--rate-source",
590
+ "vendor-price-card",
591
+ "--rate-effective-date",
592
+ "2026-09-24",
593
+ ],
594
+ CODEX_STREAM_FIXTURE.read_text(encoding="utf-8"),
595
+ )
596
+ assert completed.returncode == 0, completed.stderr
597
+ report = json.loads(completed.stdout)
598
+ assert report["uncached_input_tokens"] == 12
599
+ assert report["cache_write_tokens"] == 0
600
+ assert report["cache_read_tokens"] == 0
601
+ assert report["output_tokens"] == 6
602
+ assert report["cost_usd"] == "0.000036"
603
+ assert report["cost_basis"] == "caller_rates"