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.
- agentfluent/__init__.py +8 -0
- agentfluent/agents/__init__.py +0 -0
- agentfluent/agents/extractor.py +78 -0
- agentfluent/agents/models.py +71 -0
- agentfluent/analytics/__init__.py +0 -0
- agentfluent/analytics/agent_metrics.py +127 -0
- agentfluent/analytics/pipeline.py +278 -0
- agentfluent/analytics/pricing.py +96 -0
- agentfluent/analytics/tokens.py +137 -0
- agentfluent/analytics/tools.py +93 -0
- agentfluent/cli/__init__.py +0 -0
- agentfluent/cli/commands/__init__.py +0 -0
- agentfluent/cli/commands/analyze.py +162 -0
- agentfluent/cli/commands/config_check.py +110 -0
- agentfluent/cli/commands/list_cmd.py +165 -0
- agentfluent/cli/exit_codes.py +18 -0
- agentfluent/cli/formatters/__init__.py +0 -0
- agentfluent/cli/formatters/helpers.py +59 -0
- agentfluent/cli/formatters/json_output.py +60 -0
- agentfluent/cli/formatters/table.py +358 -0
- agentfluent/cli/main.py +63 -0
- agentfluent/config/__init__.py +31 -0
- agentfluent/config/models.py +118 -0
- agentfluent/config/scanner.py +146 -0
- agentfluent/config/scoring.py +299 -0
- agentfluent/core/__init__.py +0 -0
- agentfluent/core/discovery.py +158 -0
- agentfluent/core/parser.py +251 -0
- agentfluent/core/session.py +133 -0
- agentfluent/diagnostics/__init__.py +51 -0
- agentfluent/diagnostics/correlator.py +248 -0
- agentfluent/diagnostics/models.py +75 -0
- agentfluent/diagnostics/signals.py +162 -0
- agentfluent/py.typed +0 -0
- agentfluent-0.1.0.dist-info/METADATA +52 -0
- agentfluent-0.1.0.dist-info/RECORD +39 -0
- agentfluent-0.1.0.dist-info/WHEEL +4 -0
- agentfluent-0.1.0.dist-info/entry_points.txt +3 -0
- agentfluent-0.1.0.dist-info/licenses/LICENSE +21 -0
agentfluent/__init__.py
ADDED
|
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())
|