agentfluent 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 (39) hide show
  1. agentfluent/__init__.py +8 -0
  2. agentfluent/agents/__init__.py +0 -0
  3. agentfluent/agents/extractor.py +78 -0
  4. agentfluent/agents/models.py +71 -0
  5. agentfluent/analytics/__init__.py +0 -0
  6. agentfluent/analytics/agent_metrics.py +127 -0
  7. agentfluent/analytics/pipeline.py +278 -0
  8. agentfluent/analytics/pricing.py +96 -0
  9. agentfluent/analytics/tokens.py +137 -0
  10. agentfluent/analytics/tools.py +93 -0
  11. agentfluent/cli/__init__.py +0 -0
  12. agentfluent/cli/commands/__init__.py +0 -0
  13. agentfluent/cli/commands/analyze.py +162 -0
  14. agentfluent/cli/commands/config_check.py +110 -0
  15. agentfluent/cli/commands/list_cmd.py +165 -0
  16. agentfluent/cli/exit_codes.py +18 -0
  17. agentfluent/cli/formatters/__init__.py +0 -0
  18. agentfluent/cli/formatters/helpers.py +59 -0
  19. agentfluent/cli/formatters/json_output.py +60 -0
  20. agentfluent/cli/formatters/table.py +358 -0
  21. agentfluent/cli/main.py +63 -0
  22. agentfluent/config/__init__.py +31 -0
  23. agentfluent/config/models.py +118 -0
  24. agentfluent/config/scanner.py +146 -0
  25. agentfluent/config/scoring.py +299 -0
  26. agentfluent/core/__init__.py +0 -0
  27. agentfluent/core/discovery.py +158 -0
  28. agentfluent/core/parser.py +251 -0
  29. agentfluent/core/session.py +133 -0
  30. agentfluent/diagnostics/__init__.py +51 -0
  31. agentfluent/diagnostics/correlator.py +248 -0
  32. agentfluent/diagnostics/models.py +75 -0
  33. agentfluent/diagnostics/signals.py +162 -0
  34. agentfluent/py.typed +0 -0
  35. agentfluent-0.1.0.dist-info/METADATA +52 -0
  36. agentfluent-0.1.0.dist-info/RECORD +39 -0
  37. agentfluent-0.1.0.dist-info/WHEEL +4 -0
  38. agentfluent-0.1.0.dist-info/entry_points.txt +3 -0
  39. agentfluent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,8 @@
