fix: parse fenced ROI estimates and retain completed reports

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-29 06:41:12 +00:00
parent 7286f84dce
commit 99bc544735
4 changed files with 75 additions and 17 deletions

View file

@ -8,6 +8,7 @@ from pydantic import ValidationError
from typing_extensions import NotRequired, ReadOnly, TypedDict from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.proxy.roi_calculator.github import SourceError from litellm.proxy.roi_calculator.github import SourceError
from litellm.router_strategy.complexity_router.capability_classifier import extract_classifier_json
from litellm.types.roi_calculator import ( from litellm.types.roi_calculator import (
ROICompletionMessage, ROICompletionMessage,
ROICompletionMetadata, ROICompletionMetadata,
@ -159,7 +160,7 @@ class Estimator:
choice: Final = parsed_response.choices[0] choice: Final = parsed_response.choices[0]
if choice.finish_reason not in (None, "stop") or choice.message.content is None: if choice.finish_reason not in (None, "stop") or choice.message.content is None:
raise ValueError("incomplete estimator response") raise ValueError("incomplete estimator response")
result: Final = ROIEstimatorResult.model_validate_json(choice.message.content) result: Final = ROIEstimatorResult.model_validate_json(extract_classifier_json(choice.message.content))
except Exception: except Exception:
raise SourceError( raise SourceError(
"The estimator did not return valid hours and reasoning. Check the selected model and prompt." "The estimator did not return valid hours and reasoning. Check the selected model and prompt."

View file

@ -32,9 +32,7 @@ def _pull() -> ROIPullEvidence:
"additions": 1, "additions": 1,
"deletions": 1, "deletions": 1,
"changed_files": 1, "changed_files": 1,
"files": ( "files": ({"filename": "time.py", "status": "modified", "additions": 1, "deletions": 1},),
{"filename": "time.py", "status": "modified", "additions": 1, "deletions": 1},
),
"commits": ({"sha": "abcdef", "message": "Fix timezone conversion"},), "commits": ({"sha": "abcdef", "message": "Fix timezone conversion"},),
"commit_count": 1, "commit_count": 1,
"incomplete_metadata": False, "incomplete_metadata": False,
@ -53,8 +51,16 @@ def _completion(content: str) -> Mapping[str, object]:
return response return response
@pytest.mark.parametrize(
"content",
(
'{"hours": 4.25, "reasoning": "Timezone conversion and regression verification."}',
'```json\n{"hours": 4.25, "reasoning": "Timezone conversion and regression verification."}\n```',
'The estimate is:\n{"hours": 4.25, "reasoning": "Timezone conversion and regression verification."}\nDone.',
),
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_estimator_sends_metadata_only_json_request_and_parses_valid_result() -> None: async def test_estimator_sends_metadata_only_json_request_and_parses_valid_result(content: str) -> None:
async def complete(request: ROICompletionRequest) -> object: async def complete(request: ROICompletionRequest) -> object:
evidence: Final = TypeAdapter(ROIEstimatorEvidence).validate_json(request.messages[1]["content"]) evidence: Final = TypeAdapter(ROIEstimatorEvidence).validate_json(request.messages[1]["content"])
assert request.temperature == 0 assert request.temperature == 0
@ -66,7 +72,7 @@ async def test_estimator_sends_metadata_only_json_request_and_parses_valid_resul
assert evidence.changes == expected_changes assert evidence.changes == expected_changes
assert evidence.commits[0].message == "Fix timezone conversion" assert evidence.commits[0].message == "Fix timezone conversion"
assert "without AI assistance" in request.messages[0]["content"] assert "without AI assistance" in request.messages[0]["content"]
return _completion('{"hours": 4.25, "reasoning": "Timezone conversion and regression verification."}') return _completion(content)
result: Final = await Estimator(_settings(), complete).estimate(_pull()) result: Final = await Estimator(_settings(), complete).estimate(_pull())
@ -83,6 +89,8 @@ async def test_estimator_sends_metadata_only_json_request_and_parses_valid_resul
'{"hours": true, "reasoning": "invalid"}', '{"hours": true, "reasoning": "invalid"}',
'{"hours": 4}', '{"hours": 4}',
'{"hours": 4, "reasoning": " "}', '{"hours": 4, "reasoning": " "}',
'```json\n{"hours": -1, "reasoning": "invalid"}\n```',
'```json\n{"hours": "4", "reasoning": "invalid"}\n```',
"not json", "not json",
), ),
) )

View file

@ -187,4 +187,59 @@ describe("ROICalculatorView", () => {
expect(screen.getByLabelText("GitHub token")).toHaveAttribute("type", "password"); expect(screen.getByLabelText("GitHub token")).toHaveAttribute("type", "password");
expect(screen.getAllByText("Connect GitHub to get started")).toHaveLength(1); expect(screen.getAllByText("Connect GitHub to get started")).toHaveLength(1);
}); });
it("shows the completed report after polling a running sync", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 };
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockResolvedValueOnce(completedStatus)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
window.setTimeout(() => resolve({ report: summary }), 25);
}),
);
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
expect(await screen.findByText("Spend per estimated engineering hour", {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect GitHub to get started" })).not.toBeInTheDocument();
});
it("shows the sync error returned by the status endpoint", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const errorStatus = {
...idleStatus,
phase: "error",
error: "The estimator could not score a pull request.",
};
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockResolvedValueOnce(errorStatus);
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
"The estimator could not score a pull request.",
);
expect(screen.getByText("Sync failed")).toBeInTheDocument();
});
}); });

View file

@ -12,11 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import ROISettingsPanel from "./ROISettingsPanel"; import ROISettingsPanel from "./ROISettingsPanel";
import { import { IdentityMatchDialog, type PersonMatchSelection, PullReasoningDialog } from "./ROICalculatorDialogs";
IdentityMatchDialog,
type PersonMatchSelection,
PullReasoningDialog,
} from "./ROICalculatorDialogs";
import { ROIOverview, ROIPeopleView } from "./ROICalculatorViews"; import { ROIOverview, ROIPeopleView } from "./ROICalculatorViews";
import { filterPulls } from "./roiCalculatorData"; import { filterPulls } from "./roiCalculatorData";
import type { import type {
@ -93,11 +89,12 @@ export default function ROICalculatorView({ accessToken }: { accessToken: string
.get<ROISyncStatus>("/roi-calculator/sync", { accessToken }) .get<ROISyncStatus>("/roi-calculator/sync", { accessToken })
.then(async (nextStatus) => { .then(async (nextStatus) => {
if (cancelled) return; if (cancelled) return;
setStatus(nextStatus);
if (!nextStatus.running && nextStatus.phase === "complete") { if (!nextStatus.running && nextStatus.phase === "complete") {
const report = await loadReport(); const report = await loadReport();
if (!cancelled) setSummary(report); if (cancelled) return;
setSummary(report);
} }
if (!cancelled) setStatus(nextStatus);
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
if (!cancelled) setError(extractErrorMessage(reason)); if (!cancelled) setError(extractErrorMessage(reason));
@ -144,10 +141,7 @@ export default function ROICalculatorView({ accessToken }: { accessToken: string
[accessToken], [accessToken],
); );
const filteredPulls = React.useMemo( const filteredPulls = React.useMemo(() => (summary ? filterPulls(summary.pulls, query) : []), [query, summary]);
() => (summary ? filterPulls(summary.pulls, query) : []),
[query, summary],
);
if (error && !settings) { if (error && !settings) {
return ( return (