mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
* 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>
147 lines
5 KiB
Python
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("") == ""
|