1
+ """AgentFluent: Local-first agent analytics with prompt diagnostics."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("agentfluent")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
File without changes
@@ -0,0 +1,78 @@
1
+ """Extract agent invocations from parsed session messages.
2
+
3
+ Identifies Agent tool_use blocks in assistant messages and matches them
4
+ to their corresponding tool_result blocks to build AgentInvocation objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agentfluent.agents.models import AgentInvocation, is_builtin_agent
10
+ from agentfluent.core.session import SessionMessage
11
+
12
+
13
+ def extract_agent_invocations(messages: list[SessionMessage]) -> list[AgentInvocation]:
14
+ """Extract agent invocations from a list of parsed session messages.
15
+
16
+ Scans for assistant messages containing Agent tool_use blocks (name == "Agent"),
17
+ then matches each to its corresponding tool_result by tool_use_id.
18
+
19
+ Args:
20
+ messages: Parsed session messages from the JSONL parser.
21
+
22
+ Returns:
23
+ List of AgentInvocation objects in session order.
24
+ """
25
+ # Build a lookup of tool_result messages by tool_use_id
26
+ tool_results: dict[str, SessionMessage] = {}
27
+ for msg in messages:
28
+ if msg.type == "tool_result" and msg.tool_use_id:
29
+ tool_results[msg.tool_use_id] = msg
30
+
31
+ invocations: list[AgentInvocation] = []
32
+
33
+ for msg in messages:
34
+ if msg.type != "assistant":
35
+ continue
36
+
37
+ for tool_use in msg.tool_use_blocks:
38
+ if tool_use.name != "Agent":
39
+ continue
40
+
41
+ agent_type = tool_use.input.get("subagent_type", "unknown")
42
+ description = tool_use.input.get("description", "")
43
+ prompt = tool_use.input.get("prompt", "")
44
+
45
+ # Match to tool_result
46
+ result = tool_results.get(tool_use.id)
47
+
48
+ total_tokens = None
49
+ tool_uses_count = None
50
+ duration_ms = None
51
+ agent_id = None
52
+ output_text = ""
53
+
54
+ if result is not None:
55
+ output_text = result.text
56
+
57
+ if result.metadata is not None:
58
+ total_tokens = result.metadata.total_tokens
59
+ tool_uses_count = result.metadata.tool_uses
60
+ duration_ms = result.metadata.duration_ms
61
+ agent_id = result.metadata.agent_id
62
+
63
+ invocations.append(
64
+ AgentInvocation(
65
+ agent_type=agent_type,
66
+ is_builtin=is_builtin_agent(agent_type),
67
+ description=description,
68
+ prompt=prompt,
69
+ tool_use_id=tool_use.id,
70
+ total_tokens=total_tokens,
71
+ tool_uses=tool_uses_count,
72
+ duration_ms=duration_ms,
73
+ agent_id=agent_id,
74
+ output_text=output_text,
75
+ )
76
+ )
77
+
78
+ return invocations
@@ -0,0 +1,71 @@
1
+ """Data models for agent invocations extracted from session data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ # Built-in agent types (case-insensitive matching).
8
+ # Update this set as Anthropic adds new built-in agents.
9
+ BUILTIN_AGENT_TYPES: frozenset[str] = frozenset(
10
+ {
11
+ "explore",
12
+ "plan",
13
+ "general-purpose",
14
+ "code-reviewer",
15
+ "statusline-setup",
16
+ "claude-code-guide",
17
+ }
18
+ )
19
+
20
+
21
+ def is_builtin_agent(agent_type: str) -> bool:
22
+ """Check if an agent type is a built-in Claude Code agent."""
23
+ return agent_type.lower() in BUILTIN_AGENT_TYPES
24
+
25
+
26
+ @dataclass
27
+ class AgentInvocation:
28
+ """A single agent invocation extracted from a session.
29
+
30
+ Combines data from the Agent tool_use block (in the assistant message)
31
+ with the corresponding tool_result (including metadata).
32
+ """
33
+
34
+ agent_type: str
35
+ """Agent type (e.g., 'pm', 'Explore', 'Plan')."""
36
+
37
+ is_builtin: bool
38
+ """Whether this is a built-in Claude Code agent."""
39
+
40
+ description: str
41
+ """The description passed to the Agent tool."""
42
+
43
+ prompt: str
44
+ """The delegation prompt sent to the agent."""
45
+
46
+ tool_use_id: str
47
+ """The tool_use ID linking this invocation to its tool_result."""
48
+
49
+ # From tool_result metadata (may be None if no metadata or agent was interrupted)
50
+ total_tokens: int | None = None
51
+ tool_uses: int | None = None
52
+ duration_ms: int | None = None
53
+ agent_id: str | None = None
54
+
55
+ # From tool_result content
56
+ output_text: str = ""
57
+ """The agent's final summary/output text."""
58
+
59
+ @property
60
+ def tokens_per_tool_use(self) -> float | None:
61
+ """Average tokens per tool call. None if data unavailable."""
62
+ if self.total_tokens is not None and self.tool_uses and self.tool_uses > 0:
63
+ return self.total_tokens / self.tool_uses
64
+ return None
65
+
66
+ @property
67
+ def duration_per_tool_use(self) -> float | None:
68
+ """Average duration (ms) per tool call. None if data unavailable."""
69
+ if self.duration_ms is not None and self.tool_uses and self.tool_uses > 0:
70
+ return self.duration_ms / self.tool_uses
71
+ return None
File without changes
@@ -0,0 +1,127 @@
1
+ """Per-agent execution metrics.
2
+
3
+ Computes invocation counts, token usage, and efficiency metrics
4
+ grouped by agent type. Reports token counts and percentages rather
5
+ than dollar costs, since tool_result metadata only provides aggregate
6
+ total_tokens without the per-category breakdown needed for accurate
7
+ cost estimation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ from agentfluent.agents.models import AgentInvocation
15
+
16
+
17
+ @dataclass
18
+ class AgentTypeMetrics:
19
+ """Execution metrics for a single agent type."""
20
+
21
+ agent_type: str
22
+ is_builtin: bool
23
+ invocation_count: int = 0
24
+ total_tokens: int = 0
25
+ total_tool_uses: int = 0
26
+ total_duration_ms: int = 0
27
+ avg_tokens_per_tool_use: float | None = None
28
+ avg_duration_per_tool_use: float | None = None
29
+
30
+ @property
31
+ def avg_tokens_per_invocation(self) -> float | None:
32
+ if self.invocation_count > 0 and self.total_tokens > 0:
33
+ return self.total_tokens / self.invocation_count
34
+ return None
35
+
36
+ @property
37
+ def avg_duration_per_invocation(self) -> float | None:
38
+ if self.invocation_count > 0 and self.total_duration_ms > 0:
39
+ return self.total_duration_ms / self.invocation_count
40
+ return None
41
+
42
+
43
+ @dataclass
44
+ class AgentMetrics:
45
+ """Aggregated agent execution metrics for a session."""
46
+
47
+ by_agent_type: dict[str, AgentTypeMetrics] = field(default_factory=dict)
48
+ """Per-agent-type metrics, keyed by agent_type."""
49
+
50
+ total_invocations: int = 0
51
+ total_agent_tokens: int = 0
52
+ total_agent_duration_ms: int = 0
53
+
54
+ builtin_invocations: int = 0
55
+ custom_invocations: int = 0
56
+
57
+ agent_token_percentage: float = 0.0
58
+ """Agent tokens as percentage of session total tokens (0-100).
59
+ Set by the caller who has session-level token data."""
60
+
61
+
62
+ def compute_agent_metrics(
63
+ invocations: list[AgentInvocation],
64
+ session_total_tokens: int = 0,
65
+ ) -> AgentMetrics:
66
+ """Compute per-agent-type execution metrics from extracted invocations.
67
+
68
+ Groups invocations by agent_type and computes counts, totals, and averages.
69
+ Invocations with missing metadata are counted but excluded from averages.
70
+
71
+ Args:
72
+ invocations: Extracted agent invocations from a session.
73
+ session_total_tokens: Total session tokens for computing agent percentage.
74
+
75
+ Returns:
76
+ AgentMetrics with per-type breakdowns and summary totals.
77
+ """
78
+ by_type: dict[str, AgentTypeMetrics] = {}
79
+
80
+ for inv in invocations:
81
+ key = inv.agent_type.lower()
82
+ metrics = by_type.get(key)
83
+ if metrics is None:
84
+ metrics = AgentTypeMetrics(agent_type=inv.agent_type, is_builtin=inv.is_builtin)
85
+ by_type[key] = metrics
86
+
87
+ metrics.invocation_count += 1
88
+
89
+ if inv.total_tokens is not None:
90
+ metrics.total_tokens += inv.total_tokens
91
+ if inv.tool_uses is not None:
92
+ metrics.total_tool_uses += inv.tool_uses
93
+ if inv.duration_ms is not None:
94
+ metrics.total_duration_ms += inv.duration_ms
95
+
96
+ # Compute averages
97
+ for metrics in by_type.values():
98
+ if metrics.total_tool_uses > 0:
99
+ if metrics.total_tokens > 0:
100
+ metrics.avg_tokens_per_tool_use = metrics.total_tokens / metrics.total_tool_uses
101
+ if metrics.total_duration_ms > 0:
102
+ metrics.avg_duration_per_tool_use = (
103
+ metrics.total_duration_ms / metrics.total_tool_uses
104
+ )
105
+
106
+ # Aggregate totals
107
+ total_invocations = sum(m.invocation_count for m in by_type.values())
108
+ total_agent_tokens = sum(m.total_tokens for m in by_type.values())
109
+ total_agent_duration = sum(m.total_duration_ms for m in by_type.values())
110
+ builtin_count = sum(m.invocation_count for m in by_type.values() if m.is_builtin)
111
+ custom_count = sum(m.invocation_count for m in by_type.values() if not m.is_builtin)
112
+
113
+ agent_token_pct = (
114
+ round(total_agent_tokens / session_total_tokens * 100, 1)
115
+ if session_total_tokens > 0 and total_agent_tokens > 0
116
+ else 0.0
117
+ )
118
+
119
+ return AgentMetrics(
120
+ by_agent_type=by_type,
121
+ total_invocations=total_invocations,
122
+ total_agent_tokens=total_agent_tokens,
123
+ total_agent_duration_ms=total_agent_duration,
124
+ builtin_invocations=builtin_count,
125
+ custom_invocations=custom_count,
126
+ agent_token_percentage=agent_token_pct,
127
+ )
@@ -0,0 +1,278 @@
1
+ """Analytics pipeline orchestration.
2
+
3
+ Connects parser, extractor, and analytics modules into a single
4
+ analysis pipeline. Reusable by the CLI, future webapp, and tests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from agentfluent.agents.extractor import extract_agent_invocations
14
+ from agentfluent.agents.models import AgentInvocation
15
+ from agentfluent.analytics.agent_metrics import (
16
+ AgentMetrics,
17
+ AgentTypeMetrics,
18
+ compute_agent_metrics,
19
+ )
20
+ from agentfluent.analytics.tokens import (
21
+ ModelTokenBreakdown,
22
+ TokenMetrics,
23
+ compute_token_metrics,
24
+ )
25
+ from agentfluent.analytics.tools import (
26
+ ConcentrationEntry,
27
+ ToolMetrics,
28
+ compute_tool_metrics,
29
+ )
30
+ from agentfluent.core.parser import parse_session
31
+ from agentfluent.core.session import SessionMessage
32
+ from agentfluent.diagnostics.models import DiagnosticsResult
33
+
34
+
35
+ class SessionAnalysis(BaseModel):
36
+ """Complete analysis results for a single session."""
37
+
38
+ session_path: Path
39
+ token_metrics: TokenMetrics
40
+ tool_metrics: ToolMetrics
41
+ agent_metrics: AgentMetrics
42
+ invocations: list[AgentInvocation] = Field(default_factory=list)
43
+ message_count: int = 0
44
+ user_message_count: int = 0
45
+ assistant_message_count: int = 0
46
+
47
+
48
+ class AnalysisResult(BaseModel):
49
+ """Aggregated analysis results across one or more sessions."""
50
+
51
+ sessions: list[SessionAnalysis] = Field(default_factory=list)
52
+ token_metrics: TokenMetrics = Field(default_factory=TokenMetrics)
53
+ tool_metrics: ToolMetrics = Field(default_factory=ToolMetrics)
54
+ agent_metrics: AgentMetrics = Field(default_factory=AgentMetrics)
55
+ session_count: int = 0
56
+ diagnostics: DiagnosticsResult | None = None
57
+
58
+
59
+ def analyze_session(
60
+ path: Path,
61
+ *,
62
+ agent_filter: str | None = None,
63
+ ) -> SessionAnalysis:
64
+ """Run the full analytics pipeline on a single session file.
65
+
66
+ Args:
67
+ path: Path to the .jsonl session file.
68
+ agent_filter: If set, only include this agent type in agent metrics.
69
+
70
+ Returns:
71
+ SessionAnalysis with token, tool, and agent metrics.
72
+ """
73
+ messages = parse_session(path)
74
+
75
+ token_metrics = compute_token_metrics(messages)
76
+ tool_metrics = compute_tool_metrics(messages)
77
+
78
+ invocations = extract_agent_invocations(messages)
79
+ if agent_filter:
80
+ invocations = [
81
+ inv for inv in invocations if inv.agent_type.lower() == agent_filter.lower()
82
+ ]
83
+
84
+ agent_metrics = compute_agent_metrics(
85
+ invocations, session_total_tokens=token_metrics.total_tokens
86
+ )
87
+
88
+ return SessionAnalysis(
89
+ session_path=path,
90
+ token_metrics=token_metrics,
91
+ tool_metrics=tool_metrics,
92
+ agent_metrics=agent_metrics,
93
+ invocations=invocations,
94
+ message_count=len(messages),
95
+ user_message_count=_count_type(messages, "user"),
96
+ assistant_message_count=_count_type(messages, "assistant"),
97
+ )
98
+
99
+
100
+ def analyze_sessions(
101
+ paths: list[Path],
102
+ *,
103
+ agent_filter: str | None = None,
104
+ ) -> AnalysisResult:
105
+ """Run analytics across multiple session files and aggregate results.
106
+
107
+ Calls analyze_session per file, then merges results mathematically
108
+ rather than re-processing raw messages.
109
+
110
+ Args:
111
+ paths: List of .jsonl session file paths.
112
+ agent_filter: If set, only include this agent type in agent metrics.
113
+
114
+ Returns:
115
+ AnalysisResult with per-session and aggregated metrics.
116
+ """
117
+ session_analyses = [analyze_session(p, agent_filter=agent_filter) for p in paths]
118
+
119
+ if not session_analyses:
120
+ return AnalysisResult()
121
+
122
+ agg_token = _merge_token_metrics([s.token_metrics for s in session_analyses])
123
+ agg_tool = _merge_tool_metrics([s.tool_metrics for s in session_analyses])
124
+ agg_agent = _merge_agent_metrics(
125
+ [s.agent_metrics for s in session_analyses],
126
+ session_total_tokens=agg_token.total_tokens,
127
+ )
128
+
129
+ return AnalysisResult(
130
+ sessions=session_analyses,
131
+ token_metrics=agg_token,
132
+ tool_metrics=agg_tool,
133
+ agent_metrics=agg_agent,
134
+ session_count=len(session_analyses),
135
+ )
136
+
137
+
138
+ def _merge_token_metrics(metrics_list: list[TokenMetrics]) -> TokenMetrics:
139
+ """Merge multiple TokenMetrics by summing per-model breakdowns."""
140
+ merged_models: dict[str, ModelTokenBreakdown] = {}
141
+ api_call_count = 0
142
+
143
+ for tm in metrics_list:
144
+ api_call_count += tm.api_call_count
145
+ for model_name, breakdown in tm.by_model.items():
146
+ existing = merged_models.get(model_name)
147
+ if existing is None:
148
+ merged_models[model_name] = ModelTokenBreakdown(
149
+ model=model_name,
150
+ input_tokens=breakdown.input_tokens,
151
+ output_tokens=breakdown.output_tokens,
152
+ cache_creation_input_tokens=breakdown.cache_creation_input_tokens,
153
+ cache_read_input_tokens=breakdown.cache_read_input_tokens,
154
+ cost=breakdown.cost,
155
+ )
156
+ else:
157
+ existing.input_tokens += breakdown.input_tokens
158
+ existing.output_tokens += breakdown.output_tokens
159
+ existing.cache_creation_input_tokens += breakdown.cache_creation_input_tokens
160
+ existing.cache_read_input_tokens += breakdown.cache_read_input_tokens
161
+ existing.cost += breakdown.cost
162
+
163
+ total_input = sum(b.input_tokens for b in merged_models.values())
164
+ total_output = sum(b.output_tokens for b in merged_models.values())
165
+ total_cache_creation = sum(b.cache_creation_input_tokens for b in merged_models.values())
166
+ total_cache_read = sum(b.cache_read_input_tokens for b in merged_models.values())
167
+ total_cost = sum(b.cost for b in merged_models.values())
168
+
169
+ cache_denom = total_cache_read + total_input + total_cache_creation
170
+ cache_efficiency = (
171
+ round(total_cache_read / cache_denom * 100, 1) if cache_denom > 0 else 0.0
172
+ )
173
+
174
+ return TokenMetrics(
175
+ input_tokens=total_input,
176
+ output_tokens=total_output,
177
+ cache_creation_input_tokens=total_cache_creation,
178
+ cache_read_input_tokens=total_cache_read,
179
+ total_cost=total_cost,
180
+ cache_efficiency=cache_efficiency,
181
+ api_call_count=api_call_count,
182
+ by_model=merged_models,
183
+ )
184
+
185
+
186
+ def _merge_tool_metrics(metrics_list: list[ToolMetrics]) -> ToolMetrics:
187
+ """Merge multiple ToolMetrics by summing frequency dicts."""
188
+ merged_counts: dict[str, int] = {}
189
+ for tm in metrics_list:
190
+ for name, count in tm.tool_frequency.items():
191
+ merged_counts[name] = merged_counts.get(name, 0) + count
192
+
193
+ if not merged_counts:
194
+ return ToolMetrics()
195
+
196
+ sorted_tools = sorted(merged_counts.items(), key=lambda x: (-x[1], x[0]))
197
+ sorted_freq = dict(sorted_tools)
198
+ total = sum(merged_counts.values())
199
+
200
+ concentration: list[ConcentrationEntry] = []
201
+ cumulative = 0
202
+ for i, (_name, count) in enumerate(sorted_tools, start=1):
203
+ cumulative += count
204
+ concentration.append(
205
+ ConcentrationEntry(
206
+ top_n=i,
207
+ call_count=cumulative,
208
+ percentage=round(cumulative / total * 100, 1),
209
+ )
210
+ )
211
+
212
+ return ToolMetrics(
213
+ tool_frequency=sorted_freq,
214
+ unique_tool_count=len(merged_counts),
215
+ total_tool_calls=total,
216
+ concentration=concentration,
217
+ )
218
+
219
+
220
+ def _merge_agent_metrics(
221
+ metrics_list: list[AgentMetrics],
222
+ session_total_tokens: int = 0,
223
+ ) -> AgentMetrics:
224
+ """Merge multiple AgentMetrics by summing per-type breakdowns."""
225
+ merged: dict[str, AgentTypeMetrics] = {}
226
+
227
+ for am in metrics_list:
228
+ for key, m in am.by_agent_type.items():
229
+ existing = merged.get(key)
230
+ if existing is None:
231
+ merged[key] = AgentTypeMetrics(
232
+ agent_type=m.agent_type,
233
+ is_builtin=m.is_builtin,
234
+ invocation_count=m.invocation_count,
235
+ total_tokens=m.total_tokens,
236
+ total_tool_uses=m.total_tool_uses,
237
+ total_duration_ms=m.total_duration_ms,
238
+ )
239
+ else:
240
+ existing.invocation_count += m.invocation_count
241
+ existing.total_tokens += m.total_tokens
242
+ existing.total_tool_uses += m.total_tool_uses
243
+ existing.total_duration_ms += m.total_duration_ms
244
+
245
+ # Recompute averages on merged data
246
+ for m in merged.values():
247
+ if m.total_tool_uses > 0:
248
+ if m.total_tokens > 0:
249
+ m.avg_tokens_per_tool_use = m.total_tokens / m.total_tool_uses
250
+ if m.total_duration_ms > 0:
251
+ m.avg_duration_per_tool_use = m.total_duration_ms / m.total_tool_uses
252
+
253
+ total_invocations = sum(m.invocation_count for m in merged.values())
254
+ total_agent_tokens = sum(m.total_tokens for m in merged.values())
255
+ total_agent_duration = sum(m.total_duration_ms for m in merged.values())
256
+ builtin_count = sum(m.invocation_count for m in merged.values() if m.is_builtin)
257
+ custom_count = sum(m.invocation_count for m in merged.values() if not m.is_builtin)
258
+
259
+ agent_token_pct = (
260
+ round(total_agent_tokens / session_total_tokens * 100, 1)
261
+ if session_total_tokens > 0 and total_agent_tokens > 0
262
+ else 0.0
263
+ )
264
+
265
+ return AgentMetrics(
266
+ by_agent_type=merged,
267
+ total_invocations=total_invocations,
268
+ total_agent_tokens=total_agent_tokens,
269
+ total_agent_duration_ms=total_agent_duration,
270
+ builtin_invocations=builtin_count,
271
+ custom_invocations=custom_count,
272
+ agent_token_percentage=agent_token_pct,
273
+ )
274
+
275
+
276
+ def _count_type(messages: list[SessionMessage], msg_type: str) -> int:
277
+ """Count messages of a given type."""
278
+ return sum(1 for m in messages if m.type == msg_type)
@@ -0,0 +1,96 @@
1
+ """Model pricing lookup for token cost estimation.
2
+
3
+ Maps Anthropic model names to per-token costs (USD per 1M tokens) for
4
+ input, output, cache_creation, and cache_read token categories.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelPricing:
17
+ """Per-token pricing for a single model (USD per 1M tokens)."""
18
+
19
+ input: float
20
+ output: float
21
+ cache_creation: float
22
+ cache_read: float
23
+
24
+
25
+ # Pricing data: USD per 1M tokens.
26
+ # Update this dict when Anthropic changes pricing.
27
+ _PRICING: dict[str, ModelPricing] = {
28
+ "claude-opus-4-6": ModelPricing(
29
+ input=15.0, output=75.0, cache_creation=18.75, cache_read=1.875,
30
+ ),
31
+ "claude-opus-4-5-20251101": ModelPricing(
32
+ input=15.0, output=75.0, cache_creation=18.75, cache_read=1.875,
33
+ ),
34
+ "claude-sonnet-4-6": ModelPricing(
35
+ input=3.0, output=15.0, cache_creation=3.75, cache_read=0.30,
36
+ ),
37
+ "claude-sonnet-4-5-20250929": ModelPricing(
38
+ input=3.0, output=15.0, cache_creation=3.75, cache_read=0.30,
39
+ ),
40
+ "claude-sonnet-4-20250514": ModelPricing(
41
+ input=3.0, output=15.0, cache_creation=3.75, cache_read=0.30,
42
+ ),
43
+ "claude-haiku-4-5-20251001": ModelPricing(
44
+ input=0.80, output=4.0, cache_creation=1.0, cache_read=0.08,
45
+ ),
46
+ }
47
+
48
+ # Aliases map short names and variant identifiers to canonical model names.
49
+ _ALIASES: dict[str, str] = {
50
+ "opus": "claude-opus-4-6",
51
+ "sonnet": "claude-sonnet-4-6",
52
+ "haiku": "claude-haiku-4-5-20251001",
53
+ "claude-opus-4-6[1m]": "claude-opus-4-6",
54
+ "claude-sonnet-4-6[1m]": "claude-sonnet-4-6",
55
+ }
56
+
57
+ DEFAULT_MODEL = "claude-sonnet-4-6"
58
+
59
+
60
+ def get_pricing(model: str) -> ModelPricing | None:
61
+ """Look up pricing for a model name.
62
+
63
+ Checks exact match first, then aliases. Returns None with a warning
64
+ if the model is unknown.
65
+ """
66
+ pricing = _PRICING.get(model)
67
+ if pricing:
68
+ return pricing
69
+
70
+ canonical = _ALIASES.get(model)
71
+ if canonical:
72
+ return _PRICING.get(canonical)
73
+
74
+ logger.warning("Unknown model '%s' -- no pricing available", model)
75
+ return None
76
+
77
+
78
+ def compute_cost(
79
+ pricing: ModelPricing,
80
+ input_tokens: int,
81
+ output_tokens: int,
82
+ cache_creation_input_tokens: int = 0,
83
+ cache_read_input_tokens: int = 0,
84
+ ) -> float:
85
+ """Compute dollar cost in USD from token counts and pricing rates."""
86
+ return (
87
+ input_tokens * pricing.input
88
+ + output_tokens * pricing.output
89
+ + cache_creation_input_tokens * pricing.cache_creation
90
+ + cache_read_input_tokens * pricing.cache_read
91
+ ) / 1_000_000
92
+
93
+
94
+ def get_known_models() -> list[str]:
95
+ """Return sorted list of all known model names (excluding aliases)."""
96
+ return sorted(_PRICING.keys())