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
77 changes: 77 additions & 0 deletions src/gitmate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,83 @@ def commit(
raise typer.Exit(code)


@app.command()
def stats(
days: int | None = typer.Option(None, "--days", "-d", help="Limit stats to the last N days."),
month: bool = typer.Option(
False, "--month", "-m", help="Show stats for current calendar month."
),
raw: bool = typer.Option(
False, "--raw", help="Output raw JSON for scripting or README tables."
),
) -> None:
"""Display telemetry metrics, cache hit rate, token usage, and estimated spend."""
import json
from dataclasses import asdict
from datetime import UTC, datetime, timedelta

from gitmate.metrics import get_aggregate_stats

since: datetime | None = None
now = datetime.now(UTC)
if month:
since = datetime(now.year, now.month, 1, tzinfo=UTC)
elif days is not None:
if days <= 0:
raise typer.BadParameter("--days must be a positive integer.")
since = now - timedelta(days=days)

stats_data = get_aggregate_stats(since=since)

if raw:
typer.echo(json.dumps(asdict(stats_data), indent=2))
return

if stats_data.total_invocations == 0:
console.print(
"[yellow]No invocations recorded yet. Run 'gitmate commit' to start collecting metrics.[/yellow]"
)
return

time_label = "Current Month" if month else (f"Last {days} Days" if days else "All Time")
table = Table(title=f"gitmate stats ({time_label})", show_header=True)
table.add_column("Metric", style="bold")
table.add_column("Value", justify="right")

table.add_row("Total Invocations", str(stats_data.total_invocations))
table.add_row("Cache Hits", f"{stats_data.cache_hits} ({stats_data.cache_hit_rate:.1f}%)")
table.add_row(
"Tokens (In / Out / Total)",
f"{stats_data.total_tokens_in:,} / {stats_data.total_tokens_out:,} / {stats_data.total_tokens:,}",
)
table.add_row("Estimated Spend", f"${stats_data.total_cost_usd:.4f}")
table.add_row("Avg Latency (Overall)", f"{stats_data.avg_latency_ms:.0f} ms")
table.add_row("Avg Latency (Cache Hit)", f"{stats_data.avg_latency_hit_ms:.0f} ms")
table.add_row("Avg Latency (Cache Miss)", f"{stats_data.avg_latency_miss_ms:.0f} ms")
table.add_row("Fallback Templates Used", str(stats_data.fallback_count))

console.print(table)

if stats_data.by_command:
cmd_table = Table(title="By Command", show_header=True)
cmd_table.add_column("Command")
cmd_table.add_column("Runs", justify="right")
cmd_table.add_column("Hit Rate", justify="right")
cmd_table.add_column("Tokens", justify="right")
cmd_table.add_column("Spend", justify="right")
cmd_table.add_column("Avg Latency", justify="right")
for cmd, b in sorted(stats_data.by_command.items()):
cmd_table.add_row(
cmd,
str(b.invocations),
f"{b.cache_hit_rate:.1f}%",
f"{b.total_tokens:,}",
f"${b.cost_usd:.4f}",
f"{b.avg_latency_ms:.0f} ms",
)
console.print(cmd_table)


@app.command()
def pr_summary() -> None:
"""Generate a PR description for the current branch."""
Expand Down
45 changes: 41 additions & 4 deletions src/gitmate/committer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from gitmate.config import GitmateConfig
from gitmate.diff_extractor import DiffExtractor, GitCommandError
from gitmate.fallback import generate_commit_message
from gitmate.metrics import record_invocation
from gitmate.metrics import get_monthly_spend, record_invocation
from gitmate.providers.base import LLMProvider
from gitmate.token_budget import TokenCounter

Expand All @@ -39,6 +39,7 @@ def _safe_record_telemetry(
cache_hit: bool,
latency_ms: int | None,
fallback_used: bool,
estimated_cost_usd: float | None = None,
) -> None:
"""Record invocation to metrics database without propagating errors."""
try:
Expand All @@ -50,6 +51,7 @@ def _safe_record_telemetry(
cache_hit=cache_hit,
latency_ms=latency_ms,
fallback_used=fallback_used,
estimated_cost_usd=estimated_cost_usd,
)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to record telemetry: %s", exc)
Expand Down Expand Up @@ -232,6 +234,23 @@ def commit_flow(
)
return 1

# 3. Monthly budget cap enforcement
if active_cfg.budget_cap_usd is not None:
monthly_spend = get_monthly_spend()
if monthly_spend >= active_cfg.budget_cap_usd:
con.print(
f"[red]error:[/red] Monthly budget cap exceeded "
f"(${monthly_spend:.2f} >= ${active_cfg.budget_cap_usd:.2f}). "
"Aborting to prevent further LLM spend.\n"
"To adjust or remove the cap, run: gitmate config set budget_cap_usd <new_limit_or_none>"
)
return 1
if monthly_spend >= 0.8 * active_cfg.budget_cap_usd:
con.print(
f"[yellow]warning: Monthly spend has reached {int(monthly_spend / active_cfg.budget_cap_usd * 100)}% "
f"of budget cap (${monthly_spend:.2f} / ${active_cfg.budget_cap_usd:.2f}).[/yellow]"
)

active_counter = counter
if active_counter is None and provider is not None:

Expand All @@ -241,7 +260,7 @@ def count_tokens(self, text: str, model: str) -> int:

active_counter = _SimpleCounter()

# 3. Initial generation & telemetry recording
# 4. Initial generation & telemetry recording
t0 = time.perf_counter()
gen_result = generate_commit_message(
diffs=staged_diffs,
Expand All @@ -262,18 +281,19 @@ def count_tokens(self, text: str, model: str) -> int:
cache_hit=gen_result.cache_hit,
latency_ms=latency_ms,
fallback_used=gen_result.is_fallback,
estimated_cost_usd=gen_result.estimated_cost_usd,
)

current_message = gen_result.text
current_model = gen_result.model
current_is_fallback = gen_result.is_fallback

# 4. Fast path: --yes with allow_noninteractive_commit enabled
# 5. Fast path: --yes with allow_noninteractive_commit enabled
if yes:
code = _execute_commit(current_message, console=con, cwd=repo_dir)
return code

# 5. Interactive review loop
# 6. Interactive review loop
_render_panel(con, current_message, current_model, current_is_fallback)

while True:
Expand Down Expand Up @@ -305,6 +325,22 @@ def count_tokens(self, text: str, model: str) -> int:
_render_panel(con, current_message, current_model, current_is_fallback)

elif choice in ("r", "regenerate"):
if active_cfg.budget_cap_usd is not None:
monthly_spend = get_monthly_spend()
if monthly_spend >= active_cfg.budget_cap_usd:
con.print(
f"[red]error:[/red] Monthly budget cap exceeded "
f"(${monthly_spend:.2f} >= ${active_cfg.budget_cap_usd:.2f}). "
"Aborting to prevent further LLM spend.\n"
"To adjust or remove the cap, run: gitmate config set budget_cap_usd <new_limit_or_none>"
)
continue
if monthly_spend >= 0.8 * active_cfg.budget_cap_usd:
con.print(
f"[yellow]warning: Monthly spend has reached {int(monthly_spend / active_cfg.budget_cap_usd * 100)}% "
f"of budget cap (${monthly_spend:.2f} / ${active_cfg.budget_cap_usd:.2f}).[/yellow]"
)

con.print("Regenerating commit message with fresh API call...")
t0 = time.perf_counter()
gen_result = generate_commit_message(
Expand All @@ -326,6 +362,7 @@ def count_tokens(self, text: str, model: str) -> int:
cache_hit=False,
latency_ms=latency_ms,
fallback_used=gen_result.is_fallback,
estimated_cost_usd=gen_result.estimated_cost_usd,
)

current_message = gen_result.text
Expand Down
32 changes: 28 additions & 4 deletions src/gitmate/fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from gitmate.chunking import summarize_omitted_diffs
from gitmate.config import API_KEY_ACCOUNT, GitmateConfig
from gitmate.diff_extractor import FileDiff
from gitmate.metrics import calculate_cost
from gitmate.prompt import load_template, render_commit_prompt
from gitmate.providers.base import LLMProvider, ProviderUnavailable
from gitmate.providers.cache import CachedProvider, cache_key
Expand Down Expand Up @@ -54,6 +55,7 @@ class GenerationResult:
input_tokens: int | None = None
output_tokens: int | None = None
cache_hit: bool = False
estimated_cost_usd: float = 0.0


def _extract_scope(path_str: str) -> str | None:
Expand Down Expand Up @@ -242,7 +244,7 @@ def generate_commit_message(
active_provider: LLMProvider
if provider is not None:
if bypass_cache and isinstance(provider, CachedProvider):
active_provider = provider._provider
active_provider = provider.underlying
else:
active_provider = provider
else:
Expand Down Expand Up @@ -273,10 +275,11 @@ def generate_commit_message(
)

# 2. Generation / Chunk-and-summarize with graceful fallback on provider failure
try:
total_input_tokens = 0
total_output_tokens = 0
total_input_tokens = 0
total_output_tokens = 0
chunk_cost = 0.0

try:
if decision.strategy is BudgetStrategy.NEEDS_CHUNKING:
if not decision.omitted_diffs:
if console is not None:
Expand All @@ -298,6 +301,14 @@ def generate_commit_message(
)
total_input_tokens += chunk_in
total_output_tokens += chunk_out
if chunk_in or chunk_out:
chunk_cost = calculate_cost(
model=cfg.model,
tokens_in=chunk_in or None,
tokens_out=chunk_out or None,
cache_hit=False,
fallback_used=False,
)

summary_note = (
f"{decision.summary_note}\n\nSummaries of large omitted files:\n{merged_summaries}"
Expand Down Expand Up @@ -327,13 +338,23 @@ def generate_commit_message(
if resp.output_tokens is not None:
total_output_tokens += resp.output_tokens

final_cost = calculate_cost(
model=resp.model,
tokens_in=resp.input_tokens,
tokens_out=resp.output_tokens,
cache_hit=cache_hit,
fallback_used=False,
)
cost = round(chunk_cost + final_cost, 6)

return GenerationResult(
text=resp.text,
is_fallback=False,
model=resp.model,
input_tokens=total_input_tokens or None,
output_tokens=total_output_tokens or None,
cache_hit=cache_hit,
estimated_cost_usd=cost,
)

except ProviderUnavailable as exc:
Expand All @@ -345,5 +366,8 @@ def generate_commit_message(
text=fallback_msg,
is_fallback=True,
model=cfg.model,
input_tokens=total_input_tokens or None,
output_tokens=total_output_tokens or None,
estimated_cost_usd=chunk_cost,
fallback_reason=str(exc),
)
Loading
Loading