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.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- 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)
|