litellm/tests/unit/proxy/roi_calculator/test_analytics.py
devin-ai-integration[bot] 6b9766fa0c
feat(proxy): add native ROI calculator for gateway spend vs merged PRs (#43669)
* feat(proxy): add native ROI calculator for gateway spend vs merged PRs

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(proxy): serialize ROI Prisma inputs with builtin containers

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* style(proxy): format ROI calculator backend files

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix: parse fenced ROI estimates and retain completed reports

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(roi-calculator): correct estimator and dashboard behavior

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* chore(ui): drop next dev generated AGENTS.md block

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(proxy): chunk ROI spend user lookup

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(ui): show reused ROI estimates after sync

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(security): address ROI CodeQL alerts

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* test(proxy): make ROI calculator unit tests discoverable

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* test(ci): run ROI calculator tests in proxy infra shard

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(roi): page repository search and recover polling errors

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* feat(roi): bring scheduled analysis and guided setup into the gateway

* fix(roi): recover interrupted syncs and resolve review findings

* fix(roi): preserve cached estimates across report scope changes

* fix(roi): normalize scheduler timestamps to UTC

* fix(roi): fence cancelled syncs and read reports from writer

* fix(roi): preserve reports during metadata outages

* refactor(roi): isolate outage validation and verify uncached retry

* fix(roi): make scheduled job registration repeatable

* style(roi): format scheduler import

* fix(roi): continue syncing accessible repositories

* fix(roi): preserve reports and identity during upstream outages

* fix(roi): persist refreshed identities for reused estimates

* perf(roi): skip writes for unchanged cached identities

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
Co-authored-by: moe-berri <moe@berri.ai>
2026-09-30 14:16:41 -07:00

147 lines
5 KiB
Python

from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, Literal
from litellm.proxy.roi_calculator.analytics import match_identity, normalize_email, summarize
from litellm.types.roi_calculator import (
ROIPullRecord,
ROIReport,
ROISummaryMetrics,
ROITrendDay,
)
EMPTY_IDENTITY_MAP: Final[Mapping[str, str]] = MappingProxyType({})
def _pull(
number: int = 42,
emails: tuple[str, ...] | None = None,
estimate_status: Literal["estimated", "needs_review", "error"] = "estimated",
hours: float | None = 4.0,
) -> ROIPullRecord:
pull: Final[ROIPullRecord] = {
"repo": "org/repo",
"number": number,
"title": "Fix timezone conversion",
"url": f"https://github.com/org/repo/pull/{number}",
"login": "alice",
"emails": emails if emails is not None else ("alice@example.com",),
"profile_email": "alice@example.com",
"merged_at": "2026-09-12T12:00:00Z",
"head_sha": "abcdef",
"additions": 1,
"deletions": 1,
"changed_files": 1,
"commit_count": 1,
"incomplete_metadata": False,
"estimate": {
"status": estimate_status,
"hours": hours,
"reasoning": "Timezone conversion and regression verification.",
},
"cache_key": f"cache-{number}",
}
return pull
def _report(pulls: tuple[ROIPullRecord, ...] | None = None) -> ROIReport:
report: Final[ROIReport] = {
"mode": "live",
"start": "2026-09-01",
"end": "2026-09-30",
"synced_at": "2026-09-30T12:00:00Z",
"repos": ("org/repo",),
"estimator_model": "test-estimator",
"estimator_prompt": "Estimate effort.",
"effort_basis": "without_ai",
"spend": (
{"date": "2026-09-12", "email": " Alice@Example.com ", "user_id": "u1", "spend": 12, "requests": 2},
{"date": "2026-09-12", "email": "bob@example.com", "user_id": "u2", "spend": 8, "requests": 1},
{"date": "2026-09-12", "email": "", "user_id": "shared", "spend": 5, "requests": 3},
),
"pulls": pulls if pulls is not None else (_pull(),),
"settings_fingerprint": "fingerprint",
}
return report
def test_summary_uses_matched_cohort_for_ratio_and_reports_coverage_and_excluded_spend() -> None:
summary: Final = summarize(
_report((_pull(), _pull(number=43, emails=("unknown@example.test",)))),
EMPTY_IDENTITY_MAP,
)
expected_metrics: Final[ROISummaryMetrics] = {
"matched_spend": 12,
"output_hours": 4,
"total_spend": 25,
"total_output_hours": 8,
"excluded_spend": 13,
"cost_per_hour": 3,
"hours_per_dollar": 1 / 3,
"merged_prs": 2,
"estimated_prs": 2,
"matched_prs": 1,
"cohort_people": 1,
"people_with_prs": 2,
"pending_prs": 0,
}
expected_trend: Final[ROITrendDay] = {
"date": "2026-09-12",
"spend": 12,
"hours": 4,
"prs": 1,
}
assert summary["metrics"] == expected_metrics
assert summary["trend"] == (expected_trend,)
assert summary["metrics"]["matched_prs"] / summary["metrics"]["merged_prs"] == 0.5
def test_manual_login_mapping_overrides_ambiguous_email_candidates() -> None:
pull: Final = _pull(emails=("alice@example.com", "bob@example.com"))
assert match_identity(
pull,
frozenset({"alice@example.com", "bob@example.com"}),
EMPTY_IDENTITY_MAP,
) == (
"",
"ambiguous emails",
)
manual_map: Final[Mapping[str, str]] = MappingProxyType({"alice": "bob@example.com"})
assert match_identity(
pull,
frozenset({"alice@example.com", "bob@example.com"}),
manual_map,
) == ("bob@example.com", "manual")
def test_manual_mapping_recomputes_a_pull_without_email_evidence() -> None:
report: Final = _report((_pull(emails=()),))
before: Final = summarize(report, EMPTY_IDENTITY_MAP)
manual_map: Final[Mapping[str, str]] = MappingProxyType({"alice": "alice@example.com"})
after: Final = summarize(report, manual_map)
assert before["metrics"]["output_hours"] == 0
assert before["people"][0]["spend"] is None
assert after["metrics"]["cost_per_hour"] == 3
assert after["pulls"][0]["match_method"] == "manual"
def test_pending_estimates_exclude_the_person_from_the_ratio() -> None:
report: Final = _report((_pull(), _pull(number=43, estimate_status="error", hours=None)))
summary: Final = summarize(report, EMPTY_IDENTITY_MAP)
assert summary["metrics"]["cost_per_hour"] is None
assert summary["metrics"]["matched_spend"] == 0
assert summary["metrics"]["total_output_hours"] == 4
assert summary["metrics"]["pending_prs"] == 1
def test_email_normalization_rejects_private_or_unusable_addresses() -> None:
assert normalize_email(" Alice+work@Example.com ") == "alice+work@example.com"
assert normalize_email("123+alice@users.noreply.github.com") == ""
assert normalize_email("alice") == ""
assert normalize_email("") == ""