Skip to content

Implement TUI provider balance lookup#2141

Open
Benchan691 wants to merge 4 commits into
Hmbown:mainfrom
Benchan691:balance-provider-lookup
Open

Implement TUI provider balance lookup#2141
Benchan691 wants to merge 4 commits into
Hmbown:mainfrom
Benchan691:balance-provider-lookup

Conversation

@Benchan691
Copy link
Copy Markdown

@Benchan691 Benchan691 commented May 25, 2026

Summary

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features
  • cargo test --workspace --all-features

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes

Greptile Summary

This PR promotes the /balance TUI command from a stub into a fully functional live lookup for DeepSeek, OpenRouter, and Novita, wiring async HTTP calls through a new BalanceEvent channel in the UI event loop.

  • Adds balance_report() to DeepSeekClient with per-provider fetch/parse logic: DeepSeek uses a dedicated /user/balance endpoint, OpenRouter falls back from /credits to /key on 401/403, and Novita decodes ten-thousandth-USD units from a set of camelCase/snake_case key variants.
  • Refactors send_with_retry into a generic send_with_retry_matching that accepts a caller-supplied status predicate, enabling OpenRouter's graceful 401/403 fallback without bypassing the existing retry and error-surfacing machinery.
  • Promotes AppAction::FetchBalance as a background tokio::spawn in apply_command_result, draining results via drain_balance_events each event-loop tick into the chat history.

Confidence Score: 5/5

Safe to merge; the balance fetch is entirely additive and isolated behind a new async channel with no shared mutable state.

The core HTTP and retry machinery is well-tested (including the OpenRouter 401/403 fallback path and Novita unit conversion), error bodies are surfaced correctly through the existing retry infrastructure, and the TUI wiring follows established patterns. The two findings are edge cases unlikely to occur in normal usage.

crates/tui/src/client.rs (novita_amount_from_keys) and crates/tui/src/tui/ui.rs (AppAction::FetchBalance dispatch) warrant a second look before the next Novita API integration or any future expansion of the balance command.

Important Files Changed

Filename Overview
crates/tui/src/client.rs Core balance logic: adds provider-specific balance fetch/parse functions for DeepSeek, OpenRouter (with 401/403 fallback to key endpoint), and Novita; refactors send_with_retry into send_with_retry_matching; null-value short-circuit in novita_amount_from_keys can skip valid fallback keys
crates/tui/src/tui/ui.rs Wires BalanceEvent async channel into the event loop; FetchBalance spawns a background task without concurrency guard, unlike the inline FetchModels pattern, allowing duplicate results on rapid re-invocation
crates/tui/src/commands/balance.rs Promotes /balance from a scaffold to dispatching AppAction::FetchBalance for supported providers; clean and straightforward change
crates/tui/src/commands/mod.rs Updates balance command test to assert AppAction::FetchBalance dispatch instead of scaffold message; covers all four supported providers
crates/tui/src/tui/app.rs Adds FetchBalance variant to AppAction enum; minimal, safe change

Sequence Diagram

sequenceDiagram
    actor User
    participant TUI as TUI Event Loop
    participant Cmd as commands/balance.rs
    participant UI as ui.rs (apply_command_result)
    participant Fetch as fetch_provider_balance (tokio::spawn)
    participant API as Provider HTTP API

    User->>TUI: /balance
    TUI->>Cmd: execute("/balance", app)
    Cmd-->>TUI: "CommandResult { action: FetchBalance }"
    TUI->>UI: apply_command_result(FetchBalance)
    UI->>Fetch: tokio::spawn(fetch_provider_balance)

    alt DeepSeek / DeepseekCN
        Fetch->>API: GET /user/balance
        API-->>Fetch: 200 JSON
    else OpenRouter credits
        Fetch->>API: GET /credits
        API-->>Fetch: 200 JSON
    else OpenRouter key fallback
        Fetch->>API: GET /credits
        API-->>Fetch: 401/403
        Fetch->>API: GET /key
        API-->>Fetch: 200 JSON
    else Novita
        Fetch->>API: GET /openapi/v1/billing/balance/detail
        API-->>Fetch: 200 JSON
    end

    Fetch->>TUI: balance_tx.send(BalanceEvent::Finished)
    TUI->>UI: drain_balance_events
    UI-->>User: Balance displayed in chat history
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "Address balance lookup review feedback" | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements live account balance and credit status queries for DeepSeek, OpenRouter, and Novita AI providers in the TUI, replacing the previous static scaffold. It introduces new API endpoints, JSON response parsers, and integration tests. The review feedback highlights several critical improvements: first, awaiting the network request directly in the main TUI event loop will freeze the user interface, so it should be spawned in a background task. Second, using unwrap_or_default() on response text silently discards network errors, which should instead be propagated. Third, the Novita parser should validate that at least one balance field is present to avoid silently reporting $0.00 on API errors. Finally, the negative zero formatting logic should be made more robust.

