fix(budgets): stop reporting an unread member spend as zero - #412
aivong-openhands wants to merge 4 commits into
Conversation
The member financial listing swallows every LiteLLM failure that is not a
401/403 into `financial_data = {}`, and each row was then built with
`user_financial.get('spend', 0) or 0`. An admin saw a spend of 0 for every
member with nothing in the response to say the figure had never been observed,
so a proxy outage looked identical to an organization that had spent nothing.
Report the spend as unknown instead: lifetime_spend and current_budget are None
when the read failed, and the page carries spend_status so a caller can tell the
two apart. This is the shape the budget page already uses, where a failed read
returns spend_status 'unavailable' rather than a zero.
test_handles_litellm_failure_gracefully asserted the old behaviour directly; it
now pins the corrected contract.
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.
Note: submitted as a COMMENT review rather than REQUEST_CHANGES because GitHub does not allow requesting changes on a pull request authored by the same account. The verdict below is nonetheless needs rework — please treat the CRITICAL ISSUES as blocking.
Code Review
🟡 Acceptable — the core instinct is right (an unobserved figure must not be presented as an observed zero), the shape mirrors an existing pattern in the codebase, and the change is small. But it fixes the outage case and leaves the more common per-member case producing exactly the fabricated zero it set out to eliminate, and it forks the spend_status vocabulary it claims to reuse.
[CRITICAL ISSUES]
-
[
server/services/org_member_financial_service.py, L143-145] Incomplete Fix:spend_read_failedis only set when the LiteLLM call raises. When the call succeeds but the response has no entry for a member,members_financial.get(user_id_str, {})returns{}anduser_financial.get('spend', 0) or 0still fabricates a0.0. This is not hypothetical —OrgBudgetSettingsResponsecarriesunmapped_spendandunmapped_member_countprecisely because unmapped members are a known, tracked condition on this domain. Worse, this row now ships withspend_status: 'live', so the response actively asserts the fabricated zero is trustworthy. The outage case is the rarer one; the missing-member case is the everyday one. See the inline comment for a concrete diff. -
[
server/routes/org_models.py, L791] Vocabulary Fork:OrgBudgetSettingsResponseat L854 in the same file already declaresspend_status: Literal['live', 'stale', 'unavailable'], andfrontend/src/api/organization-service/organization-service.api.ts:655already types it as that three-value union. Introducing a second, narrower literal for the same concept in the same module is the opposite of "matching the budget page's existing vocabulary" as the description claims. Widen it or hoist a shared alias.
[IMPROVEMENT OPPORTUNITIES]
-
[
server/services/org_member_financial_service.py, L88-92] Missing Field on the Early Return: theif not members:branch constructsOrgMemberFinancialPagewithoutspend_status, so it defaults to'live'— a claim that the spend read succeeded, made on a path that never attempted it. It is harmless today because the page is empty, but the default is what makes it harmless, and defaults that quietly launder an unmade claim are how this class of bug got here in the first place. Prefer makingspend_statusrequired and passing it explicitly at both return sites. -
[
server/routes/orgs.py, L1126-1127] Stale Docstring: the endpoint docstring still enumerates the response as "items ... current_page ... per_page ... next_page_id" with no mention ofspend_status, and describeslifetime_spend/current_budgetwithout noting they can now be null. This is the documented contract for a field whose whole purpose is to be noticed by callers. -
[
server/services/org_member_financial_service.py, L149-153] Comment Placement: the newif spend_read_failed:branch was inserted directly under the pre-existing "For shared team budgets..." comment, which now explains a branch two levels below it. Move the comment onto theelif max_budget is not None:arm it actually describes. The# Without a spend figure the remaining budget is unknown too.comment on L152 restates the condition immediately above it and can go. -
[
server/routes/org_models.py, L775-776, L789-790] Comment Noise: a 3-line field change carries 4 lines of comment explaining intent that the PR description already covers.lifetime_spend: float | Nonenext to aspend_statusfield is self-evident. These comments describe why the change was made, which belongs in the commit message, not the model.
[TESTING GAPS]
-
[
tests/unit/server/services/test_org_member_financial_service.py, L446-454] Escape Hatch Weakens the Pin:try/except Exception: returnmeans the test passes without asserting anything if the service starts raising. Defensible while the contract was undecided; this PR decides it ("The endpoint still degrades rather than raising, which is unchanged"), so the hatch should go. -
No Test for the Missing-Member Case: there is no test covering "LiteLLM responds successfully but omits a member." That gap is what let the critical issue above through. Add one — a successful read with
{'members': {}}and a member row, asserting the spend is not reported as0. -
[PR description] Evidence: the
How to Testsection gives onlypytestinvocations. Per this repo's review bar, test output alone is not evidence. This endpoint is reachable and the behaviour is observable — acurlagainstGET /orgs/{org_id}/members/financial(or the equivalent call through the running server) with LiteLLM unreachable, showinglifetime_spend: nullandspend_status: "unavailable"in the actual response body, would settle it. Please also include the agent conversation URL, since the description notes this work was agent-generated.
[BREAKING CHANGE]
Credit where due: the description flags this honestly rather than burying it. lifetime_spend and current_budget widen from float to float | None on a public API response. I confirmed there is no consumer under frontend/src — the members page (manage-organization-members.tsx) uses the /members and /members/count endpoints, not /members/financial — so nothing in this repo breaks. But an unconsumed endpoint that returns per-member spend almost certainly has a consumer somewhere, and your own note says you have not checked outside this repo. That check should complete before this leaves draft.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Small, well-scoped, well-tested change to a single read path, with all CI green. The risk is not in the code volume but in the contract: two response fields widen to nullable on an endpoint whose external consumers are unverified, and any consumer doing arithmetic or formatting on lifetime_spend will fault on null rather than degrade. That is a TypeError in a downstream dashboard, not a wrong number — arguably a better failure than the one being fixed, but a real one. The incomplete fix also means an admin can still be shown a fabricated zero labelled 'live', which is the same class of defect the PR is closing.
VERDICT:
❌ Needs rework — not because the direction is wrong, but because the fix stops halfway. The missing-member path still fabricates the zero, and now stamps it 'live'.
KEY INSIGHT:
The bug was never "LiteLLM can fail" — it was that {} and 0 were treated as interchangeable with an observed spend of zero; fixing only the exception path leaves the dictionary-miss path producing the identical lie with a fresh 'live' label attesting to it.
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 the requesting user.
…d too
The previous commit reported an unknown spend only when the LiteLLM call raised.
A successful read that simply carries no entry for a member still produced
`user_financial.get('spend', 0) or 0`, so the everyday case — a just-invited
member, a key not yet provisioned, mapping drift — kept fabricating a 0.0 and
now shipped it under spend_status 'live', which attested the figure was
observed. Report those rows as unknown as well; spend_status keeps describing
the read, not the row.
Hoist SpendStatus as a shared alias so the member listing and the budget page
use one vocabulary rather than two incompatible literals for the same concept,
make spend_status required and pass it explicitly on the empty-page return so no
path can default into claiming a read it never made, and document the nullable
fields and the new field on the endpoint.
Drop the try/except in the reproduction test: the contract is now that the
endpoint degrades rather than raises, so an unexpected raise should fail.
Co-authored-by: openhands <openhands@all-hands.dev>
Review feedback verificationPicking this up after 9d0afc5, I re-checked each point from the review against the code on the branch rather than relying on the thread replies. No further code changes were needed — 9d0afc5 already addressed every item, and all five inline threads are resolved. What follows is the verification, not a new change. Critical issues
Improvements
TestingThe Because a passing test proves little on its own, I checked that these tests actually pin the behaviour: reverting the — and the file was restored afterwards (working tree clean at 9d0afc5). Full results: 12 passed / 2 skipped in the service suite (both skips are unrelated pre-existing Quint pins), 616 passed in Pre-existing failures, not caused by this PR: 15 in Two notes for the reviewer
The outstanding item from the review body is its request for the agent conversation URL in the description, which I cannot supply. This verification was performed by an AI agent (OpenHands) on behalf of the requesting user. |
Mutation review of the testsI hand-wrote a small mutant set against the tests this PR touches and ran it against Controls (these must die — and did)
The suite pins the central claim well: Survivors
Survivor 1 — the empty-members page never pins its
|
The empty-organization return hard-codes spend_status='unavailable' but no test asserted it, so flipping it to 'live' left the suite green (mutation survivor). Pin it so an empty org can't silently start claiming a live read it never made. Co-authored-by: openhands <openhands@all-hands.dev>
Mutation review — follow-upValidated both survivors against the code and addressed the actionable one. Survivor 1 — empty-members page never pinned Survivor 2 — dropping the This comment was posted by an AI agent (OpenHands) on behalf of the requesting user. |
HUMAN:
AGENT:
Why
A LiteLLM outage made every member look like they had spent nothing. The member financial listing swallows any failure that is not a 401/403 into
financial_data = {}, and each row was then built withuser_financial.get('spend', 0) or 0, so the page reportedlifetime_spend: 0for everyone with nothing to distinguish that from a genuine zero. An admin reading the page during an outage would see fabricated figures presented as observed fact.The same
{}-means-zero conflation applies on the far more common path where the read succeeds but simply has no entry for a member — a just-invited member, a key not yet provisioned, mapping drift.OrgBudgetSettingsResponsecarriesunmapped_spendandunmapped_member_countprecisely because that condition is known and tracked on this domain. Both paths now report the spend as unknown.The budget page already refuses to fabricate: a failed read there returns
spend_status: 'unavailable'rather than a zero, and there is a test pinning exactly that. This change gives the member listing the same shape:lifetime_spendandcurrent_budgetareNonewhenever LiteLLM reported no spend for that member, and the page carries aspend_statusfield so a caller can tell an unknown figure from a real one.spend_statusdescribes the read, not the row — a row can carry a null spend under'live'when the read simply omitted that member.test_handles_litellm_failure_gracefullyassertedlifetime_spend == 0on a failed read, so it encoded the defect as intended behaviour; it now pins the corrected contract. The endpoint still degrades rather than raising, which is unchanged.Summary
lifetime_spend/current_budgetasNonewhenever LiteLLM reported no spend for a member, whether the read failed or the member was absent from a successful read.spend_statusto the paginated response. It is required and passed explicitly at both return sites, so no path can default into claiming a read it never made.SpendStatus = Literal['live', 'stale', 'unavailable']alias and use it for the member page,OrgBudgetSettingsResponse, andBudgetFinancialSnapshotResult, which each carried their own copy of the literal.spend_statuson the endpoint docstring.try/exceptescape hatch, update the existing failure test, and add coverage for the missing-member case.Issue Number
N/A
How to Test
Reverting the service change makes
test_failed_spend_read_is_not_reported_as_zero_spendandtest_member_absent_from_litellm_response_has_unknown_spendfail.Observed response bodies
Test output alone is not evidence, so the endpoint was exercised end to end: real route, real service, real store, real Postgres (migrated with
alembic upgrade head), and a real HTTP call to a LiteLLM stub. Only the auth dependency was overridden.Scenario 1 — LiteLLM unreachable (nothing listening on the port):
{ "status": 200, "body": { "items": [ { "user_id": "fa152f79-0c41-451d-b2dd-11ef5e7cbcec", "email": "admin@example.com", "lifetime_spend": null, "current_budget": null, "max_budget": null } ], "current_page": 1, "per_page": 10, "next_page_id": null, "spend_status": "unavailable" } }Scenario 2 — LiteLLM answers 200, but the team carries no membership row for the member:
{ "status": 200, "body": { "items": [ { "user_id": "fa152f79-0c41-451d-b2dd-11ef5e7cbcec", "email": "admin@example.com", "lifetime_spend": null, "current_budget": null, "max_budget": null } ], "current_page": 1, "per_page": 10, "next_page_id": null, "spend_status": "live" } }The same two requests against the first commit of this branch returned
"lifetime_spend": nullfor scenario 1 but"lifetime_spend": 0.0, "current_budget": 0.0, "spend_status": "live"for scenario 2 — the fabricated zero the review flagged.Video/Screenshots
N/A — no UI change in this repo.
Type
Notes
This widens two response fields from
floattofloat | Noneand addsspend_status, so any consumer ofGET /orgs/{org_id}/members/financialthat assumes a number needs a look.External consumers, now checked across the OpenHands org (previously only
frontend/src):frontend/src— no consumer. The members page uses/membersand/members/count.OpenHands-Cloude2e_tests/utils/budgets.ts— typeslifetime_spend/current_budgetasnumber, and007-budgets.spec.tscompares them numerically.OpenHands-Cloude2e_tests/tests/008-managed-key-ownership.spec.ts— readslifetime_spendinto a map and runstoBeCloseTo/toBeGreaterThanon it.Both e2e consumers run against a live LiteLLM with provisioned members, so they land on the
'live'-with-a-row path and keep receiving numbers. Their types should still widen tonumber | nullalongside this; they are the only external consumers found.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
Review feedback on this PR was addressed, and this description updated, by an AI agent (OpenHands) on behalf of the requesting user.
Enterprise server image for this PR: