Fix 1% usage reading as 100% across providers - #198
Conversation
📝 WalkthroughWalkthroughChangesQuota percentage scaling
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Sakana quota extraction
participant detect_fraction_scale
participant to_percent
participant RateWindow
Sakana quota extraction->>detect_fraction_scale: raw JSON percentage values
detect_fraction_scale-->>Sakana quota extraction: fraction scale decision
Sakana quota extraction->>to_percent: raw value and scale decision
to_percent-->>Sakana quota extraction: clamped percentage
Sakana quota extraction->>RateWindow: normalized usage window
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
ceiling | 1065631 | Commit Preview URL Branch Preview URL |
Aug 03 2026, 04:04 AM |
There was a problem hiding this comment.
Pull request overview
This PR fixes a cross-provider regression where a reported usage value of 1 (meaning 1% on whole-percent APIs) was being interpreted as a fraction and displayed as 100%, by resolving percent scale once per response and normalizing values consistently.
Changes:
- Introduces shared percent-scale detection/conversion helpers (
detect_fraction_scale,to_percent) inrust/src/core. - Updates multiple providers (OpenCode, OpenCode Go, OpenCode scraper, Qoder, Chutes, Sakana) to normalize percent values using response-wide evidence rather than per-window
<= 1.0heuristics. - Adds regression tests for the “1% reads as 100%” case and documents the fixes in the changelog.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| rust/src/providers/sakana/mod.rs | Distinguishes literal % text from JSON percent keys; applies evidence-based scaling only to ambiguous JSON values. |
| rust/src/providers/qoder/mod.rs | Detects percent scale from payload and applies consistent normalization when building credit windows. |
| rust/src/providers/opencodego/mod.rs | Resolves percent scale across windows before converting rolling/weekly/monthly usage values; adds regression tests. |
| rust/src/providers/opencode/scraper.rs | Normalizes rolling/weekly usage by detecting scale from both windows before parsing. |
| rust/src/providers/opencode/mod.rs | Normalizes rolling/weekly window percents using response-wide scale detection and percent-key provenance. |
| rust/src/providers/chutes/mod.rs | Detects scale from payload percent fields and normalizes quota windows consistently; adds regression test. |
| rust/src/core/percent_scale.rs | Adds shared, evidence-based percent scale detection and conversion helpers with unit tests. |
| rust/src/core/mod.rs | Exposes the new percent_scale module via the core public re-exports. |
| CHANGELOG.md | Documents the regression and the provider-specific fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn find_usage_window(&self, json: &Value, keys: &[&str]) -> Option<(f64, i64, bool)> { | ||
| for key in keys { | ||
| if let Some(obj) = json.get(key) | ||
| && let Some(window) = self.parse_window(obj) | ||
| && let Some((percent, from_percent_key)) = Self::window_percent(obj) | ||
| { | ||
| return Some(window); | ||
| let reset_sec = Self::window_reset_seconds(obj).unwrap_or(0); | ||
| return Some((percent, reset_sec, from_percent_key)); | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@rust/src/providers/sakana/mod.rs`:
- Around line 154-176: Update extract_raw_window so the calculated end index is
advanced to the next valid UTF-8 character boundary after applying the 1400-byte
limit and text length cap, before slicing text[anchor.0..end]. Preserve the
existing anchor and extraction behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c4e5b3f6-2092-4954-9bd3-4488f968f291
📒 Files selected for processing (9)
CHANGELOG.mdrust/src/core/mod.rsrust/src/core/percent_scale.rsrust/src/providers/chutes/mod.rsrust/src/providers/opencode/mod.rsrust/src/providers/opencode/scraper.rsrust/src/providers/opencodego/mod.rsrust/src/providers/qoder/mod.rsrust/src/providers/sakana/mod.rs
| fn extract_raw_window(text: &str, labels: &[&str]) -> Option<RawWindow> { | ||
| let lower = text.to_ascii_lowercase(); | ||
| let anchor = labels | ||
| .iter() | ||
| .find_map(|label| lower.find(label).map(|idx| (idx, *label)))?; | ||
| let end = (anchor.0 + 1400).min(text.len()); | ||
| let segment = &text[anchor.0..end]; | ||
| let percent = extract_percent(segment)?; | ||
| let (percent, from_json) = extract_percent(segment)?; | ||
| let reset = extract_reset(segment); | ||
| let mut window = RateWindow::with_details( | ||
| let reset_description = extract_reset_text(segment); | ||
| let minutes = if anchor.1.contains("week") { | ||
| Some(7 * 24 * 60) | ||
| } else { | ||
| Some(5 * 60) | ||
| }; | ||
| Some(RawWindow { | ||
| percent, | ||
| if anchor.1.contains("week") { | ||
| Some(7 * 24 * 60) | ||
| } else { | ||
| Some(5 * 60) | ||
| }, | ||
| from_json, | ||
| reset, | ||
| None, | ||
| ); | ||
| if let Some(reset_text) = extract_reset_text(segment) { | ||
| window.reset_description = Some(reset_text); | ||
| } | ||
| Some(window) | ||
| reset_description, | ||
| minutes, | ||
| }) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- target source ---'
sed -n '1,340p' rust/src/providers/sakana/mod.rs
printf '%s\n' '--- toolchain declarations ---'
fd -a -t f '^(rust-toolchain|rust-toolchain\.toml|Cargo\.toml)$' . -x sh -c 'echo "--- $1"; sed -n "1,120p" "$1"' sh {}
printf '%s\n' '--- relevant usages ---'
rg -n -C 3 'extract_raw_window|snapshot_from_html|is_char_boundary|floor_char_boundary|ceil_char_boundary' rust/src rust/tests 2>/dev/null || true
printf '%s\n' '--- standalone UTF-8 boundary probe ---'
python3 - <<'PY'
text = "reset — usage"
start = text.index("reset")
end = min(start + 8, len(text.encode("utf-8")))
raw = text.encode("utf-8")
print("text:", repr(text))
print("byte length:", len(raw), "computed end:", end)
print("byte at end:", raw[end:end+1].hex())
print("is UTF-8 boundary:", end == 0 or end == len(raw) or (raw[end] & 0xc0) != 0x80)
PYRepository: tsouth89/ceiling
Length of output: 17695
🏁 Script executed:
#!/bin/bash
set -eu
sed -n '145,185p' rust/src/providers/sakana/mod.rs
fd -a -t f '^(rust-toolchain|rust-toolchain\.toml|Cargo\.toml)$' . -x sh -c 'echo "--- $1"; sed -n "1,80p" "$1"' sh {}Repository: tsouth89/ceiling
Length of output: 4523
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- available Rust toolchain ---'
if command -v rustc >/dev/null 2>&1; then
rustc --version
else
echo 'rustc unavailable'
fi
printf '%s\n' '--- standalone Rust slicing probe ---'
if command -v rustc >/dev/null 2>&1; then
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
cat >"$tmp/probe.rs" <<'RS'
fn main() {
let text = format!("5-hour quota 1% {}", "x".repeat(1370)) + "—";
let anchor = text.find("5-hour").unwrap();
let end = (anchor + 1400).min(text.len());
assert!(!text.is_char_boundary(end), "probe must end inside UTF-8");
let _segment = &text[anchor..end];
}
RS
rustc "$tmp/probe.rs" -o "$tmp/probe"
if "$tmp/probe"; then
echo 'unexpected: probe did not panic'
exit 1
else
echo 'expected: slicing at the computed non-boundary panicked'
fi
fiRepository: tsouth89/ceiling
Length of output: 243
🏁 Script executed:
#!/bin/bash
set -eu
command -v rustc >/dev/null 2>&1 && rustc --version || echo 'rustc unavailable'Repository: tsouth89/ceiling
Length of output: 172
Fix the UTF-8 boundary before slicing.
If end is not a character boundary, advance it to the next boundary before slicing. Otherwise, multi-byte HTML content can cause a runtime panic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@rust/src/providers/sakana/mod.rs` around lines 154 - 176, Update
extract_raw_window so the calculated end index is advanced to the next valid
UTF-8 character boundary after applying the 1400-byte limit and text length cap,
before slicing text[anchor.0..end]. Preserve the existing anchor and extraction
behavior.
## Summary Prepares **Ceiling 1.5.22**, carrying three changes merged since v1.5.21: - **#198** - Fix 1% usage reading as 100% across providers (OpenCode Go, OpenCode, Qoder, Chutes, Sakana) - **#197** - OpenCode Go monthly bar labeled "Monthly" instead of "Extra" - **#196** - Fix Microsoft Store installer parameters (40-char Partner Center limit) Bumps all version sources to 1.5.22 (build 124), moves the Unreleased CHANGELOG section into a versioned 1.5.22 entry (adding the missing #196/#197 notes), and adds `.github/release-notes-1.5.22.md`. ## Changes - `CHANGELOG.md` - retitle Unreleased to `[Ceiling] 1.5.22 - 2026-08-03`, add #196/#197 entries - `version.env` - `MARKETING_VERSION=1.5.22`, `BUILD_NUMBER=124` - `rust/Cargo.toml`, `apps/desktop-tauri/src-tauri/Cargo.toml`, `apps/desktop-tauri/package.json`, `apps/desktop-tauri/src-tauri/tauri.conf.json`, `Cargo.lock` - version 1.5.22 - `.github/release-notes-1.5.22.md` - new release notes ## Validation `powershell.exe -ExecutionPolicy Bypass -NoProfile -File scripts\local-check.ps1 -All -Version 1.5.22` passes, including the release doctor for 1.5.22 (all version sources consistent, CHANGELOG mentions 1.5.22) and the Store submission preparation test. Expected pre-tag warnings only: local tag v1.5.22, local release assets, and GitHub release not found yet. After this merges and checks are green, push annotated tag `v1.5.22` to trigger the `Signed Windows Release` workflow. Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
Summary
Fixes the 1%-reads-as-100% usage bug for providers that report usage as either whole percentages (
23= 23%) or fractions of a limit (0.23= 23%). A lone1is ambiguous in those scales, and the old per-window<= 1.0 -> *100rule turned the first 1% of use into a maxed-out window.This is the same ambiguity already fixed for Claude in #186 and OpenCode Go (this branch's sibling fix). This change resolves the scale once per response from real evidence, and only reads fractions when a window actually holds a fractional value.
Behavior changes
Affected providers, all with the same bug:
usagePercentper window; ausagePercent: 1rolling window rendered as 100% used.usedPercentper window.usage_percentper window."1% used"as 100%. Literal%values are never scaled now; only JSON percent keys use the evidence-based scale.Checked and intentionally left alone: Devin (
< 1.0boundary already treats1as 1%), NanoGPT (API always reports fractions), Codex/LLMProxy/Copilot (read percents raw, no threshold).The rule, shared in
rust/src/core/percent_scale.rs:1.0can only be a percentage -> whole-percent scale;0and1can only be a fraction -> fraction scale;0or1, read as whole percents (so1= 1%, not 100%).Each provider feeds only its ambiguous percent-key values into the scale detection and scales only those;
used/limitratios are never rescaled.Validation
cargo test --manifest-path rust/Cargo.toml: 789 passed. (Note: fourcost_scannertests flake under parallelcargo testdue to a pre-existing env-var race betweencredentials_storetests andscan_claude; they pass in isolation and on cleanmain.)cargo test --manifest-path apps/desktop-tauri/src-tauri/Cargo.toml: 464 passed.cargo fmt --allclean;cargo clippy --all-targets -- -D warningsclean on both manifests.Summary by CodeRabbit
0.5now correctly display as 50%, while values such as1display as 1% when appropriate.1%values.