Repository navigation
fix: round a credit balance down rather than toward zero - #1343
Merged
Merged
Conversation
available_credits is posted minus reserved, so a workspace whose holds exceed its posted credits reads negative, and rounding toward zero showed less of the hole than was there. Math.floor is identical for every positive balance and conservative for a negative one.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 12 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This was referenced Aug 29, 2026
Closed
sakibsadmanshajib
added a commit
that referenced
this pull request
Aug 29, 2026
#1346) Closes #1345 The chat composer banner and the Settings Usage tab printed the signed in tenant's balance through `formatUsdFromCredits`, which is a port of the console's PRICE formatter. A price rounds to nearest, which is right for a published rate and wrong for a balance: 9,996,364,207 credits rendered as `$10.00`, and the account does not hold ten dollars. ## What changed `formatUsdBalanceFromCredits` is added to `vendor/open-webui/src/lib/hive/credits.ts`, a port of the console function of the same name in `apps/web-console/lib/format/credits.ts`, carrying the rounding that function received in #1343: down, computed in credit space rather than in dollars, never up and never toward zero. Same name and same behaviour on purpose, so the next person reading the two builds finds one rule rather than two. Down rather than toward zero matters here for the same reason it mattered on the console: `available = posted - reserved` has no clamp (`apps/control-plane/internal/ledger/repository.go`), so an account whose outstanding holds exceed its posted credits reads negative, and rounding toward zero would show less of the hole than the account carries. The two balance call sites move onto it, `CreditsBanner.svelte` (remaining) and `SettingsUsage.svelte` (organization credit balance). Today's spend keeps `formatUsdFromCredits`, which is the split the console already makes: spend and price figures go through the price formatter, balances through the balance formatter. The old formatter's docstring said it rendered a balance, which is how this defect got in; it now says what it is for and points at the balance formatter for the other case. ## The boundary disagreement Flooring resolves the second half of the issue rather than moving it. A floored figure can print `$0.50` only when the floored value is exactly 0.50 dollars, which requires at least 500,000,000 credits, which is exactly `LOW_CREDITS_THRESHOLD`. So the number and the badge agree by construction: 499,999,999 now renders `$0.499` beside the `low` state, and the threshold itself renders `$0.50` beside `healthy`. The test pins both sides of that boundary against the threshold constant rather than against a literal, so a future credit unit rescale cannot quietly separate them again. ## Test Added to `vendor/open-webui/src/lib/hive/credits.test.ts`: the two figures from the issue, the boundary pair above, the negative balance case that mirrors the console's guard in `apps/web-console/lib/format/format.test.ts`, zero, non finite, one credit, and a property assertion that the rendered figure never exceeds the real balance across eight magnitudes. `settings-usage-tab.test.ts` moves its balance assertions onto the balance formatter, and its fixture balance changes from 12,500,000,000 to 12,496,364,207, a value that renders `$12.49` through the balance formatter and `$12.50` through the price formatter. Before that change the two formatters produced identical strings for the fixture, so the rendered assertion would have passed whichever one the component called. Its empty balance case now expects `$0.00` rather than `$0`, which is what the balance formatter renders for an exact zero and what the console prints for the same account; the assertion's point, that an empty balance is a readable dollar figure beside the badge rather than a bare zero, is unchanged. Runs, all through `sh scripts/test-owui-hive-frontend.sh` (Docker, pinned node and the svelte version resolved from the vendored lockfile): | Rounding in the new function | Result | | --- | --- | | `Math.round`, the ported price behaviour | 5 failed, 224 passed | | `Math.floor`, this PR | 229 passed, 16 files, 14/14 components compiled | | `Math.trunc`, the bug #1343 fixed on the console | 1 failed (the negative balance case), 228 passed | The first row is the RED that came before the fix, the third is the mutation check afterwards. Both were run, not reasoned about. ## Buglog entry ```json {"date":"2026-08-29","area":"chat","error_message":"The chat composer credit banner displayed more money than the account held, and its text disagreed with its own low-credit badge at the threshold","root_cause":"vendor/open-webui/src/lib/hive/credits.ts rendered the balance with formatUsdFromCredits, a port of the console PRICE formatter, which rounds to nearest; 9,996,364,207 credits rendered as $10.00 and 499,999,999 rendered as the $0.50 threshold while creditState called the same balance low","fix":"Port the console balance formatter as formatUsdBalanceFromCredits, rounding down in credit space, and move the two balance call sites onto it while today's spend keeps the price formatter","tags":["chat","open-webui","billing","credits","issue-1345"]} ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved credit balance displays to round down, ensuring balances never appear higher than the actual available amount. * Updated usage and settings views to consistently show balances with two decimal places, including `$0.00` for empty balances. * Preserved nearest-cent rounding for usage and spending figures. * **Tests** * Added coverage for rounding, zero, negative, invalid, and low-credit balance scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
sakibsadmanshajib
added a commit
that referenced
this pull request
Aug 29, 2026
## What this fixes Two defects found in a design review of the deployed box at SHA `37d49fcf8`. ### 1. `$0.000000858` printed as money in permanent chrome A balance of 858 credits rendered as `$0.000000858` in the chat credit banner and again in Settings, Usage. Nine significant figures is the exact width of one credit. That precision is right for a published per-million catalog rate, where a real rate must never round to a `$0.00` that reads as free, and it is wrong for a wallet: it carries nothing a reader can act on, and it sat in chrome the customer cannot dismiss. Balances and spend now read in cents. An amount below one cent reads as the bound `< $0.01`. That keeps the honesty invariant the nine decimals existed for: a real, non-zero figure still never renders as the string an empty wallet renders (`$0.00` for a balance, `$0` for spend). The floor property established by PR #1343 and PR #1346 is unchanged and re-pinned by a new property test: a displayed balance is never above the real one, at any magnitude. Flooring is now done in whole cents rather than in dollars, for the same float reason the previous code flooried in credit steps, and it still moves away from zero for a negative balance, so an account driven overdrawn by reservations overstates its hole rather than flattering it. ### 2. The formatter existed in three copies and had already drifted twice The digit cap `Math.min(9, Math.max(2, 2 - magnitude))` lived at three sites: `apps/web-console/lib/format/credits.ts:175`, `vendor/open-webui/src/lib/hive/credits.ts:70`, and the same file at line 116. All three are changed together. The console's fourth copy, `apps/web-console/lib/format/model-pricing.ts:44`, is deliberately left alone: that one formats a published catalog rate, where nine decimals is the point. `formatUsdBalanceFromCredits` is duplicated across two builds that cannot share a module, and it has drifted twice already, as #1344 and #1345, each time with both test suites green. Neither suite can catch it: the console image copies `apps/web-console` and not `vendor/`, and the chat image copies `vendor/open-webui` and not `apps/`. `tools/lint-credit-balance-formatter-parity.mjs` is the first place both files are read at once. It compares the shared declarations (`CREDITS_PER_USD`, `SUB_CENT_BALANCE`, `formatUsdBalanceFromCredits`) with comments, indentation, quote style and trailing commas normalised away, so each build keeps its own formatter and commentary and nothing else. It runs as its own required CI step next to the other `tools/lint-*.mjs` checks. ### 3. Chat composer collided at 375px Measured on the deployed box: `Cowork` occupied x124 to x199 and `Hive Auto` occupied x145 to x272. Fifty-four pixels of overlap, with the two labels printing on top of each other, leaving Cowork unreadable and unreliably tappable on a phone. The composer's left group was `flex-1 min-w-0`, so it shrank to whatever the model chip and send controls left it. Its own children do not shrink: the plus button is `shrink-0` and `.hv-mode` is `flex-shrink: 0` in `hive.css`. Shrinking the group therefore moved its content out of the box rather than making it narrower, and the overflow painted over the model chip. The row now wraps. Below `sm` the left group takes the whole line and the model chip and send controls wrap under it; from `sm` up the flex basis returns to 0 and the layout is exactly what it was, with `flex-wrap` left in place so any future overflow lands on a second line instead of on top of the chip. Wrapping rather than shrinking, because nothing here can shrink honestly: truncating a two-word mode label to "Cow..." is a broken control, not a smaller one, and the labels are translated, so no width is safe to assume. ## Both build systems are exercised Item 1 touches both. The console side is `apps/web-console/lib/format/credits.ts`, verified with `docker compose run --build web-console npm run test:unit` (903 tests pass) and `npm run build`. The chat side is `vendor/open-webui/src/lib/hive/credits.ts`, verified with `scripts/test-owui-hive-frontend.sh` (234 tests pass, 14 of 14 components compile). Both files touched here are inside that script's scope: `src/lib/hive` is copied recursively, and `chat/MessageInput.svelte` is on its explicit component list, so the guard added to `composer-size-guard.test.ts` reads the file the deploy actually builds. One pre-existing failure is unrelated and not introduced here: `apps/web-console/tests/unit/ci-web-e2e-secret-free.test.ts` reads `.github/workflows/ci.yml`, which `Dockerfile.web-console` does not copy into the image, so it cannot pass in that container regardless of this change. ## Tests that can fail Each was mutated and confirmed RED before being trusted. - Parity lint: changing `Math.floor` to `Math.round` in the chat copy alone exits 1 and names the divergence. Reverted, exits 0. - Console money tests: changing the load-bearing `CREDITS_PER_USD / 100` step turns 7 of 28 assertions red, including the floor property and both sub-cent cases. - Composer guard: reverting the left group's class list to `flex-1 min-w-0` turns the guard red. Worth recording, because it is the shape of a test that cannot fail: mutating `maximumFractionDigits` from 2 to 9 alone changes nothing, because the flooring to whole cents happens before the number reaches `Intl.NumberFormat`. The digit cap is a belt-and-braces guard, not the mechanism, and the tests are pinned to the mechanism. ## Buglog entry ```json {"id":"bug-1349-credit-precision-composer-overlap","date":"2026-08-29","title":"Balance rendered nine significant figures and the composer toolbar overlapped at 375px","error_message":"$0.000000858 shown as a credit balance in the chat banner and Settings > Usage; Cowork (x124-199) and Hive Auto (x145-272) drawn on top of each other at 375px","root_cause":"Two unrelated causes. (1) formatUsdBalanceFromCredits and the chat spend formatter both scaled decimal places to the value with Math.min(9, Math.max(2, 2 - magnitude)), a rule ported from the catalog PRICE formatter where nine decimals is correct, so a sub-cent balance rendered at the full width of one credit in chrome the customer cannot dismiss. The rule was hand-duplicated across two builds whose test suites cannot see each other, which is also how #1344 and #1345 happened. (2) The composer control row's left group was flex-1 min-w-0 while its children were shrink-0 and .hv-mode was flex-shrink: 0, so shrinking the group pushed its content out of the box and over the model chip.","fix":"Format balances and spend in whole cents, floored for balances and rounded for spend, with a sub-cent bound of \"< $0.01\" so a real non-zero figure still never renders as the empty-wallet string. Added tools/lint-credit-balance-formatter-parity.mjs, wired as a CI step, to fail the build when the two copies of formatUsdBalanceFromCredits diverge. Made the composer control row wrap, with the left group taking a full line below sm and the previous basis restored from sm up.","tags":["money","formatting","frontend","open-webui","web-console","layout","responsive","cross-build-duplication"]} ```
4 of 5 tasks
sakibsadmanshajib
added a commit
that referenced
this pull request
Aug 29, 2026
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1344
Follow-up to #1336, which merged while this last fix was still in flight.
available_creditsis posted minus reserved, so a workspace whose holds exceed its posted credits reads negative.formatUsdBalanceFromCreditsrounded withMath.trunc, which rounds toward zero, so an overdrawn balance displayed less of the hole than was actually there.Math.flooris identical for every positive balance, which is every balance the demo box has today, and conservative for a negative one.The docstring said "truncated toward zero"; it now states the direction it actually rounds, so the comment cannot drift from the code again.
Test
One case, in
lib/format/format.test.ts:-8,295,000,000credits renders-$8.30, not-$8.29. It fails onMath.truncand passes onMath.floor, which was checked by reverting.npx vitest run lib/format/format.test.ts tests/unit/credit-balance.test.tsxin Docker with--buildon a private image tag: 30 passed.No visual change on any surface that exists today, since every live balance is positive and
Math.floorandMath.truncagree there. The proof captures on #1336 stay accurate.Buglog entry
{"date":"2026-08-29","area":"web-console","error_message":"A negative credit balance displayed less debt than the account carried","root_cause":"formatUsdBalanceFromCredits rounded with Math.trunc, which rounds toward zero, so an available balance driven negative by reservations was flattered by one displayed unit","fix":"Round with Math.floor, identical for positive balances and conservative for negative ones, and state the direction in the docstring","tags":["web-console","billing","credits","issue-1332"]}