Implement TUI provider balance lookup#2141
Conversation
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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);| } | ||
| AppAction::FetchBalance => { | ||
| app.status_message = Some("Fetching provider balance...".to_string()); | ||
| match fetch_provider_balance(config).await { |
There was a problem hiding this comment.
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.
| if text == "-0" || text == "-0.00" { | ||
| "0.00".to_string() | ||
| } else { | ||
| text | ||
| } |
There was a problem hiding this comment.
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();
}
textCo-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>
|
Conflicts with recent main changes to client.rs and commands. Please rebase on main and re-open if still needed. Thanks @Benchan691! |
Summary
Testing
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
Greptile Summary
This PR promotes the
/balanceTUI command from a stub into a fully functional live lookup for DeepSeek, OpenRouter, and Novita, wiring async HTTP calls through a newBalanceEventchannel in the UI event loop.balance_report()toDeepSeekClientwith per-provider fetch/parse logic: DeepSeek uses a dedicated/user/balanceendpoint, OpenRouter falls back from/creditsto/keyon 401/403, and Novita decodes ten-thousandth-USD units from a set of camelCase/snake_case key variants.send_with_retryinto a genericsend_with_retry_matchingthat accepts a caller-supplied status predicate, enabling OpenRouter's graceful 401/403 fallback without bypassing the existing retry and error-surfacing machinery.AppAction::FetchBalanceas a backgroundtokio::spawninapply_command_result, draining results viadrain_balance_eventseach 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
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 historyReviews (2): Last reviewed commit: "Address balance lookup review feedback" | Re-trigger Greptile