agentprof 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 (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprof/model.py ADDED
@@ -0,0 +1,341 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Agent-neutral data model shared by all adapters, the analysis layer and the server."""
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from typing import Literal
9
+
10
+
11
+ class Provenance(StrEnum):
12
+ """How trustworthy a metric's value is."""
13
+
14
+ EXACT = "exact"
15
+ ESTIMATED = "estimated"
16
+ NOT_AVAILABLE = "n/a"
17
+
18
+
19
+ _PROVENANCE_RANK = {Provenance.EXACT: 0, Provenance.ESTIMATED: 1, Provenance.NOT_AVAILABLE: 2}
20
+
21
+
22
+ def worst_provenance(*provenances: Provenance) -> Provenance:
23
+ """Return the least trustworthy of `provenances`; `EXACT` if none are given."""
24
+ return max(provenances, key=_PROVENANCE_RANK.__getitem__, default=Provenance.EXACT)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Metric:
29
+ """A numeric measurement with a provenance tag. `value` is `None` exactly when not available."""
30
+
31
+ value: float | int | None
32
+ provenance: Provenance
33
+
34
+ @staticmethod
35
+ def not_available() -> "Metric":
36
+ return Metric(value=None, provenance=Provenance.NOT_AVAILABLE)
37
+
38
+ @staticmethod
39
+ def exact(value: float | int) -> "Metric":
40
+ return Metric(value=value, provenance=Provenance.EXACT)
41
+
42
+ @staticmethod
43
+ def estimated(value: float | int) -> "Metric":
44
+ return Metric(value=value, provenance=Provenance.ESTIMATED)
45
+
46
+ def number(self) -> float:
47
+ """Return the value as `float`; raises `ValueError` if the metric is not available."""
48
+ if self.value is None:
49
+ raise ValueError("metric is not available")
50
+ return float(self.value)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CostMetric:
55
+ """A cost in the agent's native unit (e.g. `credits`, `USD`); `usd_per_unit` converts other units to USD."""
56
+
57
+ value: float | None
58
+ unit: str | None
59
+ provenance: Provenance
60
+ usd_per_unit: float | None = None
61
+
62
+ @property
63
+ def usd(self) -> float | None:
64
+ """The cost in USD, or `None` if it is not available or its unit has no known conversion."""
65
+ if self.value is None:
66
+ return None
67
+ if self.unit == "USD":
68
+ return self.value
69
+ return self.value * self.usd_per_unit if self.usd_per_unit is not None else None
70
+
71
+ @staticmethod
72
+ def not_available() -> "CostMetric":
73
+ return CostMetric(value=None, unit=None, provenance=Provenance.NOT_AVAILABLE)
74
+
75
+
76
+ def sum_costs(costs: list[CostMetric]) -> CostMetric:
77
+ """Add costs of one unit; `estimated` if some are missing, `n/a` if none is available or units differ."""
78
+ available = [cost for cost in costs if cost.value is not None]
79
+ if not available:
80
+ return CostMetric.not_available()
81
+ units = {cost.unit for cost in available}
82
+ if len(units) != 1:
83
+ return CostMetric.not_available()
84
+ provenance = worst_provenance(*(cost.provenance for cost in available))
85
+ if len(available) < len(costs):
86
+ provenance = worst_provenance(provenance, Provenance.ESTIMATED)
87
+ total = sum(value for cost in available if (value := cost.value) is not None)
88
+ rates = {cost.usd_per_unit for cost in available}
89
+ return CostMetric(
90
+ value=total,
91
+ unit=units.pop(),
92
+ provenance=provenance,
93
+ usd_per_unit=rates.pop() if len(rates) == 1 else None,
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class Tokens:
99
+ """Token counts; the four counts are disjoint (`input` excludes cached tokens)."""
100
+
101
+ input: Metric = field(default_factory=Metric.not_available)
102
+ output: Metric = field(default_factory=Metric.not_available)
103
+ cache_read: Metric = field(default_factory=Metric.not_available)
104
+ cache_write: Metric = field(default_factory=Metric.not_available)
105
+ cache_write_5m: Metric = field(default_factory=Metric.not_available)
106
+ cache_write_1h: Metric = field(default_factory=Metric.not_available)
107
+
108
+
109
+ @dataclass
110
+ class EventCallLink:
111
+ """A link between an execution event and a model request."""
112
+
113
+ source_request_id: str
114
+ relation: Literal["requested_by", "consumed_by", "next_observed_call"]
115
+ evidence: Literal["recorded", "observed_order"]
116
+ owner_id: str | None = None
117
+
118
+
119
+ @dataclass
120
+ class ExecutionEvent:
121
+ """An observed occurrence in an agent's execution history.
122
+
123
+ `start` is the occurrence time, such as result receipt for a tool result.
124
+ `execution_start` and `execution_end` retain the original invocation snapshot separately.
125
+ """
126
+
127
+ kind: Literal[
128
+ "user_input",
129
+ "tool_start",
130
+ "tool_result",
131
+ "delegation",
132
+ "child_completion",
133
+ "completion",
134
+ "message",
135
+ "resume",
136
+ "compaction",
137
+ ]
138
+ event_id: str | None = None
139
+ subject_node_id: str | None = None
140
+ start: Metric = field(default_factory=Metric.not_available)
141
+ source_order: int | None = None
142
+ source_stream_id: str | None = None
143
+ success: bool | None = None
144
+ links: list[EventCallLink] = field(default_factory=list)
145
+ execution_start: Metric = field(default_factory=Metric.not_available)
146
+ execution_end: Metric = field(default_factory=Metric.not_available)
147
+
148
+
149
+ def context_size(tokens: Tokens) -> Metric:
150
+ """Tokens sent to the model in one call: input, cache reads and cache writes; `n/a` if none of them is known."""
151
+ parts = [metric for metric in (tokens.input, tokens.cache_read, tokens.cache_write) if metric.value is not None]
152
+ if not parts:
153
+ return Metric.not_available()
154
+ return Metric(
155
+ value=sum(metric.number() for metric in parts),
156
+ provenance=worst_provenance(*(metric.provenance for metric in parts)),
157
+ )
158
+
159
+
160
+ @dataclass
161
+ class LlmCall:
162
+ """One request to a language model.
163
+
164
+ `in_context` is false for side requests that do not belong to the agent's conversation (e.g. a background
165
+ summary or a small helper model), so they do not count towards the agent's context size.
166
+ """
167
+
168
+ start: Metric
169
+ duration: Metric = field(default_factory=Metric.not_available)
170
+ model: str | None = None
171
+ tokens: Tokens = field(default_factory=Tokens)
172
+ cost: CostMetric = field(default_factory=CostMetric.not_available)
173
+ in_context: bool = True
174
+ call_id: str | None = None
175
+ cost_parts: dict[str, CostMetric] = field(default_factory=dict)
176
+ price_prefix: str | None = None
177
+ gap: Metric = field(default_factory=Metric.not_available)
178
+ gap_basis: str | None = None
179
+ preceding_events: list["CallEvent"] = field(default_factory=list)
180
+ cold_reason: str = "unknown"
181
+ cold_rewrite: bool = False
182
+ source_request_id: str | None = None
183
+ source_order: int | None = None
184
+ source_stream_id: str | None = None
185
+ timing_basis: Literal["request_start", "assistant_message", "usage_report", "turn_start", "unknown"] = "unknown"
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class CallEvent:
190
+ """A short reference to an event between two calls by the same agent."""
191
+
192
+ kind: str
193
+ event_id: str
194
+ start: Metric
195
+ end: Metric = field(default_factory=Metric.not_available)
196
+ duration: Metric = field(default_factory=Metric.not_available)
197
+
198
+
199
+ class ToolCategory(StrEnum):
200
+ """Agent-neutral tool classes; heuristics only ever look at these."""
201
+
202
+ READ = "read"
203
+ EDIT = "edit"
204
+ SEARCH = "search"
205
+ SHELL = "shell"
206
+ SHELL_POLL = "shell_poll"
207
+ WEB = "web"
208
+ SUBAGENT = "subagent"
209
+ OTHER = "other"
210
+
211
+
212
+ @dataclass
213
+ class ToolInfo:
214
+ """A tool call's native identity, neutral category and normalised arguments.
215
+
216
+ `writes_file` is true for tools that write a whole file (as opposed to editing part of one).
217
+ `paths` lists every file the call touches, `path` is the first of them; it defaults to `path` alone.
218
+ """
219
+
220
+ native_id: str
221
+ category: ToolCategory
222
+ path: str | None = None
223
+ line_range: tuple[int, int] | None = None
224
+ command: str | None = None
225
+ writes_file: bool = False
226
+ arguments: dict[str, object] = field(default_factory=dict)
227
+ paths: tuple[str, ...] = ()
228
+ target_agent_id: str | None = None
229
+ is_resume: bool = False
230
+ linked_agent_node_id: str | None = None
231
+
232
+ def __post_init__(self) -> None:
233
+ if not self.paths and self.path is not None:
234
+ self.paths = (self.path,)
235
+
236
+
237
+ class NodeKind(StrEnum):
238
+ """The role of a node in the call tree."""
239
+
240
+ SESSION = "session"
241
+ TURN = "turn"
242
+ AGENT = "agent"
243
+ TOOL = "tool"
244
+
245
+
246
+ @dataclass
247
+ class Finding:
248
+ """A waste heuristic hit, attached to a node."""
249
+
250
+ heuristic_id: str
251
+ node_id: str
252
+ severity: str
253
+ message: str
254
+ evidence: dict[str, object] = field(default_factory=dict)
255
+ estimated_avoidable_cost: CostMetric = field(default_factory=CostMetric.not_available)
256
+
257
+
258
+ @dataclass
259
+ class Node:
260
+ """One entry in the call tree.
261
+
262
+ `tokens` are the node's own LLM calls' tokens; `tokens_total` includes its subtree.
263
+ `context_peak` is the largest context size of the node's own LLM calls; `compactions` are the times its own
264
+ context was compacted.
265
+ On the session node they cover the main agent's whole context, i.e. all turns.
266
+ """
267
+
268
+ node_id: str
269
+ kind: NodeKind
270
+ topic: str
271
+ agent_uuid: str | None = None
272
+ resume_times: list[Metric] = field(default_factory=list)
273
+ model: str | None = None
274
+ prompt: str = ""
275
+ result: str = ""
276
+ success: bool | None = None
277
+ tool: ToolInfo | None = None
278
+ children: list["Node"] = field(default_factory=list)
279
+ start: Metric = field(default_factory=Metric.not_available)
280
+ end: Metric = field(default_factory=Metric.not_available)
281
+ duration: Metric = field(default_factory=Metric.not_available)
282
+ user_wait: Metric = field(default_factory=Metric.not_available)
283
+ llm_calls: list[LlmCall] = field(default_factory=list)
284
+ llm_call_count: Metric = field(default_factory=Metric.not_available)
285
+ tool_call_count: Metric = field(default_factory=Metric.not_available)
286
+ tokens: Tokens = field(default_factory=Tokens)
287
+ tokens_total: Tokens = field(default_factory=Tokens)
288
+ cost_total: CostMetric = field(default_factory=CostMetric.not_available)
289
+ cost_own: CostMetric = field(default_factory=CostMetric.not_available)
290
+ findings: list[Finding] = field(default_factory=list)
291
+ context_peak: Metric = field(default_factory=Metric.not_available)
292
+ compactions: list[Metric] = field(default_factory=list)
293
+ execution_events: list[ExecutionEvent] = field(default_factory=list)
294
+
295
+
296
+ def iter_nodes(root: Node) -> Iterator[Node]:
297
+ """Yield `root` and all its descendants, depth first."""
298
+ yield root
299
+ for child in root.children:
300
+ yield from iter_nodes(child)
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class MalformedLineDetail:
305
+ """Location and redacted syntax of one skipped source line."""
306
+
307
+ source_path: str
308
+ line_number: int
309
+ excerpt: str
310
+ error_category: str
311
+
312
+
313
+ @dataclass
314
+ class Diagnostics:
315
+ """Problems found while parsing a session."""
316
+
317
+ malformed_lines: int = 0
318
+ malformed_line_details: list[MalformedLineDetail] = field(default_factory=list)
319
+ unknown_tool_ids: dict[str, int] = field(default_factory=dict)
320
+ warnings: list[str] = field(default_factory=list)
321
+
322
+
323
+ @dataclass
324
+ class Session:
325
+ """A fully parsed session. `id` is `<adapter>:<native-id>`."""
326
+
327
+ id: str
328
+ agent: str
329
+ title: str
330
+ workspace: str | None
331
+ root: Node
332
+ sources: list[str] = field(default_factory=list)
333
+ diagnostics: Diagnostics = field(default_factory=Diagnostics)
334
+
335
+ @property
336
+ def start(self) -> Metric:
337
+ return self.root.start
338
+
339
+ @property
340
+ def end(self) -> Metric:
341
+ return self.root.end
agentprof/pricing.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "source": "Anthropic API list prices, 2026-06-24",
3
+ "date": "2026-06-24",
4
+ "unit": "USD per million tokens",
5
+ "usd_per_credit": 0.01,
6
+ "models": {
7
+ "claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25, "cache_write_5m": 12.5, "cache_write_1h": 20.0},
8
+ "claude-fable-5": {"input": 10.0, "output": 50.0, "cache_read": 1.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0},
9
+ "claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_read": 0.2, "cache_write_5m": 5.0, "cache_write_1h": 8.0},
10
+ "claude-opus-5": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write_5m": 6.25, "cache_write_1h": 10.0},
11
+ "claude-opus-4-8": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write_5m": 6.25, "cache_write_1h": 10.0},
12
+ "claude-opus-4-7": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write_5m": 6.25, "cache_write_1h": 10.0},
13
+ "claude-opus-4-6": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write_5m": 6.25, "cache_write_1h": 10.0},
14
+ "claude-sonnet-5": {"input": 2.0, "output": 10.0, "cache_read": 0.2, "cache_write_5m": 2.5, "cache_write_1h": 4.0},
15
+ "claude-sonnet-4-6": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write_5m": 3.75, "cache_write_1h": 6.0},
16
+ "claude-haiku-4-5": {"input": 1.0, "output": 5.0, "cache_read": 0.1, "cache_write_5m": 1.25, "cache_write_1h": 2.0},
17
+ "gpt-6-astra": {"input": 10.0, "output": 50.0, "cache_read": 1.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0},
18
+ "gpt-6-sol": {"input": 2.0, "output": 10.0, "cache_read": 0.2, "cache_write_5m": 2.5, "cache_write_1h": 4.0},
19
+ "gpt-6-luna": {"input": 0.1, "output": 0.5, "cache_read": 0.01, "cache_write_5m": 0.125, "cache_write_1h": 0.2},
20
+ "gpt-5.6-terra": {"input": 2.0, "output": 12.0, "cache_read": 0.2, "cache_write_5m": 2.5, "cache_write_1h": 4.0},
21
+ "<synthetic>": {"input": 0.0, "output": 0.0, "cache_read": 0.0, "cache_write_5m": 0.0, "cache_write_1h": 0.0}
22
+ }
23
+ }
agentprof/pricing.py ADDED
@@ -0,0 +1,120 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Estimate the USD cost of LLM calls from a table of per-model token prices."""
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from importlib import resources
8
+ from pathlib import Path
9
+
10
+ from agentprof.model import CostMetric, Provenance
11
+
12
+ _USD = "USD"
13
+ _TOKENS_PER_PRICE_UNIT = 1_000_000
14
+ _DEFAULT_USD_PER_CREDIT = 0.01
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ModelPrice:
19
+ """USD per million tokens."""
20
+
21
+ input: float
22
+ output: float
23
+ cache_read: float
24
+ cache_write_5m: float
25
+ cache_write_1h: float
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Usage:
30
+ """Disjoint token counts of one LLM call."""
31
+
32
+ input: int
33
+ output: int
34
+ cache_read: int
35
+ cache_write_5m: int
36
+ cache_write_1h: int
37
+
38
+
39
+ class PriceTable:
40
+ """Model id prefix -> price (the longest matching prefix wins), plus the USD value of one Copilot credit."""
41
+
42
+ def __init__(
43
+ self,
44
+ prices: dict[str, ModelPrice],
45
+ usd_per_credit: float = _DEFAULT_USD_PER_CREDIT,
46
+ source: str | None = None,
47
+ date: str | None = None,
48
+ ) -> None:
49
+ self._prices = prices
50
+ self.usd_per_credit = usd_per_credit
51
+ self.source = source
52
+ self.date = date
53
+
54
+ @property
55
+ def models(self) -> dict[str, ModelPrice]:
56
+ """The effective model-prefix prices."""
57
+ return self._prices.copy()
58
+
59
+ @staticmethod
60
+ def load(path: Path | None = None) -> "PriceTable":
61
+ """Load `path`, or the bundled `pricing.json` if `path` is `None`."""
62
+ if path is None:
63
+ text = resources.files("agentprof").joinpath("pricing.json").read_text(encoding="utf-8")
64
+ else:
65
+ text = path.read_text(encoding="utf-8")
66
+ data = json.loads(text)
67
+ prices = {prefix: ModelPrice(**values) for prefix, values in data["models"].items()}
68
+ return PriceTable(
69
+ prices,
70
+ usd_per_credit=float(data.get("usd_per_credit", _DEFAULT_USD_PER_CREDIT)),
71
+ source=data.get("source"),
72
+ date=data.get("date"),
73
+ )
74
+
75
+ def matching_prefix(self, model: str | None) -> str | None:
76
+ """Return the longest price-table prefix matching a full model ID."""
77
+ if model is None:
78
+ return None
79
+ matches = (prefix for prefix in self._prices if model.startswith(prefix))
80
+ return max(matches, key=len, default=None)
81
+
82
+ def price_for(self, model: str | None) -> ModelPrice | None:
83
+ prefix = self.matching_prefix(model)
84
+ return self._prices[prefix] if prefix is not None else None
85
+
86
+ def cost_parts(self, model: str | None, usage: Usage) -> dict[str, CostMetric]:
87
+ """Estimated USD cost for each disjoint token kind, or unavailable when unpriced."""
88
+ price = self.price_for(model)
89
+ if price is None:
90
+ return {
91
+ kind: CostMetric.not_available()
92
+ for kind in ("input", "output", "cache_read", "cache_write_5m", "cache_write_1h")
93
+ }
94
+ return {
95
+ "input": CostMetric(usage.input * price.input / _TOKENS_PER_PRICE_UNIT, _USD, Provenance.ESTIMATED),
96
+ "output": CostMetric(usage.output * price.output / _TOKENS_PER_PRICE_UNIT, _USD, Provenance.ESTIMATED),
97
+ "cache_read": CostMetric(
98
+ usage.cache_read * price.cache_read / _TOKENS_PER_PRICE_UNIT, _USD, Provenance.ESTIMATED
99
+ ),
100
+ "cache_write_5m": CostMetric(
101
+ usage.cache_write_5m * price.cache_write_5m / _TOKENS_PER_PRICE_UNIT, _USD, Provenance.ESTIMATED
102
+ ),
103
+ "cache_write_1h": CostMetric(
104
+ usage.cache_write_1h * price.cache_write_1h / _TOKENS_PER_PRICE_UNIT, _USD, Provenance.ESTIMATED
105
+ ),
106
+ }
107
+
108
+ def cost(self, model: str | None, usage: Usage) -> CostMetric:
109
+ """Estimated USD cost of one call, or `n/a` if the model has no price."""
110
+ price = self.price_for(model)
111
+ if price is None:
112
+ return CostMetric.not_available()
113
+ value = (
114
+ usage.input * price.input
115
+ + usage.output * price.output
116
+ + usage.cache_read * price.cache_read
117
+ + usage.cache_write_5m * price.cache_write_5m
118
+ + usage.cache_write_1h * price.cache_write_1h
119
+ ) / _TOKENS_PER_PRICE_UNIT
120
+ return CostMetric(value=value, unit=_USD, provenance=Provenance.ESTIMATED)