From 85f558fbb8e85d0e615724598f68b2ef9d6858b7 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Wed, 30 Sep 2026 12:37:30 -0700 Subject: [PATCH] fix(roi): continue syncing accessible repositories --- .../20-partial-repository-report.png | Bin 0 -> 58018 bytes litellm/proxy/roi_calculator/analytics.py | 11 ++- litellm/proxy/roi_calculator/sync.py | 61 +++++++++++++-- litellm/types/roi_calculator.py | 1 + tests/unit/proxy/roi_calculator/test_sync.py | 70 ++++++++++++++++++ .../ROICalculatorView.integration.test.tsx | 24 ++++++ 6 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 .github/assets/roi-calculator/20-partial-repository-report.png diff --git a/.github/assets/roi-calculator/20-partial-repository-report.png b/.github/assets/roi-calculator/20-partial-repository-report.png new file mode 100644 index 0000000000000000000000000000000000000000..eac03deddaed90ab4808d61ff6bc6c185ede8706 GIT binary patch literal 58018 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 MZ~!ePssDcy0CQS> ROIPersonSummary: spend_rows: Final = tuple( row for row in spend if _person_key(normalize_email(row["email"]), "gateway:" + row["user_id"]) == key @@ -111,11 +112,14 @@ def _summarize_person( pending_prs=pending_count, match_methods=methods, eligible=eligible, - cost_per_hour=spend_total / hours if eligible and hours > 0 and spend_total is not None else None, + cost_per_hour=spend_total / hours + if complete_scope and eligible and hours > 0 and spend_total is not None + else None, ) def summarize(report: ROIReport, mappings: Mapping[str, str]) -> ROISummary: + complete_scope: Final = not report.get("unavailable_repos", ()) observed: Final = frozenset( normalized for normalized in (normalize_email(row["email"]) for row in report["spend"]) if normalized ) @@ -134,6 +138,7 @@ def summarize(report: ROIReport, mappings: Mapping[str, str]) -> ROISummary: key, report["spend"], matched_pulls, + complete_scope, ) for key in sorted(people_keys) ) @@ -181,8 +186,8 @@ def summarize(report: ROIReport, mappings: Mapping[str, str]) -> ROISummary: total_spend=total_spend, total_output_hours=total_output_hours, excluded_spend=max(0.0, total_spend - matched_spend), - cost_per_hour=matched_spend / output_hours if output_hours else None, - hours_per_dollar=output_hours / matched_spend if matched_spend else None, + cost_per_hour=matched_spend / output_hours if complete_scope and output_hours else None, + hours_per_dollar=output_hours / matched_spend if complete_scope and matched_spend else None, merged_prs=len(pull_summaries), estimated_prs=sum(person["estimated_prs"] for person in people), matched_prs=sum(pull["matched"] for pull in pull_summaries), diff --git a/litellm/proxy/roi_calculator/sync.py b/litellm/proxy/roi_calculator/sync.py index da78a7b3dc2..93614c33da0 100644 --- a/litellm/proxy/roi_calculator/sync.py +++ b/litellm/proxy/roi_calculator/sync.py @@ -245,6 +245,54 @@ class _ProcessedPull(NamedTuple): metadata_unavailable: bool = False +class _RepositoryPulls(NamedTuple): + repo: str + pulls: tuple[GitHubPullListItem, ...] + unavailable: bool = False + + +class _RepositoryBatch(NamedTuple): + queue: tuple[tuple[str, GitHubPullListItem], ...] + unavailable_repos: tuple[str, ...] + warnings: tuple[str, ...] + stage: str + + +async def _read_repository(github: GitHub, repo: str, start: date, end: date) -> _RepositoryPulls: + try: + return _RepositoryPulls(repo, await github.pulls(repo, start, end)) + except SourceError: + return _RepositoryPulls(repo, (), unavailable=True) + + +async def _read_repositories(github: GitHub, repos: tuple[str, ...], start: date, end: date) -> _RepositoryBatch: + groups: Final = await asyncio.gather(*(_read_repository(github, repo, start, end) for repo in repos)) + unavailable: Final = tuple(group.repo for group in groups if group.unavailable) + if len(unavailable) == len(repos): + raise SourceError( + "GitHub could not read any selected repository. No new report was published; " + "check repository access or try analysis again later." + ) + queue: Final = tuple(chain.from_iterable(((group.repo, pull) for pull in group.pulls) for group in groups)) + warnings: Final = ( + ( + ( + f"Incomplete report: could not read {', '.join(unavailable)}. " + "Results include only accessible repositories. Spend-per-hour figures are unavailable until " + "all selected repositories can be read. Check repository access or run analysis again to retry." + ), + ) + if unavailable + else () + ) + return _RepositoryBatch( + queue, + unavailable, + warnings, + "Analysis complete with unavailable repositories" if unavailable else "Analysis complete", + ) + + def _processed_records(processed: tuple[_ProcessedPull, ...]) -> Mapping[int, ROIPullRecord]: if processed and all(item.metadata_unavailable for item in processed): raise SourceError( @@ -384,12 +432,8 @@ class SyncManager: start: Final = end - timedelta(days=settings.backfill_days - 1) spend: Final = await spend_reader(start, end) self._update_status(phase="repositories", stage="Reading configured repositories") - pull_groups: Final = await asyncio.gather(*(github.pulls(repo, start, end) for repo in settings.repos)) - queue: Final = tuple( - chain.from_iterable( - ((repo, pull) for pull in pulls) for repo, pulls in zip(settings.repos, pull_groups, strict=True) - ) - ) + repositories: Final = await _read_repositories(github, settings.repos, start, end) + queue: Final = repositories.queue context: Final = cache_context(settings, estimator_models) previous: Final = await self._previous_report(repository) previous_pulls: Final[Mapping[str, ROIPullRecord]] = MappingProxyType( @@ -500,7 +544,8 @@ class SyncManager: spend=spend, pulls=tuple(processed_by_index[index] for index in range(len(queue))), settings_fingerprint=settings_fingerprint(settings), - warnings=(), + warnings=repositories.warnings, + unavailable_repos=repositories.unavailable_repos, ) await github.close() report_json: Final[Mapping[str, object]] = _JSON_OBJECT_ADAPTER.validate_python( @@ -514,7 +559,7 @@ class SyncManager: { "running": False, "phase": "complete", - "stage": "Analysis complete", + "stage": repositories.stage, "finished_at": self._clock().isoformat(), } ) diff --git a/litellm/types/roi_calculator.py b/litellm/types/roi_calculator.py index 7e3b31732df..2e3aa8cf49a 100644 --- a/litellm/types/roi_calculator.py +++ b/litellm/types/roi_calculator.py @@ -205,6 +205,7 @@ class ROIReport(TypedDict): pulls: ReadOnly[tuple[ROIPullRecord, ...]] settings_fingerprint: ReadOnly[str] warnings: NotRequired[ReadOnly[tuple[str, ...]]] + unavailable_repos: NotRequired[ReadOnly[tuple[str, ...]]] id: NotRequired[ReadOnly[str]] diff --git a/tests/unit/proxy/roi_calculator/test_sync.py b/tests/unit/proxy/roi_calculator/test_sync.py index 548712ec65c..a1f98caf0c5 100644 --- a/tests/unit/proxy/roi_calculator/test_sync.py +++ b/tests/unit/proxy/roi_calculator/test_sync.py @@ -9,6 +9,7 @@ import httpx import pytest from pydantic import TypeAdapter +from litellm.proxy.roi_calculator.analytics import summarize from litellm.proxy.roi_calculator.estimator import CompletionCaller from litellm.proxy.roi_calculator.github import GitHubPullListItem from litellm.proxy.roi_calculator.sync import SpendReader, SyncManager, read_spend @@ -457,3 +458,72 @@ async def test_one_unreadable_pr_preserves_other_estimates_in_report() -> None: assert manager.status.phase == "complete" assert manager.status.estimated == 1 assert manager.status.needs_attention == 1 + + +def _repository_outage_transport(status: int, *, all_unavailable: bool = False) -> httpx.MockTransport: + baseline: Final = _transport() + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/repos/org/unavailable/pulls": + return httpx.Response(status, json=[] if status == 200 else {"message": "Repository unavailable"}) + if all_unavailable and request.url.path.endswith("/pulls"): + return httpx.Response(status) + return baseline.handle_request(request) + + return httpx.MockTransport(respond) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", (403, 404, 429)) +async def test_unavailable_repository_publishes_flagged_partial_report_and_recovers(status: int) -> None: + repository: Final = _ReportRepository() + manager: Final = SyncManager(clock=_fixed_now) + settings: Final = _settings().model_copy(update=MappingProxyType({"repos": ("org/repo", "org/unavailable")})) + + assert await manager.start( + settings, repository, _spend_reader(), _completion(), _repository_outage_transport(status) + ) + await _wait_until_finished(manager) + + report: Final = TypeAdapter(ROIReport).validate_python(repository.values["roi_calculator_report"]) + summary: Final = summarize(report, MappingProxyType({})) + assert manager.status.phase == "complete" + assert manager.status.estimated == 1 + assert report["unavailable_repos"] == ("org/unavailable",) + assert "Incomplete report" in report["warnings"][0] and "org/unavailable" in report["warnings"][0] + assert report["pulls"][0]["estimate"]["status"] == "estimated" + assert summary["metrics"]["total_output_hours"] == 4 + assert summary["metrics"]["cost_per_hour"] is None + assert summary["metrics"]["hours_per_dollar"] is None + assert all(person["cost_per_hour"] is None for person in summary["people"]) + + async def unexpected_completion(request: ROICompletionRequest) -> object: + raise AssertionError("The healthy repository's estimate must be reused after recovery") + + assert await manager.start( + settings, repository, _spend_reader(), unexpected_completion, _repository_outage_transport(200) + ) + await _wait_until_finished(manager) + recovered: Final = TypeAdapter(ROIReport).validate_python(repository.values["roi_calculator_report"]) + assert recovered["unavailable_repos"] == () + assert recovered["warnings"] == () + assert manager.status.reused == 1 + assert summarize(recovered, MappingProxyType({}))["metrics"]["cost_per_hour"] == 3 + + +@pytest.mark.asyncio +async def test_all_repository_outage_preserves_previous_report() -> None: + repository: Final = _ReportRepository() + manager: Final = SyncManager(clock=_fixed_now) + settings: Final = _settings().model_copy(update=MappingProxyType({"repos": ("org/repo", "org/unavailable")})) + assert await manager.start(settings, repository, _spend_reader(), _completion(), _repository_outage_transport(200)) + await _wait_until_finished(manager) + previous: Final = repository.values["roi_calculator_report"] + + assert await manager.start( + settings, repository, _spend_reader(), _completion(), _repository_outage_transport(403, all_unavailable=True) + ) + await _wait_until_finished(manager) + assert manager.status.phase == "error" + assert manager.status.error is not None and "No new report was published" in manager.status.error + assert repository.values["roi_calculator_report"] == previous diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx index 3c7965e917f..49d0d03df85 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx @@ -157,6 +157,30 @@ describe("ROICalculatorView", () => { ); }); + it("shows incomplete repository results without a spend-per-hour figure", async () => { + const warning = "Incomplete report: could not read org/unavailable. Spend-per-hour figures are unavailable."; + vi.mocked(apiClient.get).mockImplementation((path: string) => { + if (path === "/roi-calculator/settings") return Promise.resolve(settings); + if (path === "/roi-calculator/report") { + return Promise.resolve({ + report: { + ...summary, + warnings: [warning], + metrics: { ...summary.metrics, cost_per_hour: null, hours_per_dollar: null }, + people: summary.people.map((person) => ({ ...person, cost_per_hour: null })), + }, + }); + } + return Promise.resolve(idleStatus); + }); + + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent(warning); + expect(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })).toBeInTheDocument(); + expect(screen.queryByText("$3.00")).not.toBeInTheDocument(); + }); + it("lets a view-only admin read the report without write controls", async () => { const runningStatus = { ...idleStatus,