mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
7286f84dce
commit
99bc544735
4 changed files with 75 additions and 17 deletions
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue