fix(budgets): PLTF-3562 never write a member cap below their cycle baseline - #415
Conversation
LiteLLM compares cumulative spend against an absolute member cap, so the cap is written as baseline + allowance. Only the route's Pydantic model rejects a non-positive monthly_limit: the service method and the store take whatever they are handed, and the column has no CHECK constraint. A negative override therefore produced a cap the member had already exceeded, locking them out for the rest of the cycle without their having spent anything. Clamp the allowance at zero when computing the cap. No allowance now means no further spend this cycle rather than retroactive debt, and the same clamp covers a negative default_user_monthly_limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||
aivong-openhands
left a comment
There was a problem hiding this comment.
🔴 Taste Rating: Needs improvement
The diagnosis is right and the one-character-ish fix does stop the bad cap reaching LiteLLM. But clamping at the write site while leaving the verification site unclamped splits one truth into two, and the result is a permanently degraded org.
[CRITICAL ISSUES]
-
[
server/services/org_budget_service.py:1390] Data Structure — the clamp is applied at one of two sites that must agree. The expected member cap is computed in two places from the same inputs:_sync_litellm_budgetsline 1390 (this PR):baseline + max(effective_limit, 0)— what gets written._budget_policy_comparisonline 329 (untouched):baseline + effective_limit— what drift detection expects to read back.
With the
-100override the test drives, againstbaseline=100:written to LiteLLM : 100.0 drift expects : 0.0 match (1e-6) : FalseSo
_budget_sync_readback_errorsemitsmember_budget_mismatch,policy_matchesgoesFalse, and_budget_policy_comparisonreturnsreconciliation_state='degraded'. That state is not cosmetic —get_org_budget_settingsandupsert_org_budget_overrideboth turndegradedinto an HTTP 503 (server/routes/orgs.py:1286,:1322), and the frontend surfaces the org as broken. The org cannot get out of it: every subsequent sync writes the clamped value and every subsequent readback expects the unclamped one, forever, until someone edits the row by hand.Note the readback loop inside
_sync_litellm_budgetsuses the localexpected_member_budgets(which is clamped, line 1395), so the sync itself reportssuccess. It is the separate comparison in_budget_policy_comparisonthat disagrees. That split is what makes this hard to notice and is the actual defect: two functions independently reimplement "the cap this member should have."The fix is to compute the cap once. Extract the clamp into the existing
_effective_user_budget_limit(which already owns "what limit applies to this member") or into a small_member_cap(baseline, effective_limit)helper, and call it from both sites. Then the two can never drift apart, which is the property you want — not twomax()calls that someone has to keep in sync. -
[
tests/unit/test_org_budget_service.py] Testing gap that hid the above.test_override_cap_is_never_written_below_the_cycle_baselineasserts only onupdate_user.await_args_list— the caps handed to LiteLLM. It never inspectsreconciliation_state, so the degraded-forever consequence is invisible to it. The test passes and the org is broken. Please add an assertion that the org reconciles tohealthy(or at least notdegraded) after a clamped override, which is the assertion that would have caught this.
[IMPROVEMENT OPPORTUNITIES]
-
[
server/services/org_budget_service.py:1383-1389] Unnecessary comments: five lines of comment for amax(x, 0). Lines 2-5 restate the PR description ("such a cap is already exceeded the moment it is written... no allowance means no further spend this cycle, not retroactive debt"). The original single line — "LiteLLM compares cumulative spend against an absolute member cap" — was the genuinely non-obvious fact and was sufficient; if the clamp moves into a named helper as suggested above, the name carries the rest. -
Pragmatism: the PR notes that the fuller fix is rejecting a non-positive
monthly_limitinupsert_user_overrideplus a CHECK constraint, and defers it. I think that ordering is backwards for this particular defect. The clamp preserves a nonsense row in the database and then papers over it on every read forever; validation deletes the problem. The clamp is defensible as defence-in-depth alongside validation, but on its own it is the more complex of the two options and the one that just produced a second bug. Worth reconsidering which fix ships first.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Confined to org budget sync, no schema change, no API surface change. The realistic trigger — a negative override — requires bypassing the route's Pydantic gt=0, so the population at risk is small. But when it does trigger, the outcome is an org stuck at HTTP 503 on its budgets page with no self-service recovery, which is worse for that org than the original bug (one member over-restricted). CI is green, which is precisely the problem: the test suite does not check the state this change breaks.
Recommendation: Do not merge until the clamp is applied consistently at both computation sites. This is a small change, but the failure it introduces is silent and unrecoverable without manual intervention.
VERDICT:
❌ Needs rework: Clamp once, in one place, and assert the org still reconciles healthy afterwards.
KEY INSIGHT:
When the same quantity is computed in two places, fixing one is not a fix — it is a divergence, and here that divergence is exactly the drift detector those two sites exist to power.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of @aivong-openhands.
The clamp landed only at the write site in _sync_litellm_budgets, while _budget_policy_comparison still expected the unclamped baseline + limit on readback. A negative override therefore left the org permanently degraded (HTTP 503 on the budgets routes) even though the cap itself was correct. Extract the clamp into _member_cap and call it from both sites, and assert in the reproduction test that the org still reconciles healthy afterwards. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks — the critical issue is real and I reproduced it before changing anything. With So the clamp at the write site alone did leave the org permanently degraded, which is a 503 on the budgets routes with no self-service recovery. Addressed in 54b1ce5. Clamp once. Extracted Test gap. Comments. Dropped the five-line block. The helper name carries the intent and the remaining comment is just the non-obvious LiteLLM fact. On validation vs clamping. Fair point, and I agree validation is the better primary fix — but I'd rather not fold it into this PR. The clamp is still needed regardless, because rows that predate validation already exist and a CHECK constraint would fail to apply until they are cleaned up; with the cap computed in one place it is now cheap defence-in-depth rather than a second source of truth. Rejecting a non-positive
This comment was created by an AI agent (OpenHands) on behalf of @aivong-openhands. |
Mutation review of the testsI mutation-tested this PR's tests: break the behaviour on purpose and see whether the suite notices. Baseline on the branch is Controls — the PR's own claim is pinned
Both halves of the two-site claim are genuinely pinned, and what makes that work is driving the second snapshot rather than asserting on the first. Replacing the single Survivors
M10 —
|
The preflight mirrors the sync's cap formula read-only, so leaving it unclamped makes the post-upgrade gate report cap_drift at blocking severity against the exact caps the clamped sync just wrote, failing a strict-mode upgrade of the very org the clamp rescues. Call _member_cap from the third site so all three agree. Pin the two untested routes into the clamp as well: a non-positive default_user_monthly_limit reaches the same arithmetic without an override, and the clamp's own output of zero is falsy, so a truthiness check on effective_limit would silently return the member to the shared team budget instead of capping them. Co-authored-by: openhands <openhands@all-hands.dev>
|
Addressed all three survivors in 79528b4. The mutation review was right on each one, and M10 in particular — that the preflight is the third site computing the cap, and that clamping only some of them is exactly the failure mode the PR description argues against — was a real defect, not just a test gap. M10 — the preflight's cap is now clamped
Added M2 and M7 — the default-limit route and the clamp's own outputAdded
M7 was the one worth catching. The clamp's output for a negative allowance is zero, so a one-character edit turned "no allowance means no further spend this cycle" into "spend against the org's shared pool" — the opposite of the intent — with nothing to notice. As you say, I took your test bodies essentially as written, with two cosmetic changes: renamed to Also noted
Full suites after the change: This comment was created by an AI agent (OpenHands) on behalf of the user. |
|
@OpenHands is there an E2E test in https://github.com/OpenHands/OpenHands-Cloud/blob/main/e2e_tests/tests/009-budgets.spec.ts that would catch this bug? |
|
I'm on it! aivong-openhands can track my progress at all-hands.dev |
|
No — the current 1. The API layer rejects negative overrides before the buggy code runsThe bug lives in So any request the E2E can legitimately make — a negative or zero override, or a non-positive default — is rejected with a 422 by the Pydantic model before it reaches the code this PR fixes. The E2E's 2. What the override scenario in the spec actually coversThe existing "issue 6" scenario is a positive override with drift repair: const overrideLimit = Math.max(
Math.min(config.userMonthlyLimit / 2, config.monthlyLimit / 2),
0.01, // clamped to be positive
);It then deliberately corrupts the LiteLLM member cap ( Two adjacent assertions are worth noting because they are the closest neighbours to this bug:
3. The bottom lineThe E2E plays the budgets feature from the logged-in UI/API surface with the route-level What it would take to catch it in E2EA test would need to seed a negative override bypassing the route validation — e.g. insert the
That said, the fuller fix mentioned in the PR notes (rejecting the row at |
|
The comment has been answered and posted. Here's a summary of what I did. Answer to the questionThe comment asked whether the E2E test Short answer: No — and a non-positive override would actually break the E2E itself rather than detect anything. My reasoning, verified against the actual files:
No code changes were required — this was a question, and I answered it in the PR thread. |
ak684
left a comment
There was a problem hiding this comment.
Reviewed shared member-cap calculation across synchronization, readback, and preflight. Negative effective allowance can no longer place the native cap below the spend baseline. Focused budget-service/preflight tests: 61 passed, 6 skipped. No blocking findings.
|
🚀 Released in 1.64.0. |
HUMAN:
AGENT:
Why
A negative per-user override locked a member out of the product for the rest of the cycle without their having spent anything. LiteLLM compares cumulative spend against an absolute member cap, so the sync writes
baseline + allowance. Only the route's Pydantic model rejects a non-positivemonthly_limit;upsert_user_overrideand the store take whatever they are handed, and the column has no CHECK constraint. With a negative allowance the cap landed below the member's cycle baseline — a cap already exceeded the moment it was written.The cap is computed in three places from the same inputs:
_sync_litellm_budgetswrites it,_budget_policy_comparisonexpects to read it back when detecting drift, andorg_budget_preflightmirrors the same formula read-only for the upgrade hooks. Clamping only some of them makes them disagree. Leaving the comparison site unclamped would leave the org permanentlydegraded— an HTTP 503 on the budgets routes with no self-service recovery. Leaving the preflight unclamped would have it reportcap_driftatblockingseverity against the exact cap the clamped sync just wrote, failing a strict-mode upgrade of the very org this rescues. This clamps the allowance at zero in a single_member_caphelper that both sites call, so the two can never diverge. No allowance now means no further spend this cycle rather than retroactive debt. The same clamp covers a negativedefault_user_monthly_limit, which reaches the same arithmetic by a different route.Summary
_member_cap(baseline, effective_limit), which clamps the allowance at zero, and call it from all three sites that compute the cap:_sync_litellm_budgets,_budget_policy_comparison, andorg_budget_preflight.evaluate_org.-100override, asserts every written cap is at or above the baseline, and asserts the org still reconcileshealthyafterwards.default_user_monthly_limit, which reaches the same arithmetic without an override, and the clamp's own output of zero, which is falsy and so would fall back to the shared team budget under a truthiness check.Issue Number
N/A
How to Test
Expect
43 passed, 6 skippedand18 passed. Reverting the clamp at any of the three call sites fails a test: at the write sitetest_override_cap_is_never_written_below_the_cycle_baselinesees a cap ofbaseline - 100handed to LiteLLM, at the comparison site the same test finds the org stuck atreconciliation_state == 'degraded', and at the preflight sitetest_preflight_desired_member_cap_is_clamped_like_the_syncseesdesiredof-92.0withcap_driftblocking. Making the clamp conditional on an override, or swappingeffective_limit is not Nonefor a truthiness check, failstest_non_positive_org_default_limit_caps_members_at_their_baseline.Video/Screenshots
N/A — no UI change.
Type
Notes
Clamping is the narrow fix: it stops the bad cap reaching LiteLLM but still stores the nonsense override. Rejecting a non-positive
monthly_limitinupsert_user_overrideand adding a CHECK constraint to the column would stop the row existing at all — with all three cap sites clamped, that constraint is the only thing left that would make the stored value and the enforced value agree. That is the fuller fix and a separate change — it needs a migration and a backfill for any rows that already violate it, and the clamp is still wanted as defence-in-depth for those. The reproduction test deliberately asserts the cap rather than a rejection, so both remain open.One of a set of draft PRs, each carrying a single defect the Quint model for org budgets surfaced, together with the reproduction test that was already committed but skipped.
🤖 Generated with Claude Code
Enterprise server image for this PR: