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,387 @@
1
+ """Parse vendor usage events and compute token costs for task reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from dataclasses import fields as dataclass_fields
8
+ from decimal import Decimal, InvalidOperation
9
+
10
+
11
+ class UsageReportRunFatal(ValueError):
12
+ """Stop a report when its input cannot be used safely."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelRates:
17
+ input_per_million: Decimal
18
+ write_per_million: Decimal
19
+ read_per_million: Decimal
20
+ generated_per_million: Decimal
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class UsageEvent:
25
+ model: str
26
+ uncached_input_tokens: int | None
27
+ cache_write_tokens: int | None
28
+ cache_read_tokens: int | None
29
+ generated_tokens: int | None
30
+ reasoning_generated_tokens: int | None = None
31
+ cost_usd: Decimal | None = None
32
+ event_id: str | None = None
33
+
34
+
35
+ def _require_object(candidate: object, field_name: str) -> dict[str, object]:
36
+ if not isinstance(candidate, dict):
37
+ raise UsageReportRunFatal(f"{field_name} must be an object")
38
+ return {
39
+ each_key: each_entry
40
+ for each_key, each_entry in candidate.items()
41
+ if isinstance(each_key, str)
42
+ }
43
+
44
+
45
+ def _require_list(candidate: object, field_name: str) -> list[object]:
46
+ if not isinstance(candidate, list):
47
+ raise UsageReportRunFatal(f"{field_name} must be a list")
48
+ return candidate
49
+
50
+
51
+ def _usage_object(candidate: object) -> dict[str, object]:
52
+ if candidate is None:
53
+ return {}
54
+ return _require_object(candidate, "usage")
55
+
56
+
57
+ def _read_count(all_fields: dict[str, object], field_name: str) -> int | None:
58
+ if field_name not in all_fields:
59
+ return None
60
+ candidate = all_fields[field_name]
61
+ if isinstance(candidate, bool):
62
+ raise UsageReportRunFatal(f"{field_name} must be a nonnegative integer")
63
+ if isinstance(candidate, int) and candidate >= 0:
64
+ return candidate
65
+ if isinstance(candidate, str) and candidate.isascii() and candidate.isdecimal():
66
+ return int(candidate)
67
+ raise ValueError(f"{field_name} must be a nonnegative integer")
68
+
69
+
70
+ def _read_money(all_fields: Mapping[str, object], field_name: str) -> Decimal | None:
71
+ if field_name not in all_fields or all_fields[field_name] is None:
72
+ return None
73
+ candidate = all_fields[field_name]
74
+ if isinstance(candidate, bool) or not isinstance(candidate, (int, float, str)):
75
+ raise UsageReportRunFatal(f"{field_name} must be a nonnegative amount")
76
+ try:
77
+ amount = Decimal(str(candidate))
78
+ except InvalidOperation as error:
79
+ raise ValueError(f"{field_name} must be a nonnegative amount") from error
80
+ if not amount.is_finite() or amount < 0:
81
+ raise ValueError(f"{field_name} must be a nonnegative amount")
82
+ return amount
83
+
84
+
85
+ def _safe_label(candidate: object) -> str:
86
+ if not isinstance(candidate, str):
87
+ return "unknown"
88
+ if not candidate:
89
+ return "unknown"
90
+ if not candidate.isascii() or not all(
91
+ each_character.isalnum() or each_character in "._-/"
92
+ for each_character in candidate
93
+ ):
94
+ return "unknown"
95
+ return candidate
96
+
97
+
98
+ def _require_label(candidate: str, field_name: str) -> str:
99
+ label = _safe_label(candidate)
100
+ if label == "unknown":
101
+ raise ValueError(f"{field_name} needs a nonempty identifier")
102
+ return label
103
+
104
+
105
+ def _sum_known(all_counts: list[int | None]) -> int | None:
106
+ if not all_counts or any(each_count is None for each_count in all_counts):
107
+ return None
108
+ return sum(each_count for each_count in all_counts if each_count is not None)
109
+
110
+
111
+ def _sum_costs(all_costs: list[Decimal | None]) -> Decimal | None:
112
+ if not all_costs or any(each_cost is None for each_cost in all_costs):
113
+ return None
114
+ return sum(
115
+ (each_cost for each_cost in all_costs if each_cost is not None), Decimal(0)
116
+ )
117
+
118
+
119
+ def _display_money(amount: Decimal | None) -> str | None:
120
+ if amount is None:
121
+ return None
122
+ return format(amount.normalize(), "f")
123
+
124
+
125
+ def _parse_rate(specification: str) -> tuple[str, ModelRates]:
126
+ model_text, separator, rate_text = specification.partition("=")
127
+ if not separator or _safe_label(model_text) == "unknown":
128
+ raise ValueError("rate needs MODEL=INPUT,WRITE,READ,OUTPUT")
129
+ all_rate_texts = rate_text.split(",")
130
+ if len(all_rate_texts) != len(dataclass_fields(ModelRates)):
131
+ raise ValueError("rate needs four prices per million tokens")
132
+ rate_fields = {str(i): each_rate for i, each_rate in enumerate(all_rate_texts)}
133
+ all_rates = [
134
+ _read_money(rate_fields, str(i))
135
+ for i in range(len(dataclass_fields(ModelRates)))
136
+ ]
137
+ if any(each_rate is None for each_rate in all_rates):
138
+ raise ValueError("rate needs four prices per million tokens")
139
+ return model_text, ModelRates(
140
+ *[each_rate for each_rate in all_rates if each_rate is not None]
141
+ )
142
+
143
+
144
+ def _calculate_codex_cost(
145
+ usage_event: UsageEvent, rates: ModelRates | None
146
+ ) -> Decimal | None:
147
+ if rates is None:
148
+ return None
149
+ all_counts = (
150
+ usage_event.uncached_input_tokens,
151
+ usage_event.cache_write_tokens,
152
+ usage_event.cache_read_tokens,
153
+ usage_event.generated_tokens,
154
+ )
155
+ if any(each_count is None for each_count in all_counts):
156
+ return None
157
+ all_prices = (
158
+ rates.input_per_million,
159
+ rates.write_per_million,
160
+ rates.read_per_million,
161
+ rates.generated_per_million,
162
+ )
163
+ return sum(
164
+ Decimal(each_count) * each_price
165
+ for each_count, each_price in zip(all_counts, all_prices)
166
+ if each_count is not None
167
+ ) / Decimal(1_000_000)
168
+
169
+
170
+ def _parse_claude_usage(
171
+ all_fields: dict[str, object], model: str, is_model_usage: bool
172
+ ) -> UsageEvent:
173
+ if is_model_usage:
174
+ return UsageEvent(
175
+ model=model,
176
+ uncached_input_tokens=_read_count(all_fields, "inputTokens"),
177
+ cache_write_tokens=_read_count(all_fields, "cacheCreationInputTokens"),
178
+ cache_read_tokens=_read_count(all_fields, "cacheReadInputTokens"),
179
+ generated_tokens=_read_count(all_fields, "outputTokens"),
180
+ cost_usd=_read_money(all_fields, "costUSD"),
181
+ )
182
+ return UsageEvent(
183
+ model=model,
184
+ uncached_input_tokens=_read_count(all_fields, "input_tokens"),
185
+ cache_write_tokens=_read_count(all_fields, "cache_creation_input_tokens"),
186
+ cache_read_tokens=_read_count(all_fields, "cache_read_input_tokens"),
187
+ generated_tokens=_read_count(all_fields, "output_tokens"),
188
+ )
189
+
190
+
191
+ def _parse_claude_json(
192
+ all_document_fields: dict[str, object],
193
+ ) -> tuple[list[UsageEvent], Decimal | None]:
194
+ if all_document_fields.get("type") != "result":
195
+ raise ValueError("Claude JSON needs one result object")
196
+ model_usage = all_document_fields.get("modelUsage")
197
+ if isinstance(model_usage, dict) and model_usage:
198
+ all_events = [
199
+ _parse_claude_usage(
200
+ _require_object(each_usage, "model usage"),
201
+ _safe_label(each_model),
202
+ True,
203
+ )
204
+ for each_model, each_usage in model_usage.items()
205
+ ]
206
+ else:
207
+ usage_fields = _usage_object(all_document_fields.get("usage"))
208
+ all_events = [
209
+ _parse_claude_usage(
210
+ usage_fields, _safe_label(all_document_fields.get("model")), False
211
+ )
212
+ ]
213
+ total_cost = _read_money(all_document_fields, "total_cost_usd")
214
+ return all_events, (
215
+ total_cost
216
+ if total_cost is not None
217
+ else _sum_costs([each_event.cost_usd for each_event in all_events])
218
+ )
219
+
220
+
221
+ def _validate_reasoning_tokens(
222
+ generated_tokens: int | None, reasoning_tokens: int | None
223
+ ) -> None:
224
+ if (
225
+ generated_tokens is not None
226
+ and reasoning_tokens is not None
227
+ and reasoning_tokens > generated_tokens
228
+ ):
229
+ raise ValueError("reasoning output exceeds output tokens")
230
+
231
+
232
+ def _uncached_codex_tokens(
233
+ input_tokens: int | None,
234
+ cache_read_tokens: int | None,
235
+ cache_write_tokens: int | None,
236
+ ) -> int | None:
237
+ if input_tokens is None or cache_read_tokens is None or cache_write_tokens is None:
238
+ return None
239
+ uncached_tokens = input_tokens - cache_read_tokens - cache_write_tokens
240
+ if uncached_tokens < 0:
241
+ raise ValueError("cached input exceeds input tokens")
242
+ return uncached_tokens
243
+
244
+
245
+ def _parse_codex_usage(
246
+ all_fields: dict[str, object], model: str, event_id: str | None
247
+ ) -> UsageEvent:
248
+ input_tokens = _read_count(all_fields, "input_tokens")
249
+ cache_read_tokens = _read_count(all_fields, "cached_input_tokens")
250
+ cache_write_tokens = (
251
+ _read_count(all_fields, "cache_write_input_tokens")
252
+ if "cache_write_input_tokens" in all_fields
253
+ else 0
254
+ if all_fields
255
+ else None
256
+ )
257
+ generated_tokens = _read_count(all_fields, "output_tokens")
258
+ reasoning_tokens = _read_count(all_fields, "reasoning_output_tokens")
259
+ _validate_reasoning_tokens(generated_tokens, reasoning_tokens)
260
+ uncached_tokens = _uncached_codex_tokens(
261
+ input_tokens, cache_read_tokens, cache_write_tokens
262
+ )
263
+ return UsageEvent(
264
+ model,
265
+ uncached_tokens,
266
+ cache_write_tokens,
267
+ cache_read_tokens,
268
+ generated_tokens,
269
+ reasoning_tokens,
270
+ event_id=event_id,
271
+ )
272
+
273
+
274
+ def _parse_codex_event(
275
+ all_document_fields: dict[str, object],
276
+ fallback_model: str,
277
+ rates_by_model: dict[str, ModelRates],
278
+ ) -> UsageEvent | None:
279
+ if all_document_fields.get("type") != "turn.completed":
280
+ return None
281
+ model = _safe_label(all_document_fields.get("model"))
282
+ if model == "unknown":
283
+ model = fallback_model
284
+ usage_fields = _usage_object(all_document_fields.get("usage"))
285
+ event_id = _safe_label(all_document_fields.get("turn_id"))
286
+ usage_event = _parse_codex_usage(
287
+ usage_fields, model, None if event_id == "unknown" else event_id
288
+ )
289
+ return UsageEvent(
290
+ model=usage_event.model,
291
+ uncached_input_tokens=usage_event.uncached_input_tokens,
292
+ cache_write_tokens=usage_event.cache_write_tokens,
293
+ cache_read_tokens=usage_event.cache_read_tokens,
294
+ generated_tokens=usage_event.generated_tokens,
295
+ reasoning_generated_tokens=usage_event.reasoning_generated_tokens,
296
+ cost_usd=_calculate_codex_cost(usage_event, rates_by_model.get(model)),
297
+ event_id=usage_event.event_id,
298
+ )
299
+
300
+
301
+ def _unwrap_otel_attribute(candidate: object) -> object:
302
+ if not isinstance(candidate, dict):
303
+ return candidate
304
+ for each_key in ("stringValue", "intValue", "doubleValue"):
305
+ if each_key in candidate:
306
+ return candidate[each_key]
307
+ return candidate
308
+
309
+
310
+ def _otel_attributes(all_document_fields: dict[str, object]) -> dict[str, object]:
311
+ raw_attributes = all_document_fields.get("attributes", {})
312
+ if isinstance(raw_attributes, dict):
313
+ return {
314
+ each_key: _unwrap_otel_attribute(each_entry)
315
+ for each_key, each_entry in raw_attributes.items()
316
+ }
317
+ all_attributes = _require_list(raw_attributes, "attributes")
318
+ attributes: dict[str, object] = {}
319
+ for each_attribute in all_attributes:
320
+ attribute = _require_object(each_attribute, "attribute")
321
+ attribute_key = attribute.get("key")
322
+ if isinstance(attribute_key, str):
323
+ attributes[attribute_key] = _unwrap_otel_attribute(attribute.get("value"))
324
+ return attributes
325
+
326
+
327
+ def _parse_otel_event(all_document_fields: dict[str, object]) -> UsageEvent | None:
328
+ attributes = (
329
+ _otel_attributes(all_document_fields)
330
+ if "attributes" in all_document_fields
331
+ else all_document_fields
332
+ )
333
+ event_name = _unwrap_otel_attribute(
334
+ all_document_fields.get("body", all_document_fields.get("event.name"))
335
+ )
336
+ if (
337
+ event_name not in ("claude_code.api_request", "api_request")
338
+ and attributes.get("event.name") != "api_request"
339
+ ):
340
+ return None
341
+ cost = _read_money(attributes, "cost_usd")
342
+ if cost is None:
343
+ cost_micros = _read_count(attributes, "cost_usd_micros")
344
+ cost = (
345
+ None if cost_micros is None else Decimal(cost_micros) / Decimal(1_000_000)
346
+ )
347
+ event_id = _safe_label(attributes.get("request_id"))
348
+ return UsageEvent(
349
+ model=_safe_label(attributes.get("model")),
350
+ uncached_input_tokens=_read_count(attributes, "input_tokens"),
351
+ cache_write_tokens=_read_count(attributes, "cache_creation_tokens"),
352
+ cache_read_tokens=_read_count(attributes, "cache_read_tokens"),
353
+ generated_tokens=_read_count(attributes, "output_tokens"),
354
+ cost_usd=cost,
355
+ event_id=None if event_id == "unknown" else event_id,
356
+ )
357
+
358
+
359
+ def _require_one_otel_session(all_records: list[dict[str, object]]) -> None:
360
+ all_session_ids: set[str] = set()
361
+ for each_record in all_records:
362
+ attributes = (
363
+ _otel_attributes(each_record)
364
+ if "attributes" in each_record
365
+ else each_record
366
+ )
367
+ all_session_ids.add(_safe_label(attributes.get("session.id")))
368
+ if len(all_session_ids) != 1 or "unknown" in all_session_ids:
369
+ raise ValueError("OTel input needs one session ID across all log records")
370
+
371
+
372
+ def _cost_basis(source_format: str, total_cost: Decimal | None) -> str:
373
+ if total_cost is None:
374
+ return "unknown"
375
+ if source_format == "codex-json":
376
+ return "caller_rates"
377
+ return "provider_estimate"
378
+
379
+
380
+ def _require_one_codex_thread(all_documents: list[dict[str, object]]) -> None:
381
+ all_thread_starts = [
382
+ _safe_label(each_document.get("thread_id"))
383
+ for each_document in all_documents
384
+ if each_document.get("type") == "thread.started"
385
+ ]
386
+ if len(all_thread_starts) != 1 or all_thread_starts[0] == "unknown":
387
+ raise ValueError("Codex input needs one thread.started event")