fix(budgets): never write a member cap below their cycle baseline - #415
aivong-openhands wants to merge 2 commits into
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 —
|
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 two places from the same inputs:
_sync_litellm_budgetswrites it, and_budget_policy_comparisonexpects to read it back when detecting drift. Clamping only the write site would make the two disagree, so a negative override would leave the org permanentlydegraded— an HTTP 503 on the budgets routes with no self-service recovery. 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 both_sync_litellm_budgetsand_budget_policy_comparison.-100override, asserts every written cap is at or above the baseline, and asserts the org still reconcileshealthyafterwards.Issue Number
N/A
How to Test
Expect
41 passed, 6 skipped. Reverting the clamp at either call site makestest_override_cap_is_never_written_below_the_cycle_baselinefail: at the write site with a cap ofbaseline - 100handed to LiteLLM, at the comparison site with the org stuck atreconciliation_state == 'degraded'.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, which 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: