diff --git a/app/services/analysis.py b/app/services/analysis.py index 32dd497..66600cb 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -96,16 +96,56 @@ def _run(prompt: str, *, system: str = _SYSTEM) -> str: try: provider = get_provider() - return provider.complete( + content = provider.complete( [ {"role": "system", "content": system}, {"role": "user", "content": prompt}, ] ) + _record_analysis_usage(provider) + return content except LLMProviderError as exc: return f"[analysis unavailable: {exc}]" +def _record_analysis_usage(provider) -> None: + """Record one GitHub-analysis completion in the central usage ledger. + + ``complete()`` returns text only, so token counts are recorded as estimated + zeroes rather than guessed. Accounting is best-effort and must never break + analysis; anonymous calls and calls outside an app context are not ledgered + because they have no owner. + """ + from flask import has_app_context + + if not has_app_context(): + return + + from flask_login import current_user + + if not getattr(current_user, "is_authenticated", False): + return + + from app.services import usage_ledger + + model = getattr(provider, "model", None) + if not model: + models = getattr(provider, "models", ()) or () + model = models[0] if models else None + try: + usage_ledger.record_usage( + source=usage_ledger.SOURCE_ANALYSIS, + user=current_user, + provider=getattr(provider, "name", None), + model=model, + tokens_estimated=True, + status=usage_ledger.STATUS_SUCCESS, + ) + except Exception: + # Never let accounting change the analysis result. + return + + def _clip(text: str, limit: int = MAX_CONTEXT_CHARS) -> str: if len(text) <= limit: return text diff --git a/tests/test_analysis_usage_ledger.py b/tests/test_analysis_usage_ledger.py new file mode 100644 index 0000000..214dadc --- /dev/null +++ b/tests/test_analysis_usage_ledger.py @@ -0,0 +1,46 @@ +"""GitHub analysis must record one usage-ledger row per LLM call (#366).""" + +from __future__ import annotations + +from types import SimpleNamespace + +from flask_login import login_user + +from app.models import UsageRecord +from app.services import analysis, budgets + + +class FakeProvider: + name = "mock" + model = "mock-1" + models = ("mock-1",) + + def complete(self, messages, *, stream=False): + return "analysis result" + + def chat(self, messages, **kwargs): + raise AssertionError("analysis must keep using the provider's complete() path") + + +def test_github_analysis_records_a_ledger_row(app, db, make_user, monkeypatch): + user = make_user(username="analyst", email="analyst@example.com") + monkeypatch.setattr(analysis, "get_provider", lambda: FakeProvider()) + monkeypatch.setattr( + budgets, + "check_budgets", + lambda current_user: SimpleNamespace(allowed=True, message=""), + ) + monkeypatch.setattr(budgets, "after_usage_recorded", lambda record: None) + + with app.test_request_context("/"): + login_user(user) + assert analysis._run("review this change") == "analysis result" + + row = UsageRecord.query.order_by(UsageRecord.id.desc()).first() + assert row is not None + assert row.source == "analysis" + assert row.user_id == user.id + assert row.provider == "mock" + assert row.model == "mock-1" + assert row.total_tokens == 0 + assert row.tokens_estimated is True