Comment thread crates/tui/src/client.rs Outdated
Comment on lines +1120 to +1153
let available = novita_amount_from_keys(
data,
&[
"available_balance",
"availableBalance",
"available",
"balance",
],
)?
.unwrap_or(0.0);
let cash =
novita_amount_from_keys(data, &["cash_balance", "cashBalance", "cash"])?.unwrap_or(0.0);
let credit_limit =
novita_amount_from_keys(data, &["credit_limit", "creditLimit"])?.unwrap_or(0.0);
let pending_charges = novita_amount_from_keys(
data,
&[
"pending_charge",
"pendingCharge",
"pending_charges",
"pendingCharges",
],
)?
.unwrap_or(0.0);
let outstanding_invoices = novita_amount_from_keys(
data,
&[
"outstanding_invoice",
"outstandingInvoice",
"outstanding_invoices",
"outstandingInvoices",
],
)?
.unwrap_or(0.0);
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.

high

If the Novita API returns an error payload (e.g., {"code": 400, "message": "invalid key"}), none of the expected balance fields will be present. Because each field uses .unwrap_or(0.0), the parser will silently succeed and report a balance of $0.00 for all fields, masking the actual error. We should verify that at least one expected balance field is present in the response, or return an error if none are found.

    let available = novita_amount_from_keys(
        data,
        &[
            "available_balance",
            "availableBalance",
            "available",
            "balance",
        ],
    )?;
    let cash =
        novita_amount_from_keys(data, &["cash_balance", "cashBalance", "cash"])?;
    let credit_limit =
        novita_amount_from_keys(data, &["credit_limit", "creditLimit"])?;
    let pending_charges = novita_amount_from_keys(
        data,
        &[
            "pending_charge",
            "pendingCharge",
            "pending_charges",
            "pendingCharges",
        ],
    )?;
    let outstanding_invoices = novita_amount_from_keys(
        data,
        &[
            "outstanding_invoice",
            "outstandingInvoice",
            "outstanding_invoices",
            "outstandingInvoices",
        ],
    )?;

    if available.is_none()
        && cash.is_none()
        && credit_limit.is_none()
        && pending_charges.is_none()
        && outstanding_invoices.is_none()
    {
        anyhow::bail!("Invalid Novita balance response: no balance fields found");
    }

    let available = available.unwrap_or(0.0);
    let cash = cash.unwrap_or(0.0);
    let credit_limit = credit_limit.unwrap_or(0.0);
    let pending_charges = pending_charges.unwrap_or(0.0);
    let outstanding_invoices = outstanding_invoices.unwrap_or(0.0);

Comment thread crates/tui/src/tui/ui.rs Outdated
}
AppAction::FetchBalance => {
app.status_message = Some("Fetching provider balance...".to_string());
match fetch_provider_balance(config).await {
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.

high

Awaiting fetch_provider_balance(config) directly within the main TUI event loop thread will freeze the entire user interface for up to 20 seconds if the network is slow or the provider's server is unresponsive. During this time, the TUI will not process any user input (such as key presses, mouse clicks, or resize events) and will appear completely hung. To keep the TUI responsive, consider spawning the balance check in a background task using tokio::spawn and sending the result back to the main event loop via a channel, similar to how TranslationEvent or VoiceInputEvent are handled.

Comment thread crates/tui/src/client.rs Outdated
Comment thread crates/tui/src/client.rs Outdated
Comment thread crates/tui/src/client.rs Outdated
Comment on lines +1217 to +1221
if text == "-0" || text == "-0.00" {
"0.00".to_string()
} else {
text
}
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.

medium

The current negative zero check text == "-0" || text == "-0.00" is fragile and inconsistent. For example, if min_decimals is 0, -0.0 will be formatted as "0.00" (due to the hardcoded fallback), whereas 0.0 will be formatted as "0". Additionally, if min_decimals is greater than 2 (e.g., 3), a rounded negative zero like "-0.000" will not be matched and will retain its minus sign. A more robust and consistent approach is to check if the formatted string starts with '-' and contains only '0' and '.' characters, and if so, strip the minus sign.

    if text.starts_with('-') && text.chars().skip(1).all(|c| c == '0' || c == '.') {
        text = text.strip_prefix('-').unwrap().to_string();
    }
    text

Benchan691 and others added 2 commits May 26, 2026 03:24
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Comment thread crates/tui/src/client.rs
Comment thread crates/tui/src/client.rs
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.

1 participant