Skip to content

Fix 1% usage reading as 100% across providers - #198

Merged
tsouth89 merged 1 commit into
mainfrom
fix/percent-scale
Aug 3, 2026
Merged

Fix 1% usage reading as 100% across providers#198
tsouth89 merged 1 commit into
mainfrom
fix/percent-scale

Conversation

@tsouth89

@tsouth89 tsouth89 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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 lone 1 is ambiguous in those scales, and the old per-window <= 1.0 -> *100 rule 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:

  • OpenCode Go - refactored onto the shared helper (behavior unchanged from the local fix).
  • OpenCode - JSON path and scraper both scaled usagePercent per window; a usagePercent: 1 rolling window rendered as 100% used.
  • Qoder - credit windows scaled usedPercent per window.
  • Chutes - quota windows scaled usage_percent per window.
  • Sakana - worst case: it also misread literal text like "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.0 boundary already treats 1 as 1%), NanoGPT (API always reports fractions), Codex/LLMProxy/Copilot (read percents raw, no threshold).

The rule, shared in rust/src/core/percent_scale.rs:

  • a value above 1.0 can only be a percentage -> whole-percent scale;
  • a value strictly between 0 and 1 can only be a fraction -> fraction scale;
  • otherwise every window is 0 or 1, read as whole percents (so 1 = 1%, not 100%).

Each provider feeds only its ambiguous percent-key values into the scale detection and scales only those; used/limit ratios are never rescaled.

Validation

  • cargo test --manifest-path rust/Cargo.toml: 789 passed. (Note: four cost_scanner tests flake under parallel cargo test due to a pre-existing env-var race between credentials_store tests and scan_claude; they pass in isolation and on clean main.)
  • cargo test --manifest-path apps/desktop-tauri/src-tauri/Cargo.toml: 464 passed.
  • cargo fmt --all clean; cargo clippy --all-targets -- -D warnings clean on both manifests.
  • Added regression tests for each affected provider that fail against the old logic.

Summary by CodeRabbit

  • Bug Fixes
    • Corrected quota percentage interpretation across OpenCode Go, OpenCode, Qoder, Chutes, and Sakana.
    • Fractional values such as 0.5 now correctly display as 50%, while values such as 1 display as 1% when appropriate.
    • Preserved accurate handling of literal percentage text, including Sakana’s 1% values.
    • Improved consistency across rolling, weekly, and monthly usage windows.

Copilot AI review requested due to automatic review settings August 3, 2026 04:03
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Quota percentage scaling

Layer / File(s) Summary
Shared percentage-scale utilities
rust/src/core/mod.rs, rust/src/core/percent_scale.rs
The core module now detects fractional encoding from response values and converts values to clamped percentages. Tests cover ambiguous, fractional, and whole-percentage inputs.
OpenCode usage normalization
rust/src/providers/opencodego/mod.rs, rust/src/providers/opencode/mod.rs, rust/src/providers/opencode/scraper.rs
OpenCode parsers resolve one scale across response windows before converting rolling, weekly, and monthly values.
Chutes and Qoder quota parsing
rust/src/providers/chutes/mod.rs, rust/src/providers/qoder/mod.rs
Chutes and Qoder collect percentage values before recursive parsing and use shared conversion. Regression tests cover 1 as 1%.
Sakana source-aware quota parsing
rust/src/providers/sakana/mod.rs, CHANGELOG.md
Sakana applies scaling only to JSON percentage fields and preserves literal values such as 1%. The changelog records the provider fixes.

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
Loading

Suggested reviewers: copilot, finesssee

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: correcting 1% usage values that were interpreted as 100% across providers.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/percent-scale

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) in rust/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.0 heuristics.
  • 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.

Comment on lines +269 to 276
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));
}
@tsouth89
tsouth89 merged commit 4c182ab into main Aug 3, 2026
11 of 12 checks passed
@tsouth89
tsouth89 deleted the fix/percent-scale branch August 3, 2026 04:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f694951 and 1065631.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • rust/src/core/mod.rs
  • rust/src/core/percent_scale.rs
  • rust/src/providers/chutes/mod.rs
  • rust/src/providers/opencode/mod.rs
  • rust/src/providers/opencode/scraper.rs
  • rust/src/providers/opencodego/mod.rs
  • rust/src/providers/qoder/mod.rs
  • rust/src/providers/sakana/mod.rs

Comment on lines +154 to 176
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,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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)
PY

Repository: 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
fi

Repository: 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.

@tsouth89 tsouth89 mentioned this pull request Aug 3, 2026
tsouth89 added a commit that referenced this pull request Aug 3, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants