diff --git a/src/copilot_usage/report.py b/src/copilot_usage/report.py index 8f60023..4403cdb 100644 --- a/src/copilot_usage/report.py +++ b/src/copilot_usage/report.py @@ -25,6 +25,7 @@ UserMessageData, merge_model_metrics, ) +from copilot_usage.pricing import lookup_model_pricing __all__ = [ "format_duration", @@ -111,6 +112,19 @@ def _estimated_output_tokens(session: SessionSummary) -> int: return sum(m.usage.outputTokens for m in session.model_metrics.values()) +def _estimate_premium_cost(model: str | None, calls: int) -> str: + """Return a ``~``-prefixed estimated premium cost string. + + Uses :func:`lookup_model_pricing` to look up the multiplier for *model* + and multiplies by *calls*. Returns ``"—"`` when *model* is ``None``. + """ + if model is None: + return "—" + pricing = lookup_model_pricing(model) + cost = round(calls * pricing.multiplier) + return f"~{cost}" + + def _format_session_running_time(session: SessionSummary) -> str: """Return a human-readable running time for *session*. @@ -151,6 +165,7 @@ def render_live_sessions( table.add_column("Model", style="magenta") table.add_column("Running", style="yellow", justify="right") table.add_column("Messages", style="blue", justify="right") + table.add_column("Est. Cost", style="green", justify="right") table.add_column("Output Tokens", style="red", justify="right") table.add_column("CWD", style="dim") @@ -169,10 +184,12 @@ def render_live_sessions( # Resumed/active session with post-resume stats (even when 0) messages = str(s.active_user_messages) output_tok = s.active_output_tokens + est_cost = _estimate_premium_cost(s.model, s.active_model_calls) else: # Pure-active (never shut down): totals are already in model_metrics messages = str(s.user_messages) output_tok = _estimated_output_tokens(s) + est_cost = _estimate_premium_cost(s.model, s.model_calls) tokens = format_tokens(output_tok) cwd = s.cwd or "—" @@ -183,6 +200,7 @@ def render_live_sessions( model, running, messages, + est_cost, tokens, cwd, ) @@ -921,11 +939,12 @@ def render_cost_view( grand_model_calls += s.model_calls if s.is_active: + est = _estimate_premium_cost(s.model, s.active_model_calls) table.add_row( " ↳ Since last shutdown", s.model or "—", "N/A", - "N/A", + est, str(s.active_model_calls), format_tokens(s.active_output_tokens), ) diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py index 3d8d407..d058aac 100644 --- a/tests/copilot_usage/test_report.py +++ b/tests/copilot_usage/test_report.py @@ -379,6 +379,54 @@ def test_resumed_session_zero_activity_shows_zeros(self) -> None: "resumed session row should show 0 for both messages and output tokens" ) + def test_est_cost_column_present(self) -> None: + """Live sessions table includes an Est. Cost column.""" + now = datetime.now(tz=UTC) + session = _make_session(start_time=now - timedelta(minutes=5)) + output = _capture_output([session]) + assert "Est. Cost" in output + + def test_est_cost_premium_model(self) -> None: + """Live session with a premium model shows estimated cost.""" + now = datetime.now(tz=UTC) + session = SessionSummary( + session_id="live-premium-1234", + name="Premium Live", + model="claude-opus-4.6", + is_active=True, + start_time=now - timedelta(minutes=10), + user_messages=5, + model_calls=4, + model_metrics={ + "claude-opus-4.6": ModelMetrics( + usage=TokenUsage(outputTokens=1000), + ) + }, + ) + output = _capture_output([session]) + # 4 calls × 3.0 multiplier = ~12 + assert "~12" in output + + def test_est_cost_free_model(self) -> None: + """Live session with gpt-5-mini (0× multiplier) shows ~0.""" + now = datetime.now(tz=UTC) + session = SessionSummary( + session_id="live-free-12345678", + name="Free Live", + model="gpt-5-mini", + is_active=True, + start_time=now - timedelta(minutes=10), + user_messages=5, + model_calls=4, + model_metrics={ + "gpt-5-mini": ModelMetrics( + usage=TokenUsage(outputTokens=500), + ) + }, + ) + output = _capture_output([session]) + assert "~0" in output + # --------------------------------------------------------------------------- # Helpers for session detail tests @@ -1469,7 +1517,8 @@ def test_active_session_shows_shutdown_row(self) -> None: ) output = _capture_cost_view([session]) assert "Since last shutdown" in output - assert "N/A" in output + # Premium Cost shows estimated cost (~9 = 3 calls × 3.0 multiplier) + assert "~9" in output def test_session_without_metrics(self) -> None: session = SessionSummary( @@ -1561,7 +1610,8 @@ def test_pure_active_session_no_metrics_shows_both_rows(self) -> None: assert "Just Started" in output assert "—" in output # placeholder row (no metrics) assert "Since last shutdown" in output # active row - assert "N/A" in output + # Premium Cost shows estimated cost (~2 = 2 calls × 1.0 multiplier) + assert "~2" in output def test_pure_active_no_metrics_grand_total_includes_active_tokens(self) -> None: """Grand total output tokens includes active_output_tokens for no-metrics active session.""" @@ -1611,6 +1661,71 @@ def test_mixed_sessions_grand_total(self) -> None: # 2000 + 500 = 2500 → "2.5K" assert "2.5K" in output + def test_active_session_estimated_cost_known_model(self) -> None: + """Active session shows numeric estimated cost, not 'N/A', when model is known.""" + session = SessionSummary( + session_id="est-cost-known-mod", + name="Known Model", + model="claude-opus-4.5", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + model_calls=5, + active_model_calls=4, + active_output_tokens=800, + model_metrics={ + "claude-opus-4.5": ModelMetrics( + requests=RequestMetrics(count=5, cost=15), + usage=TokenUsage(outputTokens=2000), + ) + }, + ) + output = _capture_cost_view([session]) + # claude-opus-4.5 multiplier = 3.0, active_model_calls = 4 → ~12 + assert "~12" in output + # The "Since last shutdown" row should NOT show "N/A" for Premium Cost + lines = output.splitlines() + shutdown_line = next( + (line for line in lines if "Since last shutdown" in line), "" + ) + assert "N/A" not in shutdown_line or shutdown_line.count("N/A") == 1 + + def test_estimated_cost_zero_for_free_model(self) -> None: + """gpt-5-mini has 0× multiplier → estimated cost is 0.""" + session = SessionSummary( + session_id="est-cost-free-mod", + name="Free Model", + model="gpt-5-mini", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + model_calls=5, + active_model_calls=5, + active_output_tokens=1000, + ) + output = _capture_cost_view([session]) + assert "~0" in output + + def test_estimated_cost_premium_model_multiplier(self) -> None: + """3 calls of claude-opus-4.6 (3× multiplier) → estimated cost ~9.""" + session = SessionSummary( + session_id="est-cost-prem-mod", + name="Premium Model", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + model_calls=3, + active_model_calls=3, + active_output_tokens=500, + model_metrics={ + "claude-opus-4.6": ModelMetrics( + requests=RequestMetrics(count=3, cost=9), + usage=TokenUsage(outputTokens=1000), + ) + }, + ) + output = _capture_cost_view([session]) + # 3 calls × 3.0 multiplier = ~9 + assert "~9" in output + class TestRenderFullSummaryHelperReuse: """Verify _render_historical_section delegates to shared table helpers."""