Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 110 additions & 22 deletions rust/src/providers/codex/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,15 @@ impl CodexApi {

// Extract credits if present
let cost = self.extract_credits(json);
if let Some(balance) = Self::credit_balance(json) {
// `balance` is credit remaining, not money spent, so it gets its own
// info-only line rather than being passed off as cost.
usage = usage.with_extra_rate_window(
"codex-credit-balance",
"Credits",
RateWindow::with_details(0.0, None, None, Some(format!("${balance:.2} left"))),
);
}

Ok((usage, cost))
}
Expand Down Expand Up @@ -491,33 +500,45 @@ impl CodexApi {
))
}

fn extract_credits(&self, json: &serde_json::Value) -> Option<CostSnapshot> {
/// The remaining credit balance, when the account has metered credits.
///
/// This is what is *left*, never what was spent. Callers must not put it in
/// a `CostSnapshot`'s `used` slot.
fn credit_balance(json: &serde_json::Value) -> Option<f64> {
let credits = json.get("credits")?;

let has_credits = credits
if !credits
.get("has_credits")
.and_then(|v| v.as_bool())
.unwrap_or(false);

if !has_credits {
.unwrap_or(false)
{
return None;
}

let unlimited = credits
if credits
.get("unlimited")
.and_then(|v| v.as_bool())
.unwrap_or(false);

if unlimited {
.unwrap_or(false)
{
return None;
}
credits.get("balance").and_then(|v| v.as_f64())
}

let balance = credits
.get("balance")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);

Some(CostSnapshot::new(balance, "USD", "Credits"))
/// Money actually spent against a credit allowance.
///
/// Spend is only knowable when the account also reports a spend-control
/// limit: `used = limit - balance`. A balance on its own says how much is
/// left, so reporting it as `used` inverts the number — it would fall as
/// the user spends and read $0 exactly when the account is exhausted. The
/// balance is surfaced separately as its own line instead.
fn extract_credits(&self, json: &serde_json::Value) -> Option<CostSnapshot> {
let balance = Self::credit_balance(json)?;
let limit = json.get("individual_limit").or_else(|| {
json.get("rate_limit")
.and_then(|r| r.get("individual_limit"))
})?;
serde_json::from_value::<SpendControlLimitSnapshot>(limit.clone())
.ok()?
.to_cost_snapshot(balance)
}

fn build_result(
Expand Down Expand Up @@ -596,12 +617,10 @@ impl CodexApi {
let balance = credits.balance.unwrap_or(0.0);
if credits.unlimited() {
None // Unlimited credits, no need to show
} else if let Some(limit) =
credit_limit.and_then(|limit| limit.to_cost_snapshot(balance))
{
Some(limit)
} else {
Some(CostSnapshot::new(balance, "USD", "Credits"))
// Without a limit there is no way to know what was spent;
// `balance` is what remains. Never report it as `used`.
credit_limit.and_then(|limit| limit.to_cost_snapshot(balance))
}
} else {
None
Expand Down Expand Up @@ -1218,6 +1237,75 @@ mod tests {
assert!(!lifted);
}

#[test]
fn credit_balance_is_never_reported_as_money_spent() {
// `balance` is what is LEFT. Reporting it as `used` inverted the
// number: it fell as the user spent and read $0 at exhaustion.
let api = CodexApi::new();
let (usage, cost) = api
.build_result_from_json(&json!({
"rate_limit": {
"primary_window": { "used_percent": 10, "limit_window_seconds": 18000 }
},
"credits": { "has_credits": true, "unlimited": false, "balance": 10.0 }
}))
.expect("codex usage");

// No spend-control limit, so spend is unknowable and none is claimed.
assert!(cost.is_none(), "a balance alone is not spend");
// The balance is still shown, as its own line.
let credits = usage
.extra_rate_windows
.iter()
.find(|w| w.id == "codex-credit-balance")
.expect("credit balance line");
assert_eq!(credits.title, "Credits");
assert_eq!(
credits.window.reset_description.as_deref(),
Some("$10.00 left")
);
}

#[test]
fn credit_spend_is_derived_from_the_spend_control_limit() {
// With a limit, real spend is limit - balance: $40 of $50.
let api = CodexApi::new();
let (_, cost) = api
.build_result_from_json(&json!({
"rate_limit": {
"primary_window": { "used_percent": 10, "limit_window_seconds": 18000 }
},
"credits": { "has_credits": true, "unlimited": false, "balance": 10.0 },
"individual_limit": { "limit": 50.0 }
}))
.expect("codex usage");

let cost = cost.expect("cost from spend-control limit");
assert!((cost.used - 40.0).abs() < 0.01, "used = limit - balance");
assert_eq!(cost.limit, Some(50.0));
}

#[test]
fn unlimited_credits_report_neither_spend_nor_balance() {
let api = CodexApi::new();
let (usage, cost) = api
.build_result_from_json(&json!({
"rate_limit": {
"primary_window": { "used_percent": 10, "limit_window_seconds": 18000 }
},
"credits": { "has_credits": true, "unlimited": true, "balance": 10.0 }
}))
.expect("codex usage");

assert!(cost.is_none());
assert!(
!usage
.extra_rate_windows
.iter()
.any(|w| w.id == "codex-credit-balance")
);
}

#[test]
fn ignores_placeholder_additional_rate_limits() {
let api = CodexApi::new();
Expand Down
48 changes: 38 additions & 10 deletions rust/src/providers/opencodego/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use reqwest::Client;
use uuid::Uuid;

use crate::core::{
CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId,
ProviderMetadata, RateWindow, SourceMode, UsageSnapshot,
FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata,
RateWindow, SourceMode, UsageSnapshot,
};

const BASE_URL: &str = "https://opencode.ai";
Expand Down Expand Up @@ -344,20 +344,27 @@ impl OpenCodeGoProvider {
None => self.fetch_workspace_id(cookie_header).await?,
};
let page = self.fetch_usage_page(&workspace_id, cookie_header).await?;
let mut usage = Self::parse_usage_text(&page)?;
let balance = Self::parse_zen_balance(&page);
if let Some(balance) = balance {
Self::result_from_page(&page)
}

/// Build the snapshot from an already-fetched usage page.
///
/// Split out from the fetch so the balance handling is testable without a
/// network round trip.
fn result_from_page(page: &str) -> Result<ProviderFetchResult, ProviderError> {
let mut usage = Self::parse_usage_text(page)?;
if let Some(balance) = Self::parse_zen_balance(page) {
usage = usage.with_extra_rate_window(
"zen-balance",
"Zen balance",
RateWindow::with_details(0.0, None, None, Some(format!("${balance:.2}"))),
);
}
let mut result = ProviderFetchResult::new(usage, "web");
if let Some(balance) = balance {
result = result.with_cost(CostSnapshot::new(balance, "USD", "Zen balance"));
}
Ok(result)
// The Zen balance is credit *remaining*, so it is shown above as an
// info-only line and deliberately not reported as cost: putting it in
// `CostSnapshot.used` would make spend fall as the user spends, and
// read $0 at exactly the moment the balance runs out.
Ok(ProviderFetchResult::new(usage, "web"))
}
}

Expand Down Expand Up @@ -425,6 +432,27 @@ impl Provider for OpenCodeGoProvider {
mod tests {
use super::*;

#[test]
fn zen_balance_is_shown_as_remaining_not_as_spend() {
Comment thread
tsouth89 marked this conversation as resolved.
// `currentBalance` is credit left. It used to be reported as
// CostSnapshot.used, which inverted it: spend fell as the user spent
// and read $0 exactly when the balance ran out.
// Shaped like the real page: unquoted JS keys for the windows, and the
// balance rendered as display text.
let page = r#"{rollingUsage: {usedPercent: 40, resetInSec: 3600}} Current balance $12.50"#;
let result = OpenCodeGoProvider::result_from_page(page).expect("usage");

assert!(result.cost.is_none(), "a balance is not money spent");
let zen = result
.usage
.extra_rate_windows
.iter()
.find(|w| w.id == "zen-balance")
.expect("zen balance line");
assert_eq!(zen.window.reset_description.as_deref(), Some("$12.50"));
assert_eq!(zen.window.used_percent, 0.0, "info-only, not a meter");
}

#[test]
fn parses_workspace_ids() {
let text = r#"{ id: "wrk_abc123", name: "x" } { id: "wrk_def456" }"#;
Expand Down
Loading