Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ $ copilot-usage cost
│ Session Gamma │ claude-opus-4.6 │ 8 │ 10 │ 2 │ 400 │
│ ↳ Since last shutdown │ claude-opus-4.6 │ N/A │ N/A │ 1 │ 150 │
├──────────────────────────┼────────────────────┼──────────┼──────────────┼─────────────┼───────────────┤
│ Grand Total │ │ 835 │ 802 │ 9 │ 301.5K │
│ Grand Total │ │ 835 │ 802 │ 8 │ 301.5K │
└──────────────────────────┴────────────────────┴──────────┴──────────────┴─────────────┴───────────────┘
```

Expand Down
75 changes: 12 additions & 63 deletions src/copilot_usage/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,13 +611,18 @@ def _render_totals(console: Console, sessions: list[SessionSummary]) -> None:
console.print(Panel("\n".join(lines), title="Totals", border_style="cyan"))


def _render_model_table(console: Console, sessions: list[SessionSummary]) -> None:
def _render_model_table(
console: Console,
sessions: list[SessionSummary],
*,
title: str = "Per-Model Breakdown",
) -> None:
"""Render the per-model breakdown table."""
merged = _aggregate_model_metrics(sessions)
if not merged:
return

table = Table(title="Per-Model Breakdown", border_style="cyan")
table = Table(title=title, border_style="cyan")
table.add_column("Model", style="bold")
table.add_column("Requests", justify="right")
table.add_column("Premium Cost", justify="right")
Expand All @@ -642,6 +647,8 @@ def _render_model_table(console: Console, sessions: list[SessionSummary]) -> Non
def _render_session_table(
console: Console,
sessions: list[SessionSummary],
*,
title: str = "Sessions",
) -> None:
"""Render the per-session table sorted by start time (newest first)."""
if not sessions:
Expand All @@ -653,7 +660,7 @@ def _render_session_table(
reverse=True,
)

table = Table(title="Sessions", border_style="cyan")
table = Table(title=title, border_style="cyan")
table.add_column("Name", style="bold", max_width=40)
table.add_column("Model")
table.add_column("Premium", justify="right")
Expand Down Expand Up @@ -759,67 +766,10 @@ def _render_historical_section(
)

# Per-model table
merged = _aggregate_model_metrics(historical)
if merged:
table = Table(title="Per-Model Breakdown", border_style="cyan")
table.add_column("Model", style="bold")
table.add_column("Requests", justify="right")
table.add_column("Premium Cost", justify="right")
table.add_column("Input Tokens", justify="right")
table.add_column("Output Tokens", justify="right")
table.add_column("Cache Read", justify="right")

for model_name in sorted(merged):
mm = merged[model_name]
table.add_row(
model_name,
str(mm.requests.count),
str(mm.requests.cost),
format_tokens(mm.usage.inputTokens),
format_tokens(mm.usage.outputTokens),
format_tokens(mm.usage.cacheReadTokens),
)
console.print(table)
_render_model_table(console, historical)

# Per-session table
sorted_sessions = sorted(
historical,
key=lambda s: s.start_time.isoformat() if s.start_time else "",
reverse=True,
)
session_table = Table(title="Sessions (Shutdown Data)", border_style="cyan")
session_table.add_column("Name", style="bold", max_width=40)
session_table.add_column("Model")
session_table.add_column("Premium", justify="right")
session_table.add_column("Model Calls", justify="right")
session_table.add_column("User Msgs", justify="right")
session_table.add_column("Output Tokens", justify="right")
session_table.add_column("Status")

for s in sorted_sessions:
name = s.name or s.session_id[:12]
model = s.model or "—"
output_tokens = sum(mm.usage.outputTokens for mm in s.model_metrics.values())
status = (
Text("Active 🟢", style="yellow")
if s.is_active
else Text("Completed", style="dim")
)
pr_display = (
str(s.total_premium_requests) if s.total_premium_requests > 0 else "—"
)

session_table.add_row(
name,
model,
pr_display,
str(s.model_calls),
str(s.user_messages),
format_tokens(output_tokens),
status,
)

console.print(session_table)
_render_session_table(console, historical, title="Sessions (Shutdown Data)")


def _render_active_section(
Expand Down Expand Up @@ -966,7 +916,6 @@ def render_cost_view(
str(s.active_model_calls),
format_tokens(s.active_output_tokens),
)
grand_model_calls += s.active_model_calls
grand_output += s.active_output_tokens

table.add_section()
Expand Down
82 changes: 82 additions & 0 deletions tests/copilot_usage/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -1239,3 +1239,85 @@ def test_multi_model_session(self) -> None:
assert "claude-sonnet-4" in output
assert "claude-haiku-4.5" in output
assert "Grand Total" in output

def test_resumed_session_no_double_count(self) -> None:
"""Regression: active_model_calls must not be added to grand_model_calls."""
import re

session = SessionSummary(
session_id="resume-5555-abcde",
name="Resumed",
model="claude-opus-4.6",
start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC),
is_active=True,
model_calls=10,
user_messages=4,
active_model_calls=3,
active_output_tokens=200,
model_metrics={
"claude-opus-4.6": ModelMetrics(
requests=RequestMetrics(count=7, cost=21),
usage=TokenUsage(outputTokens=1000),
)
},
)
output = _capture_cost_view([session])
# Grand Total Model Calls should be 10, not 13
clean = re.sub(r"\x1b\[[0-9;]*m", "", output)
# Match: Grand Total │ │ Req │ Prem │ ModelCalls │
grand_match = re.search(
r"Grand Total\s*│[^│]*│\s*\d+\s*│\s*\d+\s*│\s*(\d+)\s*│", clean
)
assert grand_match is not None, "Grand Total row not found"
assert grand_match.group(1) == "10"


class TestRenderFullSummaryHelperReuse:
"""Verify _render_historical_section delegates to shared table helpers."""

def test_historical_session_table_title(self) -> None:
"""Historical section must use Sessions (Shutdown Data) title."""
session = SessionSummary(
session_id="hist-7777-abcdef",
name="HistReuse",
model="claude-sonnet-4",
start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC),
is_active=False,
total_premium_requests=5,
user_messages=2,
model_calls=3,
model_metrics={
"claude-sonnet-4": ModelMetrics(
requests=RequestMetrics(count=3, cost=5),
usage=TokenUsage(
inputTokens=300, outputTokens=600, cacheReadTokens=50
),
)
},
)
output = _capture_full_summary([session])
assert "Sessions (Shutdown Data)" in output

def test_historical_model_table_present(self) -> None:
"""Historical section must contain per-model breakdown table."""
session = SessionSummary(
session_id="hist-8888-abcdef",
name="ModelTbl",
model="claude-sonnet-4",
start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC),
is_active=False,
total_premium_requests=5,
user_messages=2,
model_calls=3,
model_metrics={
"claude-sonnet-4": ModelMetrics(
requests=RequestMetrics(count=3, cost=5),
usage=TokenUsage(
inputTokens=300, outputTokens=600, cacheReadTokens=50
),
)
},
)
output = _capture_full_summary([session])
assert "Per-Model Breakdown" in output
assert "claude-sonnet-4" in output
Loading