diff --git a/rust/src/cli/serve/dashboard/antigravity.rs b/rust/src/cli/serve/dashboard/antigravity.rs index 6b2bb2cdf40..0d0e0ba0508 100644 --- a/rust/src/cli/serve/dashboard/antigravity.rs +++ b/rust/src/cli/serve/dashboard/antigravity.rs @@ -41,12 +41,14 @@ pub(super) fn quota_summary_windows( )); } windows.extend(usage.extra_rate_windows.iter().map(|extra| { - make_window_with_idle( + let mut window = make_window_with_idle( &extra.id, &extra.title, &extra.window, idle_ids.contains(&extra.id), - ) + ); + window.unavailable |= !extra.usage_known; + window })); windows } diff --git a/rust/src/cli/serve/dashboard/dashboard.html b/rust/src/cli/serve/dashboard/dashboard.html index 72507fe776a..709448dc518 100644 --- a/rust/src/cli/serve/dashboard/dashboard.html +++ b/rust/src/cli/serve/dashboard/dashboard.html @@ -129,7 +129,7 @@

CodexBar

return "bar"; } function visibleWindows(windows) { - return (windows || []).filter(w => w.idle !== true); + return (windows || []).filter(w => w.idle !== true && w.unavailable !== true); } function windowRow(w) { const used = Number.isFinite(w.usedPercent) ? Math.max(0, Math.min(100, w.usedPercent)) : 0; diff --git a/rust/src/cli/serve/dashboard/html.rs b/rust/src/cli/serve/dashboard/html.rs index 37aeb5dc372..57aca40ce42 100644 --- a/rust/src/cli/serve/dashboard/html.rs +++ b/rust/src/cli/serve/dashboard/html.rs @@ -58,7 +58,7 @@ mod tests { fn web_ui_skips_windows_marked_idle() { let html = render_shell(60); assert!(html.contains("function visibleWindows(windows)")); - assert!(html.contains("w.idle !== true")); + assert!(html.contains("w.idle !== true && w.unavailable !== true")); assert!(html.contains("visibleWindows(account.windows).forEach")); assert!(html.contains("visibleWindows(row.windows).forEach")); } diff --git a/rust/src/cli/serve/dashboard/snapshot_tests.rs b/rust/src/cli/serve/dashboard/snapshot_tests.rs index a4a8265c6d0..c72bfd2c8f5 100644 --- a/rust/src/cli/serve/dashboard/snapshot_tests.rs +++ b/rust/src/cli/serve/dashboard/snapshot_tests.rs @@ -317,6 +317,49 @@ mod tests { assert!(windows.iter().all(|window| window.get("idle").is_none())); } + #[test] + fn antigravity_dashboard_marks_unknown_lane_unavailable() { + let windows = vec![ + NamedRateWindow::new("model-gemini", "Gemini Pro", RateWindow::new(20.0)), + NamedRateWindow::new("model-claude", "Claude Sonnet", RateWindow::new(0.0)) + .with_usage_known(false), + NamedRateWindow::new("model-gpt", "GPT", RateWindow::new(0.0)), + ]; + let json = serde_json::to_value(build_snapshot(&input( + vec![antigravity_envelope(windows)], + DashboardIdentity::Redacted, + ))) + .unwrap(); + let windows = json["providers"][0]["windows"].as_array().unwrap(); + let unavailable: Vec<&str> = windows + .iter() + .filter(|window| window["unavailable"] == true) + .map(|window| window["label"].as_str().unwrap()) + .collect(); + assert_eq!(unavailable, vec!["Claude Sonnet"]); + } + + #[test] + fn informational_window_is_unavailable_and_a_real_zero_is_not() { + let usage = UsageSnapshot::new(RateWindow::informational("No primary quota")) + .with_secondary(RateWindow::new(0.0)); + let json = serde_json::to_value(build_snapshot(&input( + vec![ProviderFetchEnvelope { + id: "windsurf".to_string(), + display_name: "Windsurf".to_string(), + session_label: "Daily".to_string(), + weekly_label: "Weekly".to_string(), + fetch: Ok(ProviderFetchResult::new(usage, "local")), + }], + DashboardIdentity::Redacted, + ))) + .unwrap(); + let windows = json["providers"][0]["windows"].as_array().unwrap(); + assert_eq!(windows[0]["unavailable"], true); + assert!(windows[1].get("unavailable").is_none()); + assert_eq!(windows[1]["usedPercent"], 0.0); + } + #[test] fn antigravity_dashboard_keeps_all_families_after_global_reset() { let windows = vec![ diff --git a/rust/src/cli/serve/dashboard/window.rs b/rust/src/cli/serve/dashboard/window.rs index b8eebb26acc..790a19cc11a 100644 --- a/rust/src/cli/serve/dashboard/window.rs +++ b/rust/src/cli/serve/dashboard/window.rs @@ -13,6 +13,10 @@ pub struct WindowPayload { /// Display-only hint. Script clients can ignore this additive schema-v1 key. #[serde(skip_serializing_if = "is_false")] pub idle: bool, + /// Informational or unknown lane: `used_percent` / `remaining_percent` are + /// placeholders, not a measurement. Additive schema-v1 key. + #[serde(skip_serializing_if = "is_false")] + pub unavailable: bool, } fn is_false(value: &bool) -> bool { @@ -33,5 +37,6 @@ pub(super) fn make_window_with_idle( remaining_percent: (100.0 - used).clamp(0.0, 100.0), reset_at: window.resets_at, idle, + unavailable: window.is_informational, } } diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 15bf72fb700..2502d2796e4 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -79,9 +79,12 @@ pub struct NamedRateWindow { pub id: String, pub title: String, pub window: RateWindow, - /// Whether the provider explicitly reported usage for this lane. - /// In-memory presentation metadata only; external snapshot JSON stays stable. - #[serde(default = "named_rate_window_usage_known_default", skip_serializing)] + /// Whether the provider explicitly reported usage for this lane. Exported + /// so consumers can tell an unknown lane from a real 0 %. + #[serde( + default = "named_rate_window_usage_known_default", + rename = "usageKnown" + )] pub usage_known: bool, } diff --git a/rust/src/providers/abacus/mod.rs b/rust/src/providers/abacus/mod.rs index 0363d87bd52..ae82a9ad97a 100644 --- a/rust/src/providers/abacus/mod.rs +++ b/rust/src/providers/abacus/mod.rs @@ -28,9 +28,7 @@ struct ApiEnvelope { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ComputePoints { - #[serde(default)] total_compute_points: f64, - #[serde(default)] compute_points_left: f64, } @@ -43,6 +41,18 @@ struct BillingInfo { current_tier: Option, } +fn parse_compute_response(body: &str) -> Result { + let env: ApiEnvelope = serde_json::from_str(body) + .map_err(|_| ProviderError::Parse("Abacus compute response is invalid".into()))?; + + if !env.success { + return Err(ProviderError::AuthRequired); + } + env.result.ok_or_else(|| { + ProviderError::Parse("Abacus response contains no compute points result".into()) + }) +} + pub struct AbacusProvider { metadata: ProviderMetadata, client: Client, @@ -88,12 +98,17 @@ impl AbacusProvider { .and_then(|b| b.next_billing_date.as_deref()) .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) .map(|dt| dt.with_timezone(&Utc)); - let primary = RateWindow::with_details( - percent, - RateWindow::monthly_window_minutes(resets_at).or(Some(FALLBACK_MONTHLY_WINDOW_MINUTES)), - resets_at, - Some(format_credit_detail(used, total)), - ); + let primary = if total <= 0.0 { + RateWindow::informational(format_credit_detail(used, total)) + } else { + RateWindow::with_details( + percent, + RateWindow::monthly_window_minutes(resets_at) + .or(Some(FALLBACK_MONTHLY_WINDOW_MINUTES)), + resets_at, + Some(format_credit_detail(used, total)), + ) + }; let mut snapshot = UsageSnapshot::new(primary).with_primary_label(CREDITS_LABEL); if let Some(b) = billing @@ -126,14 +141,7 @@ impl AbacusProvider { } let body = resp.text().await?; - let env: ApiEnvelope = serde_json::from_str(&body) - .map_err(|e| ProviderError::Parse(format!("Failed to parse compute points: {}", e)))?; - - if !env.success { - return Err(ProviderError::AuthRequired); - } - env.result - .ok_or_else(|| ProviderError::Parse("Missing compute points result".to_string())) + parse_compute_response(&body) } async fn fetch_billing(&self, cookie_header: &str) -> Option { @@ -334,3 +342,74 @@ mod tests { assert_eq!(format_credit_detail(42.5, 100.0), "42.5 / 100 credits"); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + #[test] + fn cx289a_missing_compute_fields_are_rejected() { + for payload in [ + json!({}), + json!({"totalComputePoints":100}), + json!({"computePointsLeft":100}), + ] { + let result = serde_json::from_value::(payload) + .map_err(|e| ProviderError::Parse(e.to_string())) + .and_then(|compute| AbacusProvider::build_snapshot(compute, None)); + assert!(result.is_err()); + } + } + #[test] + fn cx289a_real_zero_is_preserved() { + let compute = + serde_json::from_value(json!({"totalComputePoints":100,"computePointsLeft":100})) + .unwrap(); + let snapshot = AbacusProvider::build_snapshot(compute, None).unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + } +} + +#[cfg(test)] +mod p1_11_zero_denominator_is_information_tests { + use super::*; + #[test] + fn review16_p1_11_zero_denominator_is_information() { + let compute = serde_json::from_value( + serde_json::json!({"totalComputePoints":0,"computePointsLeft":0}), + ) + .unwrap(); + let snapshot = AbacusProvider::build_snapshot(compute, None).unwrap(); + assert!(snapshot.primary.is_informational); + assert!(snapshot.primary.window_minutes.is_none()); + assert!(snapshot.primary.resets_at.is_none()); + } +} + +#[cfg(test)] +mod p2_02_incomplete_compute_is_a_data_error_tests { + use super::*; + #[test] + fn review16_p2_02_incomplete_compute_is_a_data_error() { + for body in [ + r#"{"success":true,"result":{"totalComputePoints":100}}"#, + r#"{"success":true}"#, + ] { + let error = parse_compute_response(body).unwrap_err(); + assert!(matches!(error, ProviderError::Parse(_))); + let text = error.to_string().to_lowercase(); + for marker in [ + "missing", + "not found", + "required", + "auth", + "token", + "cookie", + "login", + ] { + assert!(!text.contains(marker), "{text}"); + } + } + } +} diff --git a/rust/src/providers/alibaba/parser.rs b/rust/src/providers/alibaba/parser.rs index 6c11ccadbc8..eff81399c3f 100644 --- a/rust/src/providers/alibaba/parser.rs +++ b/rust/src/providers/alibaba/parser.rs @@ -58,14 +58,10 @@ pub(crate) fn parse_response(json: &serde_json::Value) -> Result f64 { - let used = quota.get(used_key).and_then(|v| v.as_f64()).unwrap_or(0.0); - let total = quota.get(total_key).and_then(|v| v.as_f64()).unwrap_or(1.0); - if total > 0.0 { - (used / total * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - } + let pct = |used_key: &str, total_key: &str| -> Option { + let used = quota.get(used_key).and_then(|v| v.as_f64())?; + let total = quota.get(total_key).and_then(|v| v.as_f64())?; + (total > 0.0).then(|| (used / total * 100.0).clamp(0.0, 100.0)) }; let detail = |used_key: &str, total_key: &str| -> Option { @@ -90,30 +86,48 @@ pub(crate) fn parse_response(json: &serde_json::Value) -> Result String { @@ -248,3 +262,44 @@ mod tests { assert_eq!(fmt_tokens(i64::MIN), "-9,223,372,036,854,775,808"); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + fn payload(quota: serde_json::Value) -> serde_json::Value { + json!({"data":{"DataV2":{"data":{"data":{"codingPlanInstanceInfos":[{"codingPlanQuotaInfo":quota}]}}}}}) + } + #[test] + fn cx289a_incomplete_quotas_are_rejected() { + for quota in [ + json!({}), + json!({"per5HourTotalQuota":100}), + json!({"per5HourUsedQuota":0}), + ] { + assert!(parse_response(&payload(quota)).is_err()); + } + } + #[test] + fn cx289a_weekly_only_keeps_primary_unknown() { + let snapshot = parse_response(&payload( + json!({"perWeekUsedQuota":0,"perWeekTotalQuota":100}), + )) + .unwrap(); + assert!(snapshot.primary.is_informational); + let weekly = snapshot.secondary.unwrap(); + assert_eq!(weekly.used_percent, 0.0); + assert!(!weekly.is_informational); + assert!(snapshot.tertiary.is_none()); + } + #[test] + fn cx289a_real_zero_is_preserved() { + let snapshot = parse_response(&payload( + json!({"per5HourUsedQuota":0,"per5HourTotalQuota":100}), + )) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + assert!(snapshot.secondary.is_none()); + } +} diff --git a/rust/src/providers/amp/mod.rs b/rust/src/providers/amp/mod.rs index 21a9c5ec2a8..d61258623fd 100755 --- a/rust/src/providers/amp/mod.rs +++ b/rust/src/providers/amp/mod.rs @@ -121,19 +121,18 @@ impl AmpProvider { .get("completionsUsed") .or_else(|| json.get("used")) .and_then(|v| v.as_f64()) - .unwrap_or(0.0); + .ok_or_else(|| ProviderError::Parse("Amp response contains no usage counter".into()))?; let limit = json .get("completionsLimit") .or_else(|| json.get("limit")) .and_then(|v| v.as_f64()) - .unwrap_or(500.0); + .filter(|limit| *limit > 0.0) + .ok_or_else(|| { + ProviderError::Parse("Amp response contains no valid usage limit".into()) + })?; - let used_percent = if limit > 0.0 { - (used / limit) * 100.0 - } else { - 0.0 - }; + let used_percent = (used / limit) * 100.0; let plan = json .get("plan") @@ -153,6 +152,12 @@ impl AmpProvider { Ok(usage) } + fn configured_usage() -> Result { + Err(ProviderError::Parse( + "Amp response contains no usage data".into(), + )) + } + /// Probe for Amp installation async fn probe_cli(&self, ctx: &FetchContext) -> Result { // Check ctx.api_key first @@ -170,9 +175,7 @@ impl AmpProvider { .unwrap_or(false); if has_api_key || has_env || has_amp_config || has_cody_config { - let usage = - UsageSnapshot::new(RateWindow::new(0.0)).with_login_method("Amp (configured)"); - Ok(usage) + Self::configured_usage() } else { Err(ProviderError::NotInstalled( "Amp not configured. Set SRC_ACCESS_TOKEN environment variable or configure Amp." @@ -643,3 +646,30 @@ mod current_subscription_tests { assert_eq!(sub.reset_description, "renews in 1 month"); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + + #[test] + fn cx289a_configuration_is_not_usage() { + assert!(super::AmpProvider::configured_usage().is_err()); + } + + #[test] + fn cx289a_missing_usage_is_rejected() { + let provider = AmpProvider::new(); + for payload in [json!({}), json!({"limit":100}), json!({"used":0})] { + assert!(provider.parse_usage_response(&payload).is_err()); + } + } + #[test] + fn cx289a_real_zero_is_preserved() { + let snapshot = AmpProvider::new() + .parse_usage_response(&json!({"used":0,"limit":100})) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + } +} diff --git a/rust/src/providers/antigravity/legacy_status.rs b/rust/src/providers/antigravity/legacy_status.rs index 852561a3995..8f4e8f13506 100644 --- a/rust/src/providers/antigravity/legacy_status.rs +++ b/rust/src/providers/antigravity/legacy_status.rs @@ -241,7 +241,9 @@ fn model_window_id(config: &ModelConfig) -> String { } fn rate_window_from_quota(quota: &QuotaInfo) -> RateWindow { - let remaining = quota.remaining_fraction.unwrap_or(1.0); + let Some(remaining) = quota.remaining_fraction else { + return RateWindow::informational("Antigravity model usage unavailable"); + }; let used_percent = (1.0 - remaining) * 100.0; RateWindow::with_details(used_percent, None, None, quota.reset_time.clone()) } @@ -311,6 +313,17 @@ pub(super) fn parse_user_status( .filter(|config| !model_label(config).is_empty()) .collect::>(); quota_configs.sort_by(|a, b| compare_model_configs(a, b)); + if !quota_configs.iter().any(|config| { + config + .quota_info + .as_ref() + .and_then(|quota| quota.remaining_fraction) + .is_some() + }) { + return Err(ProviderError::Parse( + "Antigravity status contains no model quota".into(), + )); + } let summary_candidates = quota_configs .iter() @@ -334,7 +347,8 @@ pub(super) fn parse_user_status( .and_then(|config| config.quota_info.as_ref()) .map(rate_window_from_quota); - let primary = primary.unwrap_or_else(|| RateWindow::new(0.0)); + let primary = primary + .ok_or_else(|| ProviderError::Parse("Antigravity status contains no model quota".into()))?; let mut snapshot = UsageSnapshot::new(primary); if let Some(sec) = secondary { @@ -385,3 +399,35 @@ pub(super) fn parse_user_status( Ok(snapshot) } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + fn parse(models: serde_json::Value) -> Result { + parse_user_status( + serde_json::from_value( + json!({"userStatus":{"cascadeModelConfigData":{"clientModelConfigs":models}}}), + ) + .unwrap(), + ) + } + #[test] + fn cx289a_missing_model_usage_is_rejected() { + assert!(parse(json!([])).is_err()); + assert!(parse(json!([{"label":"Claude","quotaInfo":{}}])).is_err()); + } + #[test] + fn cx289a_real_zero_is_preserved_with_informational_extra() { + let snapshot = parse(json!([ + {"label":"Claude","quotaInfo":{"remainingFraction":1.0}}, + {"label":"Other","quotaInfo":{}} + ])) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.extra_rate_windows.len(), 1); + assert!(snapshot.extra_rate_windows[0].window.is_informational); + assert!(!snapshot.extra_rate_windows[0].usage_known); + } +} diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index d74ae791e7c..8840ef6f01e 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -162,6 +162,31 @@ fn antigravity_extra_windows_preserve_usage_known() { assert_eq!(claude.window.used_percent, 0.0); } +#[test] +fn antigravity_unknown_window_exports_usage_known_false() { + let resp: UserStatusResponse = serde_json::from_value(serde_json::json!({ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + {"label": "Claude 4 Sonnet", "quotaInfo": {"remainingFraction": 0.8}}, + {"label": "Gemini 2.5 Pro", "quotaInfo": {"remainingFraction": 0.6}}, + {"label": "Gemini 2.5 Flash", "quotaInfo": {"remainingFraction": 0.4}}, + {"label": "Claude 3.5 Sonnet", "quotaInfo": {"remainingFraction": null}} + ] + } + } + })) + .unwrap(); + let snap = AntigravityProvider::new().parse_user_status(resp).unwrap(); + let exported = serde_json::to_value(&snap).unwrap(); + let windows = exported["extra_rate_windows"].as_array().unwrap(); + assert_eq!(windows.len(), snap.extra_rate_windows.len()); + assert!(windows.iter().any(|window| window["usageKnown"] == false)); + for (json, window) in windows.iter().zip(&snap.extra_rate_windows) { + assert_eq!(json["usageKnown"], window.usage_known); + } +} + #[test] fn test_parse_user_status_standard() { let resp = make_response(vec![ diff --git a/rust/src/providers/augment/mod.rs b/rust/src/providers/augment/mod.rs index 388746ef75a..c79417896cd 100755 --- a/rust/src/providers/augment/mod.rs +++ b/rust/src/providers/augment/mod.rs @@ -172,19 +172,20 @@ impl AugmentProvider { .get("used_credits") .or_else(|| json.get("usage")) .and_then(|v| v.as_f64()) - .unwrap_or(0.0); + .ok_or_else(|| { + ProviderError::Parse("Augment response contains no usage counter".into()) + })?; let limit = json .get("credit_limit") .or_else(|| json.get("limit")) .and_then(|v| v.as_f64()) - .unwrap_or(100.0); + .filter(|limit| *limit > 0.0) + .ok_or_else(|| { + ProviderError::Parse("Augment response contains no valid usage limit".into()) + })?; - let used_percent = if limit > 0.0 { - (used / limit) * 100.0 - } else { - 0.0 - }; + let used_percent = (used / limit) * 100.0; let email = json .get("email") @@ -497,3 +498,28 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + #[test] + fn cx289a_missing_usage_is_rejected() { + let provider = AugmentProvider::new(); + for payload in [ + json!({}), + json!({"credit_limit":100}), + json!({"used_credits":0}), + ] { + assert!(provider.parse_usage_response(&payload).is_err()); + } + } + #[test] + fn cx289a_real_zero_is_preserved() { + let snapshot = AugmentProvider::new() + .parse_usage_response(&json!({"used_credits":0,"credit_limit":100})) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + } +} diff --git a/rust/src/providers/azureopenai.rs b/rust/src/providers/azureopenai.rs index 35585618f4f..eb2bf50e9b5 100644 --- a/rust/src/providers/azureopenai.rs +++ b/rust/src/providers/azureopenai.rs @@ -89,15 +89,20 @@ impl AzureOpenAIProvider { let parsed: ChatCompletionResponse = serde_json::from_slice(&bytes) .map_err(|e| ProviderError::Parse(format!("Azure OpenAI response parse error: {e}")))?; - let mut primary = RateWindow::new(0.0); - primary.reset_description = Some(Self::detail_text( + Ok(Self::snapshot_from_response(&config, &parsed)) + } + + fn snapshot_from_response( + config: &AzureOpenAIConfig, + parsed: &ChatCompletionResponse, + ) -> UsageSnapshot { + let primary = RateWindow::informational(Self::detail_text( &config.deployment, parsed.model.as_deref(), )); - let usage = UsageSnapshot::new(primary) + UsageSnapshot::new(primary) .with_organization(config.endpoint.host_str().unwrap_or_default().to_string()) - .with_login_method(format!("Deployment: {}", config.deployment)); - Ok(usage) + .with_login_method(format!("Deployment: {}", config.deployment)) } fn resolve_config(ctx: &FetchContext) -> Result { @@ -364,3 +369,23 @@ mod tests { assert!(AzureOpenAIProvider::parse_endpoint("https://example.com%2f.evil.test").is_err()); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + + #[test] + fn deployment_without_quota_is_informational() { + let config = AzureOpenAIProvider::parse_saved_config( + "synthetic-key|https://example.openai.azure.com|test-deployment", + ) + .unwrap(); + for json in ["{}", r#"{"model":"test-model"}"#] { + let parsed = serde_json::from_str(json).unwrap(); + let usage = AzureOpenAIProvider::snapshot_from_response(&config, &parsed); + assert!(usage.primary.is_informational); + assert!(usage.primary.window_minutes.is_none()); + assert!(usage.primary.resets_at.is_none()); + } + } +} diff --git a/rust/src/providers/bedrock/mod.rs b/rust/src/providers/bedrock/mod.rs index ddf613628f9..60b05ca54d0 100644 --- a/rust/src/providers/bedrock/mod.rs +++ b/rust/src/providers/bedrock/mod.rs @@ -429,6 +429,29 @@ impl BedrockProvider { } } + fn spend_window( + spend: f64, + budget: Option, + resets_at: Option>, + ) -> RateWindow { + match budget { + Some(limit) if limit > 0.0 => RateWindow::with_details( + (spend / limit) * 100.0, + None, + resets_at, + Some("Monthly budget".to_string()), + ), + _ => RateWindow::informational(format!("Monthly spend ${spend:.2}")), + } + } + + fn activity_window(activity: &BedrockClaudeActivity) -> RateWindow { + RateWindow::informational(format!( + "Claude 14d: {:.0} input, {:.0} output tokens, {:.0} requests", + activity.input_tokens, activity.output_tokens, activity.request_count + )) + } + async fn fetch_via_api( &self, ctx: &FetchContext, @@ -438,25 +461,7 @@ impl BedrockProvider { let spend = self.fetch_monthly_spend(&credentials).await?; let resets_at = end_of_current_month(); - let used_percent = budget - .map(|limit| { - if limit > 0.0 { - (spend / limit) * 100.0 - } else { - 0.0 - } - }) - .unwrap_or(0.0); - - let mut primary = RateWindow::with_details( - used_percent, - None, - resets_at, - budget.map(|_| "Monthly budget".to_string()), - ); - if budget.is_none() { - primary.reset_description = Some(format!("Monthly spend ${spend:.2}")); - } + let primary = Self::spend_window(spend, budget, resets_at); let mut cost = CostSnapshot::new(spend, "USD", "Monthly"); if let Some(limit) = budget { @@ -482,11 +487,7 @@ impl BedrockProvider { if let Ok(activity) = self.fetch_claude_activity(&credentials, ®ion).await && activity.request_count > 0.0 { - let mut window = RateWindow::new(0.0); - window.reset_description = Some(format!( - "Claude 14d: {:.0} input, {:.0} output tokens, {:.0} requests", - activity.input_tokens, activity.output_tokens, activity.request_count - )); + let window = Self::activity_window(&activity); usage = usage.with_extra_rate_window("claude-14d", "Claude 14d activity", window); } Ok(ProviderFetchResult::new(usage, "api").with_cost(cost)) @@ -911,3 +912,31 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + #[test] + fn spend_without_budget_is_informational() { + for spend in [0.0, 12.5] { + let window = BedrockProvider::spend_window(spend, None, end_of_current_month()); + assert!(window.is_informational); + assert!(window.resets_at.is_none()); + } + } + #[test] + fn activity_without_denominator_is_informational() { + let activity = BedrockClaudeActivity { + input_tokens: 10.0, + output_tokens: 20.0, + request_count: 1.0, + }; + assert!(BedrockProvider::activity_window(&activity).is_informational); + } + #[test] + fn real_zero_spend_with_budget_stays_quota() { + let window = BedrockProvider::spend_window(0.0, Some(100.0), end_of_current_month()); + assert!(!window.is_informational); + assert_eq!(window.used_percent, 0.0); + } +} diff --git a/rust/src/providers/claude/admin_api.rs b/rust/src/providers/claude/admin_api.rs index e56f0cf907c..1df4b1c4e72 100644 --- a/rust/src/providers/claude/admin_api.rs +++ b/rust/src/providers/claude/admin_api.rs @@ -138,7 +138,6 @@ struct CostReportResponse { #[derive(Debug, Deserialize)] struct CostBucket { - starting_at: String, #[allow( dead_code, reason = "admin API response fields are deserialized for parsing but not all are read" @@ -161,7 +160,6 @@ struct MessagesUsageResponse { #[derive(Debug, Deserialize)] struct MessageBucket { - starting_at: String, #[allow( dead_code, reason = "admin API response fields are deserialized for parsing but not all are read" @@ -215,39 +213,19 @@ fn result_from_admin_usage( .map(|r| r.output_tokens.unwrap_or(0)) .sum(); let total_tokens = input_tokens + output_tokens; - let start = costs - .data - .first() - .and_then(|b| DateTime::parse_from_rfc3339(&b.starting_at).ok()) - .or_else(|| { - messages - .data - .first() - .and_then(|b| DateTime::parse_from_rfc3339(&b.starting_at).ok()) - }) - .map(|dt| dt.with_timezone(&Utc)); - - let mut usage = UsageSnapshot::new(RateWindow::with_details( - 0.0, - None, - start, - Some(format!("${cost_total:.2} over last 30 days")), - )) - .with_secondary(RateWindow::with_details( - 0.0, - None, - None, - Some(format!("{total_tokens} tokens")), - )) + let mut usage = UsageSnapshot::new(RateWindow::informational(format!( + "${cost_total:.2} over last 30 days" + ))) + .with_secondary(RateWindow::informational(format!("{total_tokens} tokens"))) .with_extra_rate_window( "input-tokens", "Input tokens", - RateWindow::with_details(0.0, None, None, Some(format!("{input_tokens}"))), + RateWindow::informational(format!("{input_tokens}")), ) .with_extra_rate_window( "output-tokens", "Output tokens", - RateWindow::with_details(0.0, None, None, Some(format!("{output_tokens}"))), + RateWindow::informational(format!("{output_tokens}")), ) .with_login_method("Admin API"); usage.updated_at = now; @@ -276,7 +254,7 @@ fn result_from_admin_usage( usage = usage.with_extra_rate_window( format!("model-{idx}"), format!("Model: {model}"), - RateWindow::with_details(0.0, None, None, Some(format!("{tokens} tokens"))), + RateWindow::informational(format!("{tokens} tokens")), ); } @@ -297,7 +275,7 @@ fn result_from_admin_usage( usage = usage.with_extra_rate_window( format!("cost-{idx}"), format!("Cost: {item}"), - RateWindow::with_details(0.0, None, None, Some(format!("${cost:.2}"))), + RateWindow::informational(format!("${cost:.2}")), ); } @@ -324,3 +302,45 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + use serde_json::json; + + #[test] + fn admin_costs_and_tokens_without_quota_are_informational() { + let costs = serde_json::from_value(json!({"data":[{ + "starting_at":"2026-10-01T00:00:00Z", "ending_at":"2026-10-02T00:00:00Z", + "results":[{"amount":"1250", "description":"API"}] + }]})) + .unwrap(); + let messages = serde_json::from_value(json!({"data":[{ + "starting_at":"2026-10-01T00:00:00Z", "ending_at":"2026-10-02T00:00:00Z", + "results":[{"model":"synthetic", "uncached_input_tokens":100, "output_tokens":50}] + }]})) + .unwrap(); + let result = result_from_admin_usage(&costs, &messages, Utc::now()); + assert!(result.usage.primary.is_informational); + assert!(result.usage.primary.resets_at.is_none()); + assert!(result.usage.secondary.unwrap().is_informational); + assert!( + result + .usage + .extra_rate_windows + .iter() + .all(|w| w.window.is_informational) + ); + assert!(result.cost.is_some()); + } + #[test] + fn empty_admin_reports_are_not_a_zero_quota() { + let result = result_from_admin_usage( + &CostReportResponse { data: vec![] }, + &MessagesUsageResponse { data: vec![] }, + Utc::now(), + ); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.unwrap().is_informational); + } +} diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs index eae0fd2faa5..3da6079a6c2 100644 --- a/rust/src/providers/codebuddy/mod.rs +++ b/rust/src/providers/codebuddy/mod.rs @@ -500,44 +500,51 @@ fn totals_from_api_payload(value: &Value) -> Result let mut reset: Option> = None; for account in accounts { - total += precise_or_field(account, "CapacitySizePrecise", "CapacitySize").unwrap_or(0.0); - used += precise_or_field(account, "CapacityUsedPrecise", "CapacityUsed").unwrap_or(0.0); - remaining += - precise_or_field(account, "CapacityRemainPrecise", "CapacityRemain").unwrap_or(0.0); + let account_total = precise_or_field(account, "CapacitySizePrecise", "CapacitySize") + .ok_or_else(|| { + ProviderError::Parse("CodeBuddy response contains no package total".into()) + })?; + let account_used = precise_or_field(account, "CapacityUsedPrecise", "CapacityUsed"); + let account_remaining = + precise_or_field(account, "CapacityRemainPrecise", "CapacityRemain"); + // A fully known empty package contributes nothing to the aggregate. + if account_total == 0.0 && account_used == Some(0.0) && account_remaining == Some(0.0) { + continue; + } + let totals = + CreditTotals::normalized(account_total, account_used, account_remaining, None)?; + total += totals.total; + used += totals.used; + remaining += totals.remaining; if let Some(reset_at) = expire_time(account) { reset = Some(reset.map_or(reset_at, |prev| prev.min(reset_at))); } } - CreditTotals::normalized(total, used, remaining, reset) + CreditTotals::normalized(total, Some(used), Some(remaining), reset) } impl CreditTotals { - /// Reconcile partial sums: prefer summed remaining, recompute used when it - /// is missing, and clamp every field to non-negative. + /// Derive a missing counter only from a measured total and its counterpart. fn normalized( - mut total: f64, - mut used: f64, - mut remaining: f64, + total: f64, + used: Option, + remaining: Option, reset: Option>, ) -> Result { - if !(total.is_finite() && used.is_finite() && remaining.is_finite()) { + let used = used + .or_else(|| remaining.map(|remaining| total - remaining)) + .ok_or_else(|| { + ProviderError::Parse("CodeBuddy response contains no usage counter".into()) + })?; + let remaining = remaining.unwrap_or(total - used); + if !(total.is_finite() && total > 0.0 && used.is_finite() && remaining.is_finite()) { return Err(ProviderError::Parse( - "CodeBuddy payload contains non-finite credit values".into(), + "CodeBuddy payload contains invalid credit values".into(), )); } - if remaining > 0.0 && total > 0.0 && used <= 0.0 { - used = (total - remaining).max(0.0); - } - if used < 0.0 { - used = 0.0; - } - if total < 0.0 { - total = 0.0; - } - if remaining < 0.0 { - remaining = (total - used).max(0.0); - } + let used = used.max(0.0); + let remaining = remaining.max(0.0); Ok(CreditTotals { total, used, @@ -596,22 +603,15 @@ fn totals_from_cache_json(value: &Value) -> Result "cb_credits.json invalid total: {total}" ))); } - let used = number_field(value, &["used"]).unwrap_or(0.0).max(0.0); - let remaining = number_field(value, &["remaining"]) - .map(|r| r.max(0.0)) - .unwrap_or_else(|| (total - used).max(0.0)); + let used = number_field(value, &["used"]); + let remaining = number_field(value, &["remaining"]); // Prefer real package expiry from cache if present; do not treat updatedAt // as a quota reset (that previously looked like a bogus reset countdown). let reset = value .get("resetsAt") .and_then(|v| v.as_str()) .and_then(parse_datetime); - Ok(CreditTotals { - total, - used, - remaining, - reset, - }) + CreditTotals::normalized(total, used, remaining, reset) } fn write_credits_cache( @@ -1288,3 +1288,74 @@ mod tests { } } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + fn payload(accounts: Value) -> Value { + json!({"code":0,"data":{"Response":{"Data":{"Accounts":accounts}}}}) + } + #[test] + fn review16_empty_package_preserves_known_aggregate() { + let totals = totals_from_api_payload(&payload(json!([ + {"CapacitySize":100,"CapacityUsed":25,"CapacityRemain":75}, + {"CapacitySize":0,"CapacityUsed":0,"CapacityRemain":0} + ]))) + .unwrap(); + let snapshot = snapshot_from_totals(&totals); + assert_eq!(snapshot.primary.used_percent, 25.0); + assert!(!snapshot.primary.is_informational); + assert_eq!(totals.remaining, 75.0); + } + + #[test] + fn review16_empty_package_alone_does_not_invent_quota() { + assert!( + totals_from_api_payload(&payload(json!([ + {"CapacitySize":0,"CapacityUsed":0,"CapacityRemain":0} + ]))) + .is_err() + ); + assert!( + totals_from_api_payload(&payload(json!([ + {"CapacitySize":100,"CapacityUsed":25,"CapacityRemain":75}, + {"CapacitySize":0} + ]))) + .is_err() + ); + } + + #[test] + fn cx289a_missing_api_counter_is_rejected() { + assert!(totals_from_api_payload(&payload(json!([{"CapacitySize":100}]))).is_err()); + assert!(totals_from_api_payload(&payload(json!([{"CapacityUsed":0}]))).is_err()); + assert!( + totals_from_api_payload(&payload(json!([ + {"CapacitySize":100,"CapacityUsed":0},{"CapacitySize":100} + ]))) + .is_err() + ); + } + #[test] + fn cx289a_cache_requires_counter_and_derives_used() { + assert!(totals_from_cache_json(&json!({"total":100})).is_err()); + let totals = totals_from_cache_json(&json!({"total":100,"remaining":75})).unwrap(); + assert_eq!(snapshot_from_totals(&totals).primary.used_percent, 25.0); + let exhausted = + totals_from_api_payload(&payload(json!([{"CapacitySize":100,"CapacityRemain":0}]))) + .unwrap(); + assert_eq!(snapshot_from_totals(&exhausted).primary.used_percent, 100.0); + } + #[test] + fn cx289a_real_zero_is_preserved() { + let totals = + totals_from_api_payload(&payload(json!([{"CapacitySize":100,"CapacityUsed":0}]))) + .unwrap(); + let snapshot = snapshot_from_totals(&totals); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + let totals = totals_from_cache_json(&json!({"total":100,"used":0})).unwrap(); + assert_eq!(snapshot_from_totals(&totals).primary.used_percent, 0.0); + } +} diff --git a/rust/src/providers/codebuff/mod.rs b/rust/src/providers/codebuff/mod.rs index d0e00fa2575..ea84150fb72 100644 --- a/rust/src/providers/codebuff/mod.rs +++ b/rust/src/providers/codebuff/mod.rs @@ -128,7 +128,7 @@ impl CodebuffProvider { })?; let subscription = self.fetch_subscription(&base, &api_key).await; - Ok(Self::snapshot_from_values(&usage, subscription.as_ref())) + Self::snapshot_from_values(&usage, subscription.as_ref()) } async fn fetch_subscription(&self, base: &Url, api_key: &str) -> Option { @@ -150,41 +150,57 @@ impl CodebuffProvider { resp.json::().await.ok() } - fn snapshot_from_values(usage: &Value, subscription: Option<&Value>) -> UsageSnapshot { + fn snapshot_from_values( + usage: &Value, + subscription: Option<&Value>, + ) -> Result { let usage = data_payload(usage); let subscription = subscription.map(data_payload); - let used = number_at(usage, &["usage", "used"]).unwrap_or(0.0); + let used = number_at(usage, &["usage", "used"]); let remaining = number_at(usage, &["remainingBalance", "remaining"]); - let total = number_at(usage, &["creditsTotal", "quota", "limit"]) - .or_else(|| remaining.map(|r| used + r)); - - let percent = match (total, remaining) { - (Some(total), _) if total > 0.0 => (used / total * 100.0).clamp(0.0, 100.0), - (None, Some(_)) => 100.0, - _ => 0.0, - }; - - let mut primary = RateWindow::new(percent); - if let Some(reset) = - string_at(usage, &["next_quota_reset", "nextQuotaReset"]).and_then(parse_datetime) + let total = number_at(usage, &["creditsTotal", "quota", "limit"]).or_else(|| { + used.zip(remaining) + .map(|(used, remaining)| used + remaining) + }); + let used = used.or_else(|| { + total + .zip(remaining) + .map(|(total, remaining)| total - remaining) + }); + let weekly = subscription + .and_then(|v| v.get("rateLimit").or_else(|| v.get("subscription"))) + .and_then(weekly_window); + let primary = match used + .zip(total) + .filter(|(used, total)| used.is_finite() && total.is_finite() && *total > 0.0) { - primary.resets_at = Some(reset); - } - primary.reset_description = match (used, total, remaining) { - (_, Some(total), Some(remaining)) => Some(format!( - "{used:.0}/{total:.0} credits ({remaining:.0} remaining)" + Some((used, total)) => { + let mut window = RateWindow::new((used / total * 100.0).clamp(0.0, 100.0)); + window.resets_at = string_at(usage, &["next_quota_reset", "nextQuotaReset"]) + .and_then(parse_datetime); + window.reset_description = Some(match remaining { + Some(remaining) => { + format!("{used:.0}/{total:.0} credits ({remaining:.0} remaining)") + } + None => format!("{used:.0}/{total:.0} credits"), + }); + window + } + None if remaining.is_some() => RateWindow::informational(format!( + "{} credits remaining; quota unavailable", + remaining.unwrap() )), - (_, Some(total), None) => Some(format!("{used:.0}/{total:.0} credits")), - (_, None, Some(remaining)) => Some(format!("{remaining:.0} credits remaining")), - _ => None, + None if weekly.is_some() => { + RateWindow::informational("Codebuff credit usage unavailable") + } + None => { + return Err(ProviderError::Parse( + "Codebuff response contains no usable quota".into(), + )); + } }; - let mut snapshot = UsageSnapshot::new(primary); - - if let Some(rate_limit) = - subscription.and_then(|v| v.get("rateLimit").or_else(|| v.get("subscription"))) - && let Some(weekly) = weekly_window(rate_limit) - { + if let Some(weekly) = weekly { snapshot = snapshot.with_secondary(weekly); } @@ -221,7 +237,7 @@ impl CodebuffProvider { snapshot = snapshot.with_login_method(method_parts.join(" · ")); } - snapshot + Ok(snapshot) } } @@ -330,7 +346,7 @@ mod tests { } }); - let snapshot = CodebuffProvider::snapshot_from_values(&usage, Some(&subscription)); + let snapshot = CodebuffProvider::snapshot_from_values(&usage, Some(&subscription)).unwrap(); assert_eq!(snapshot.primary.used_percent, 25.0); assert_eq!( @@ -361,7 +377,7 @@ mod tests { } }); - let snapshot = CodebuffProvider::snapshot_from_values(&usage, Some(&subscription)); + let snapshot = CodebuffProvider::snapshot_from_values(&usage, Some(&subscription)).unwrap(); assert_eq!(snapshot.primary.used_percent, 40.0); assert_eq!( @@ -370,3 +386,60 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + + #[test] + fn cx289a_incomplete_quotas_are_rejected() { + for payload in [json!({}), json!({"creditsTotal":100}), json!({"used":0})] { + assert!(CodebuffProvider::snapshot_from_values(&payload, None).is_err()); + } + } + #[test] + fn cx289a_remaining_and_total_determine_used() { + let snapshot = CodebuffProvider::snapshot_from_values( + &json!({"creditsTotal":100,"remaining":75}), + None, + ) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 25.0); + } + + #[test] + fn cx289a_weekly_only_keeps_primary_unknown() { + let snapshot = CodebuffProvider::snapshot_from_values( + &json!({"creditsTotal":100}), + Some(&json!({"rateLimit":{"weeklyUsed":0,"weeklyLimit":100}})), + ) + .unwrap(); + assert!(snapshot.primary.is_informational); + let weekly = snapshot.secondary.unwrap(); + assert_eq!(weekly.used_percent, 0.0); + assert!(!weekly.is_informational); + } + #[test] + fn cx289a_real_zero_is_preserved() { + let snapshot = + CodebuffProvider::snapshot_from_values(&json!({"creditsTotal":100,"used":0}), None) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert!(!snapshot.primary.is_informational); + } +} + +#[cfg(test)] +mod review16_tests { + use super::*; + #[test] + fn review16_p1_01_remaining_without_quota_is_information() { + let snapshot = + CodebuffProvider::snapshot_from_values(&json!({"remaining":100}), None).unwrap(); + assert!(snapshot.primary.is_informational); + assert!(snapshot.primary.reset_description.unwrap().contains("100")); + assert!(snapshot.primary.resets_at.is_none()); + assert!(snapshot.secondary.is_none()); + } +} diff --git a/rust/src/providers/commandcode/mod.rs b/rust/src/providers/commandcode/mod.rs index 1f8f5cfd061..ee1b2a4fb58 100644 --- a/rust/src/providers/commandcode/mod.rs +++ b/rust/src/providers/commandcode/mod.rs @@ -412,12 +412,9 @@ fn result_from_payloads( } else if let Some(monthly) = monthly_window { UsageSnapshot::new(monthly) } else { - UsageSnapshot::new(RateWindow::with_details( - 0.0, - None, - period_end, - Some(format!("{monthly_credits:.2} monthly credits remaining")), - )) + UsageSnapshot::new(RateWindow::informational(format!( + "{monthly_credits:.2} monthly credits remaining" + ))) }; if let Some(method) = login_method(monthly_credits, purchased, plan) { @@ -455,7 +452,7 @@ fn limit_window(value: Option<&Value>, window_minutes: u32) -> Option) -> Option> { /// Monthly grant window from the plan catalog (upstream `makeMonthlyWindow`): /// catalog total − remaining, clamped to [0, total]. Without a recognized plan -/// the fallback keeps a visible-but-empty bar when credits remain, and no -/// window at all when the account holds nothing. +/// the remaining balance is informational, not a measured usage percentage. fn monthly_window( monthly_remaining: f64, purchased: f64, @@ -511,12 +507,9 @@ fn monthly_window( )); } if monthly_remaining > 0.0 || purchased > 0.0 { - return Some(RateWindow::with_details( - 0.0, - RateWindow::monthly_window_minutes(period_end), - period_end, - None, - )); + return Some(RateWindow::informational(format!( + "{monthly_remaining:.2} monthly credits remaining · {purchased:.2} purchased credits" + ))); } None } @@ -849,3 +842,25 @@ mod tests { assert!(result.usage.login_method.is_none()); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + #[test] + fn cx289a_cap_without_counter_is_unknown() { + assert!(limit_window(Some(&json!({"cap":100})), 300).is_none()); + } + #[test] + fn cx289a_unknown_plan_balance_is_informational() { + let window = monthly_window(25.0, 0.0, None, None).unwrap(); + assert!(window.is_informational); + assert!(window.resets_at.is_none()); + } + #[test] + fn cx289a_real_zero_is_preserved() { + let window = limit_window(Some(&json!({"cap":100,"used":0})), 300).unwrap(); + assert_eq!(window.used_percent, 0.0); + assert!(!window.is_informational); + } +} diff --git a/rust/src/providers/crof/mod.rs b/rust/src/providers/crof/mod.rs index 25b69904886..9f570bb9793 100644 --- a/rust/src/providers/crof/mod.rs +++ b/rust/src/providers/crof/mod.rs @@ -104,20 +104,22 @@ fn snapshot_from_usage(usage: &CrofUsageResponse) -> UsageSnapshot { } else { format!("${credits:.4}") }; - let mut primary = RateWindow::new(if credits > 0.0 { 0.0 } else { 100.0 }); - primary.reset_description = Some(display.clone()); + let primary = RateWindow::informational(display.clone()); let mut snapshot = UsageSnapshot::new(primary).with_login_method(format!("{display} balance")); if let (Some(plan), Some(usable)) = (usage.requests_plan, usage.usable_requests) { - let remaining = usable.max(0.0).min(plan.max(0.0)); - let remaining_percent = if plan > 0.0 { - ((remaining / plan) * 100.0).clamp(0.0, 100.0) + let requests = if plan > 0.0 { + let remaining = usable.max(0.0).min(plan); + RateWindow::with_details( + 100.0 - ((remaining / plan) * 100.0).clamp(0.0, 100.0), + None, + None, + Some(format!("{remaining:.0} requests left")), + ) } else { - 0.0 + RateWindow::informational(format!("{:.0} requests left", usable.max(0.0))) }; - let mut requests = RateWindow::new(100.0 - remaining_percent); - requests.reset_description = Some(format!("{remaining:.0} requests left")); snapshot = snapshot.with_secondary(requests); } @@ -236,13 +238,52 @@ mod tests { } #[test] - fn crof_zero_balance_is_exhausted() { + fn crof_zero_balance_is_informational() { let snapshot = snapshot_from_usage(&CrofUsageResponse { credits: 0.0, requests_plan: None, usable_requests: None, }); - assert_eq!(snapshot.primary.used_percent, 100.0); + assert!(snapshot.primary.is_informational); assert_eq!(snapshot.primary.reset_description.as_deref(), Some("$0.00")); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + #[test] + fn balance_without_quota_is_informational() { + for credits in [0.0, 12.5] { + let snapshot = snapshot_from_usage(&CrofUsageResponse { + credits, + requests_plan: None, + usable_requests: None, + }); + assert!(snapshot.primary.is_informational); + assert!(snapshot.secondary.is_none()); + } + } + #[test] + fn request_count_without_positive_plan_is_informational() { + for plan in [0.0, -1.0] { + let snapshot = snapshot_from_usage(&CrofUsageResponse { + credits: 12.5, + requests_plan: Some(plan), + usable_requests: Some(10.0), + }); + assert!(snapshot.secondary.unwrap().is_informational); + } + } + #[test] + fn real_zero_requests_stays_quota() { + let snapshot = snapshot_from_usage(&CrofUsageResponse { + credits: 12.5, + requests_plan: Some(100.0), + usable_requests: Some(100.0), + }); + let window = snapshot.secondary.unwrap(); + assert!(!window.is_informational); + assert_eq!(window.used_percent, 0.0); + } +} diff --git a/rust/src/providers/crossmodel/mod.rs b/rust/src/providers/crossmodel/mod.rs index 0f23cdb7305..0f0fd536f12 100644 --- a/rust/src/providers/crossmodel/mod.rs +++ b/rust/src/providers/crossmodel/mod.rs @@ -158,22 +158,13 @@ fn snapshot_from_parts( let currency = normalize_currency(&credits.currency); let balance = major_units(credits.balance_micro); let uncollected = major_units(credits.uncollected_micro); - let mut primary = usage + let primary = usage .as_ref() .and_then(|u| usage_window_rate(u.daily.as_ref(), "Daily")) .unwrap_or_else(|| { - RateWindow::with_details( - 0.0, - Some(24 * 60), - None, - Some(format!("{} balance", format_amount(balance, ¤cy))), - ) + RateWindow::informational(format!("{} balance", format_amount(balance, ¤cy))) }); - if primary.reset_description.is_none() { - primary.reset_description = Some("Daily usage".to_string()); - } - let mut snapshot = UsageSnapshot::new(primary).with_login_method("API key"); if let Some(usage) = usage .as_ref() @@ -190,7 +181,7 @@ fn snapshot_from_parts( snapshot = snapshot.with_extra_rate_window( "uncollected", "Uncollected", - RateWindow::with_details(0.0, None, None, Some(format_amount(uncollected, ¤cy))), + RateWindow::informational(format_amount(uncollected, ¤cy)), ); } @@ -217,12 +208,7 @@ fn usage_window_rate(window: Option<&UsageWindow>, label: &str) -> Option f64 { @@ -315,3 +301,40 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + use serde_json::json; + #[test] + fn balance_without_quota_is_informational() { + let credits = serde_json::from_value( + json!({"currency":"USD", "balanceMicro":12500000, "uncollectedMicro":1000000}), + ) + .unwrap(); + let result = snapshot_from_parts(credits, None); + assert!(result.usage.primary.is_informational); + assert!(result.usage.primary.window_minutes.is_none()); + assert!( + result + .usage + .extra_rate_windows + .iter() + .all(|w| w.window.is_informational) + ); + } + #[test] + fn costs_and_zero_counters_without_quota_are_informational() { + let credits = serde_json::from_value(json!({"currency":"USD", "balanceMicro":0})).unwrap(); + let usage = serde_json::from_value(json!({"currency":"USD", + "daily":{"costMicro":0,"requestCount":0}, + "weekly":{"costMicro":1000000,"totalTokens":50}, + "monthly":{"costMicro":2000000} + })) + .unwrap(); + let result = snapshot_from_parts(credits, Some(usage)); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.unwrap().is_informational); + assert!(result.usage.tertiary.unwrap().is_informational); + } +} diff --git a/rust/src/providers/deepgram/mod.rs b/rust/src/providers/deepgram/mod.rs index eed4bfec4af..8eb21b0577e 100644 --- a/rust/src/providers/deepgram/mod.rs +++ b/rust/src/providers/deepgram/mod.rs @@ -282,18 +282,15 @@ fn aggregate_summaries( } fn snapshot_from_summary(summary: &DeepgramUsageSummary) -> UsageSnapshot { - let mut primary = RateWindow::new(0.0); - primary.reset_description = Some(format!("{} requests", format_count(summary.requests))); + let primary = RateWindow::informational(format!("{} requests", format_count(summary.requests))); - let mut secondary = RateWindow::new(0.0); - secondary.reset_description = Some(format!( + let secondary = RateWindow::informational(format!( "{} audio hours / {} billable hours", format_decimal(summary.hours), format_decimal(summary.total_hours) )); - let mut tertiary = RateWindow::new(0.0); - tertiary.reset_description = Some(format!( + let tertiary = RateWindow::informational(format!( "{} tokens / {} TTS chars", format_count(summary.tokens_in + summary.tokens_out), format_count(summary.tts_characters) @@ -417,3 +414,26 @@ mod tests { assert_eq!(snapshot.login_method.as_deref(), Some("Project: Prod")); } } + +#[cfg(test)] +mod cx289b_tests { + use super::*; + #[test] + fn counters_without_quota_are_informational() { + let project = Project { + project_id: "synthetic".into(), + name: None, + }; + for raw in [ + "{}", + r#"{"results":[{"requests":0,"tokens_in":0,"tokens_out":0,"hours":0,"total_hours":0,"tts_characters":0}]}"#, + r#"{"results":[{"requests":10,"tokens_in":100,"tokens_out":50,"hours":2,"total_hours":3,"tts_characters":1000}]}"#, + ] { + let usage: UsageResponse = serde_json::from_str(raw).unwrap(); + let snapshot = snapshot_from_summary(&summary_from_usage(&project, &usage)); + assert!(snapshot.primary.is_informational); + assert!(snapshot.secondary.unwrap().is_informational); + assert!(snapshot.tertiary.unwrap().is_informational); + } + } +} diff --git a/rust/src/providers/deepinfra/mod.rs b/rust/src/providers/deepinfra/mod.rs index b8ab5e2600b..218d1592319 100644 --- a/rust/src/providers/deepinfra/mod.rs +++ b/rust/src/providers/deepinfra/mod.rs @@ -87,22 +87,6 @@ impl DeepInfraSnapshot { } fn to_usage_snapshot(&self) -> UsageSnapshot { - // Upstream #2822: with a positive spending limit configured, the - // primary percent reflects billing-cycle spend against that limit so - // the automatic menu-bar metric tracks it; balance-depletion stays - // the hard 100% signal. - let used_percent = - if self.suspended || self.amount_owed_usd > 0.0 || self.available_balance_usd <= 0.0 { - 100.0 - } else if let Some(limit) = self - .spending_limit_usd - .filter(|limit| *limit > 0.0 && limit.is_finite()) - { - ((self.recent_cost_usd / limit) * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - }; - let balance_text = if self.amount_owed_usd > 0.0 { format!("${:.2} owed", self.amount_owed_usd) } else { @@ -123,8 +107,18 @@ impl DeepInfraSnapshot { format!("{balance_text} · {spending_text}") }; - let mut primary = RateWindow::new(used_percent); - primary.reset_description = Some(detail); + let primary = match self + .spending_limit_usd + .filter(|limit| *limit > 0.0 && limit.is_finite()) + { + Some(limit) => RateWindow::with_details( + (self.recent_cost_usd / limit * 100.0).clamp(0.0, 100.0), + None, + None, + Some(detail), + ), + None => RateWindow::informational(detail), + }; UsageSnapshot::new(primary).with_login_method(balance_text) } @@ -364,7 +358,7 @@ mod tests { } #[test] - fn spend_over_limit_clamps_to_100_and_zero_limit_stays_binary() { + fn spend_over_limit_clamps_to_100_and_zero_limit_is_informational() { // Spend beyond the positive limit clamps at 100% instead of exceeding. let snapshot = parse_snapshot_for_testing( &checklist_json(-50.0, 30.0, Some(20.0), false, None), @@ -373,13 +367,13 @@ mod tests { .unwrap(); assert_eq!(snapshot.to_usage_snapshot().primary.used_percent, 100.0); - // No (or non-positive) limit keeps the legacy binary balance signal. + // Without a positive limit, balance is information rather than a quota. let snapshot = parse_snapshot_for_testing( &checklist_json(-50.0, 1.0, Some(0.0), false, None), &usage_json(100.0), ) .unwrap(); - assert_eq!(snapshot.to_usage_snapshot().primary.used_percent, 0.0); + assert!(snapshot.to_usage_snapshot().primary.is_informational); } #[test] @@ -395,7 +389,7 @@ mod tests { assert_eq!(snapshot.spending_limit_usd, None); let usage = snapshot.to_usage_snapshot(); - assert_eq!(usage.primary.used_percent, 100.0); + assert!(usage.primary.is_informational); assert_eq!( usage.primary.reset_description.as_deref(), Some("$9.75 owed · $6.50 spent this month") @@ -404,7 +398,7 @@ mod tests { } #[test] - fn suspended_account_is_marked_exhausted() { + fn suspended_account_is_reported_without_a_quota() { let snapshot = parse_snapshot_for_testing( &checklist_json(-5.0, 1.0, None, true, Some("Payment review")), &usage_json(100.0), @@ -412,7 +406,7 @@ mod tests { .unwrap() .to_usage_snapshot(); - assert_eq!(snapshot.primary.used_percent, 100.0); + assert!(snapshot.primary.is_informational); assert!( snapshot .primary @@ -459,3 +453,82 @@ mod tests { assert!(!provider.metadata().default_enabled); } } + +#[cfg(test)] +mod section_b_tests { + use super::*; + + #[test] + fn balance_without_limit_is_informational() { + for (balance, recent, suspended) in + [(-20.0, 3.0, false), (2.0, 1.0, false), (-20.0, 0.0, true)] + { + let checklist = serde_json::json!({"stripe_balance": balance, "recent": recent, "suspended": suspended}); + let snapshot = parse_snapshot_for_testing(&checklist.to_string(), r#"{"months":[]}"#) + .unwrap() + .to_usage_snapshot(); + assert!(snapshot.primary.is_informational); + assert!(snapshot.primary.window_minutes.is_none()); + assert!(snapshot.primary.resets_at.is_none()); + } + } + + #[test] + fn actual_zero_over_limit_remains_quota() { + let snapshot = parse_snapshot_for_testing( + r#"{"stripe_balance":-20,"recent":0,"limit":10}"#, + r#"{"months":[]}"#, + ) + .unwrap() + .to_usage_snapshot(); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, 0.0); + } + + #[test] + fn depleted_or_suspended_account_preserves_measured_quota() { + for checklist in [ + r#"{"stripe_balance":2,"recent":1,"limit":10}"#, + r#"{"stripe_balance":-20,"recent":1,"limit":10,"suspended":true}"#, + ] { + let snapshot = parse_snapshot_for_testing(checklist, r#"{"months":[]}"#) + .unwrap() + .to_usage_snapshot(); + assert!(!snapshot.primary.is_informational, "{checklist}"); + assert_eq!(snapshot.primary.used_percent, 10.0, "{checklist}"); + } + } + + #[test] + fn missing_billing_fields_are_rejected() { + assert!(parse_snapshot_for_testing(r#"{"limit":10}"#, r#"{"months":[]}"#).is_err()); + } +} + +#[cfg(test)] +mod p1_15_account_status_does_not_override_measured_usage_tests { + use super::*; + #[test] + fn review16_p1_15_account_status_does_not_override_measured_usage() { + for recent in [0, 1] { + for (balance, suspended) in [(-20, true), (0, false)] { + let value = serde_json::json!({"stripe_balance":balance,"recent":recent,"limit":10,"suspended":suspended}); + let parsed = + parse_snapshot_for_testing(&value.to_string(), r#"{"months":[]}"#).unwrap(); + let snapshot = parsed.to_usage_snapshot(); + assert!(!snapshot.primary.is_informational); + assert_eq!(snapshot.primary.used_percent, f64::from(recent) * 10.0); + assert_eq!(parsed.to_cost_snapshot().unwrap().used, f64::from(recent)); + if suspended { + assert!( + snapshot + .primary + .reset_description + .unwrap() + .contains("Suspended") + ); + } + } + } + } +} diff --git a/rust/src/providers/deepseek/mod.rs b/rust/src/providers/deepseek/mod.rs index 1ff6683c4df..d89a30cd9b4 100644 --- a/rust/src/providers/deepseek/mod.rs +++ b/rust/src/providers/deepseek/mod.rs @@ -261,8 +261,7 @@ impl DeepSeekProvider { let selected = select_balance_info(&balance.balance_infos).cloned(); let Some(info) = selected else { - let mut window = RateWindow::new(100.0); - window.reset_description = Some("No balance information returned".to_string()); + let window = RateWindow::informational("No balance information returned"); return UsageSnapshot::new(window).with_login_method("Balance unavailable"); }; @@ -271,11 +270,7 @@ impl DeepSeekProvider { let topped_up = parse_money(&info.topped_up_balance); let symbol = currency_symbol(&info.currency); - let mut window = RateWindow::new(if !balance.is_available || total <= 0.0 { - 100.0 - } else { - 0.0 - }); + let mut window = RateWindow::informational("Balance unavailable"); window.reset_description = if !balance.is_available { Some("Balance unavailable for API calls".to_string()) @@ -303,57 +298,42 @@ impl DeepSeekProvider { usage = usage.with_extra_rate_window( "tokens-today", "Tokens today", - RateWindow::with_details( - 0.0, - Some(24 * 60), - None, - Some(format_count(summary.today_tokens)), - ), + RateWindow::informational(format_count(summary.today_tokens)), ); } if summary.month_tokens > 0.0 { usage = usage.with_extra_rate_window( "tokens-month", "Tokens (month)", - RateWindow::with_details(0.0, None, None, Some(format_count(summary.month_tokens))), + RateWindow::informational(format_count(summary.month_tokens)), ); } if summary.today_requests > 0.0 { usage = usage.with_extra_rate_window( "requests-today", "Requests today", - RateWindow::with_details( - 0.0, - Some(24 * 60), - None, - Some(format_count(summary.today_requests)), - ), + RateWindow::informational(format_count(summary.today_requests)), ); } if summary.month_requests > 0.0 { usage = usage.with_extra_rate_window( "requests-month", "Requests (month)", - RateWindow::with_details( - 0.0, - None, - None, - Some(format_count(summary.month_requests)), - ), + RateWindow::informational(format_count(summary.month_requests)), ); } if let Some(model) = summary.top_model.as_deref() { usage = usage.with_extra_rate_window( "top-model", "Top model", - RateWindow::with_details(0.0, None, None, Some(model.to_string())), + RateWindow::informational(model), ); } for (idx, (category, tokens)) in summary.category_tokens.iter().take(3).enumerate() { usage = usage.with_extra_rate_window( format!("category-{idx}"), format!("Category: {}", category_label(category)), - RateWindow::with_details(0.0, None, None, Some(format_count(*tokens))), + RateWindow::informational(format_count(*tokens)), ); } usage @@ -695,7 +675,7 @@ mod tests { ], }); - assert_eq!(snapshot.primary.used_percent, 100.0); + assert!(snapshot.primary.is_informational); assert_eq!( snapshot.primary.reset_description.as_deref(), Some("$0.00 — add credits at platform.deepseek.com") @@ -704,7 +684,7 @@ mod tests { } #[test] - fn exhausted_when_balance_unavailable() { + fn reports_unavailable_balance_as_information() { let snapshot = DeepSeekProvider::snapshot_from_balance(BalanceResponse { is_available: false, balance_infos: vec![BalanceInfo { @@ -715,7 +695,7 @@ mod tests { }], }); - assert_eq!(snapshot.primary.used_percent, 100.0); + assert!(snapshot.primary.is_informational); assert_eq!( snapshot.primary.reset_description.as_deref(), Some("Balance unavailable for API calls") @@ -794,3 +774,45 @@ mod tests { ); } } + +#[cfg(test)] +mod section_b_tests { + use super::*; + + #[test] + fn balances_and_missing_balance_are_not_quotas() { + for body in [ + r#"{}"#, + r#"{"is_available":true,"balance_infos":[{"currency":"USD","total_balance":"10","granted_balance":"2","topped_up_balance":"8"}]}"#, + r#"{"is_available":false,"balance_infos":[{"currency":"USD","total_balance":"0","granted_balance":"0","topped_up_balance":"0"}]}"#, + ] { + let snapshot = + DeepSeekProvider::snapshot_from_balance(serde_json::from_str(body).unwrap()); + assert!(snapshot.primary.is_informational, "{body}"); + assert!(snapshot.primary.resets_at.is_none()); + } + } + + #[test] + fn activity_and_model_rows_are_informational() { + let summary = DeepSeekUsageSummary { + today_tokens: 2.0, + month_tokens: 5.0, + today_requests: 1.0, + month_requests: 3.0, + top_model: Some("deepseek-chat".into()), + category_tokens: vec![("input".into(), 2.0)], + ..Default::default() + }; + let snapshot = DeepSeekProvider::apply_usage_summary( + UsageSnapshot::new(RateWindow::informational("Balance")), + &summary, + ); + assert_eq!(snapshot.extra_rate_windows.len(), 6); + for row in snapshot.extra_rate_windows { + assert!(row.window.is_informational, "{}", row.id); + assert!(row.window.window_minutes.is_none()); + assert!(row.window.resets_at.is_none()); + } + } +} diff --git a/rust/src/providers/devin/mod.rs b/rust/src/providers/devin/mod.rs index 24c0ce4def4..67308b058bc 100644 --- a/rust/src/providers/devin/mod.rs +++ b/rust/src/providers/devin/mod.rs @@ -97,7 +97,7 @@ impl Provider for DevinProvider { let value: Value = response.json().await.map_err(|e| { ProviderError::Parse(format!("Failed to parse Devin quota: {e}")) })?; - Ok(fetch_result_from_quota(&value, &org)) + fetch_result_from_quota(&value, &org) } SourceMode::Web | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) @@ -145,23 +145,32 @@ fn normalized_org(raw: &str) -> String { } } -fn snapshot_from_quota(value: &Value, org: &str) -> UsageSnapshot { +fn snapshot_from_quota(value: &Value, org: &str) -> Result { let daily = percent(value, &["daily_percentage", "dailyPercentage"]) - .unwrap_or_else(|| percent(value, &["used_percent", "usedPercent"]).unwrap_or(0.0)); - let mut snapshot = - UsageSnapshot::new(RateWindow::new(daily)).with_organization(org.to_string()); - if let Some(weekly) = percent(value, &["weekly_percentage", "weeklyPercentage"]) { + .or_else(|| percent(value, &["used_percent", "usedPercent"])) + .or_else(|| generic_percent(value)); + let weekly = percent(value, &["weekly_percentage", "weeklyPercentage"]); + if daily.is_none() && weekly.is_none() && extra_usage_balance(value).is_none() { + return Err(ProviderError::Parse( + "Devin response contains no usable quota".into(), + )); + } + let primary = daily + .map(RateWindow::new) + .unwrap_or_else(|| RateWindow::informational("Daily usage unavailable")); + let mut snapshot = UsageSnapshot::new(primary).with_organization(org.to_string()); + if let Some(weekly) = weekly { snapshot = snapshot.with_secondary(RateWindow::new(weekly)); } - snapshot + Ok(snapshot) } -fn fetch_result_from_quota(value: &Value, org: &str) -> ProviderFetchResult { - let mut result = ProviderFetchResult::new(snapshot_from_quota(value, org), "api"); +fn fetch_result_from_quota(value: &Value, org: &str) -> Result { + let mut result = ProviderFetchResult::new(snapshot_from_quota(value, org)?, "api"); if let Some(balance) = extra_usage_balance(value) { result = result.with_cost(CostSnapshot::new(balance, "USD", "Extra usage balance")); } - result + Ok(result) } fn percent(value: &Value, keys: &[&str]) -> Option { @@ -170,6 +179,10 @@ fn percent(value: &Value, keys: &[&str]) -> Option { return Some(if v < 1.0 { v * 100.0 } else { v }); } } + None +} + +fn generic_percent(value: &Value) -> Option { let used = ["used", "usage", "used_count", "usedCount", "consumed"] .iter() .find_map(|k| value.get(*k).and_then(Value::as_f64)); @@ -208,14 +221,14 @@ mod tests { #[test] fn parses_fraction_percent() { let snapshot = - snapshot_from_quota(&serde_json::json!({"daily_percentage":0.25}), "org/demo"); + snapshot_from_quota(&serde_json::json!({"daily_percentage":0.25}), "org/demo").unwrap(); assert_eq!(snapshot.primary.used_percent, 25.0); } #[test] fn parses_exact_one_as_one_percent() { let snapshot = - snapshot_from_quota(&serde_json::json!({"daily_percentage":1.0}), "org/demo"); + snapshot_from_quota(&serde_json::json!({"daily_percentage":1.0}), "org/demo").unwrap(); assert_eq!(snapshot.primary.used_percent, 1.0); } @@ -224,7 +237,8 @@ mod tests { let result = fetch_result_from_quota( &serde_json::json!({"daily_percentage": 0.2, "overage_balance": 12.34}), "org/demo", - ); + ) + .unwrap(); let cost = result.cost.unwrap(); assert_eq!(cost.used, 12.34); @@ -236,7 +250,8 @@ mod tests { let result = fetch_result_from_quota( &serde_json::json!({"daily_percentage": 0.2, "overage_balance_cents": 7087}), "org/demo", - ); + ) + .unwrap(); assert_eq!(result.cost.unwrap().used, 70.87); } @@ -278,3 +293,65 @@ mod tests { assert!(auth_response_error(reqwest::StatusCode::NOT_FOUND, body).is_none()); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::json; + fn build(value: Value) -> Result { + snapshot_from_quota(&value, "org/synthetic") + } + #[test] + fn cx289a_missing_quota_is_error() { + for value in [json!({}), json!({"limit":100}), json!({"used":0})] { + assert!(build(value.clone()).is_err(), "{value}"); + } + } + #[test] + fn cx289a_weekly_only_keeps_unknown_primary() { + let snap = build(json!({"weekly_percentage":25})).unwrap(); + assert!(snap.primary.is_informational); + assert_eq!(snap.secondary.unwrap().used_percent, 25.0); + } + #[test] + fn cx289a_real_zero_is_quota() { + for value in [json!({"used":0,"limit":100}), json!({"daily_percentage":0})] { + let snap = build(value).unwrap(); + assert!(!snap.primary.is_informational); + assert_eq!(snap.primary.used_percent, 0.0); + } + } +} + +#[cfg(test)] +mod p1_02_balance_without_quota_survives_tests { + use super::*; + #[test] + fn review16_p1_02_balance_without_quota_survives() { + for (value, amount) in [ + (serde_json::json!({"overage_balance":12.34}), 12.34), + (serde_json::json!({"overage_balance_cents":7087}), 70.87), + ] { + let result = fetch_result_from_quota(&value, "org/demo").unwrap(); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.is_none()); + assert_eq!(result.cost.unwrap().used, amount); + } + } +} + +#[cfg(test)] +mod p1_12_generic_counter_has_no_weekly_duplicate_tests { + use super::*; + #[test] + fn review16_p1_12_generic_counter_has_no_weekly_duplicate() { + for used in [0, 25] { + let snapshot = + snapshot_from_quota(&serde_json::json!({"used":used,"limit":100}), "org/demo") + .unwrap(); + assert_eq!(snapshot.primary.used_percent, f64::from(used)); + assert!(!snapshot.primary.is_informational); + assert!(snapshot.secondary.is_none()); + } + } +} diff --git a/rust/src/providers/doubao/mod.rs b/rust/src/providers/doubao/mod.rs index f448fd7f9f3..ef383a608d2 100644 --- a/rust/src/providers/doubao/mod.rs +++ b/rust/src/providers/doubao/mod.rs @@ -75,9 +75,9 @@ impl DoubaoProvider { for model in PROBE_MODELS { match self.probe(api_key, model).await { Ok(result) => { - return Ok(self + return self .confirm_ambiguous_zero_remaining(api_key, model, result) - .await); + .await; } Err(error @ ProviderError::AuthRequired) => return Err(error), Err(error) => { @@ -120,7 +120,7 @@ impl DoubaoProvider { ))); } - Ok(coding_plan_snapshot(decode_coding_plan_usage(&bytes)?)) + coding_plan_snapshot(decode_coding_plan_usage(&bytes)?) } async fn confirm_ambiguous_zero_remaining( @@ -128,7 +128,7 @@ impl DoubaoProvider { api_key: &str, model: &str, initial: DoubaoProbeResult, - ) -> UsageSnapshot { + ) -> Result { if !initial.has_ambiguous_zero_remaining() { return initial.snapshot; } @@ -137,13 +137,9 @@ impl DoubaoProvider { Ok(confirmation) if confirmation.status == reqwest::StatusCode::TOO_MANY_REQUESTS => { initial.snapshot } - Ok(confirmation) if confirmation.has_ambiguous_zero_remaining() => snapshot_from_parts( - confirmation.remaining, - confirmation.limit, - confirmation.resets_at, - confirmation.total_tokens, - false, - ), + Ok(confirmation) if confirmation.has_ambiguous_zero_remaining() => { + confirmation.without_request_quota() + } Ok(confirmation) => confirmation.snapshot, Err(error) => { tracing::warn!( @@ -188,16 +184,24 @@ impl DoubaoProvider { #[derive(Debug)] struct DoubaoProbeResult { - snapshot: UsageSnapshot, + snapshot: Result, status: reqwest::StatusCode, remaining: Option, limit: Option, resets_at: Option>, - total_tokens: Option, request_limits_reliable: bool, + tokens: Option, } impl DoubaoProbeResult { + /// The request quota is ambiguous: keep only what the probe observed. + fn without_request_quota(&self) -> Result { + match self.tokens { + Some(tokens) => Ok(UsageSnapshot::new(tokens_information(tokens))), + None => snapshot_from_parts(self.remaining, self.limit, self.resets_at, false), + } + } + fn has_ambiguous_zero_remaining(&self) -> bool { self.status == reqwest::StatusCode::OK && self.request_limits_reliable @@ -214,23 +218,27 @@ fn probe_result_from_response( let remaining = int_header(headers, "x-ratelimit-remaining-requests"); let limit = int_header(headers, "x-ratelimit-limit-requests"); let resets_at = string_header(headers, "x-ratelimit-reset-requests").and_then(parse_reset_time); - let total_tokens = body - .get("usage") - .and_then(|usage| usage.get("total_tokens")) - .and_then(|value| value.as_i64()); let request_limits_reliable = if status == reqwest::StatusCode::TOO_MANY_REQUESTS { limit.is_some() } else { limit.is_some() && remaining.is_some() }; - let snapshot = snapshot_from_parts( - remaining, - limit, - resets_at, - total_tokens, - request_limits_reliable, - ); + let mut snapshot = snapshot_from_parts(remaining, limit, resets_at, request_limits_reliable); + let tokens = body + .pointer("/usage/total_tokens") + .and_then(serde_json::Value::as_u64) + .filter(|_| status.is_success()); + if let Some(tokens) = tokens { + snapshot = Ok(match snapshot { + Ok(snapshot) => snapshot.with_extra_rate_window( + "probe", + "Probe", + RateWindow::informational(format!("{tokens} tokens observed in probe")), + ), + Err(_) => UsageSnapshot::new(tokens_information(tokens)), + }); + } DoubaoProbeResult { snapshot, @@ -238,47 +246,40 @@ fn probe_result_from_response( remaining, limit, resets_at, - total_tokens, request_limits_reliable, + tokens, } } +fn tokens_information(tokens: u64) -> RateWindow { + RateWindow::informational(format!( + "{tokens} tokens observed in probe; request quota unavailable" + )) +} + fn snapshot_from_parts( remaining: Option, limit: Option, resets_at: Option>, - total_tokens: Option, request_limits_reliable: bool, -) -> UsageSnapshot { - let effective_remaining = remaining.unwrap_or(0); - - let (used_percent, detail) = if let (Some(remaining), Some(limit)) = (remaining, limit) { - if request_limits_reliable && limit > 0 { - let used = (limit - remaining).max(0); - let percent = used as f64 / limit as f64 * 100.0; - (percent, format!("{used}/{limit} requests")) - } else { - (0.0, "Active - check dashboard for details".to_string()) - } - } else if let Some(limit) = limit.filter(|limit| request_limits_reliable && *limit > 0) { - let used = (limit - effective_remaining).max(0); - let percent = if limit > 0 { - used as f64 / limit as f64 * 100.0 - } else { - 0.0 - }; - (percent, format!("{used}/{limit} requests")) - } else if let Some(total_tokens) = total_tokens { - (0.0, format!("Active - {total_tokens} tokens observed")) - } else { - (0.0, "Active - check dashboard for details".to_string()) +) -> Result { + let (Some(remaining), Some(limit)) = (remaining, limit.filter(|limit| *limit > 0)) else { + return Err(ProviderError::Parse( + "Doubao response contains no usable request quota".into(), + )); }; - - let mut window = RateWindow::with_details(used_percent, None, resets_at, Some(detail)); - if window.used_percent.is_nan() { - window.used_percent = 0.0; + if !request_limits_reliable { + return Err(ProviderError::Parse( + "Doubao request quota is not reliable".into(), + )); } - UsageSnapshot::new(window) + let used = (limit - remaining).max(0); + Ok(UsageSnapshot::new(RateWindow::with_details( + used as f64 / limit as f64 * 100.0, + None, + resets_at, + Some(format!("{used}/{limit} requests")), + ))) } fn int_header(headers: &reqwest::header::HeaderMap, name: &str) -> Option { @@ -422,13 +423,13 @@ fn decode_coding_plan_usage(bytes: &[u8]) -> Result UsageSnapshot { +fn coding_plan_snapshot(usage: CodingPlanResult) -> Result { let primary = coding_plan_window( &usage, &["session", "5-hour", "five_hour", "5h"], Some(5 * 60), ) - .unwrap_or_else(|| RateWindow::new(0.0)); + .unwrap_or_else(|| RateWindow::informational("Session usage unavailable")); let mut snapshot = UsageSnapshot::new(primary); if let Some(weekly) = coding_plan_window(&usage, &["weekly", "week"], Some(7 * 24 * 60)) { snapshot = snapshot.with_secondary(weekly); @@ -481,13 +482,22 @@ fn coding_plan_snapshot(usage: CodingPlanResult) -> UsageSnapshot { )); } } + if snapshot.primary.is_informational + && snapshot.secondary.is_none() + && snapshot.tertiary.is_none() + && snapshot.extra_rate_windows.is_empty() + { + return Err(ProviderError::Parse( + "Doubao Coding Plan contains no usable quota".into(), + )); + } if let Some(status) = usage.status.filter(|s| !s.trim().is_empty()) { snapshot = snapshot.with_login_method(status); } if let Some(update) = usage.update_timestamp.and_then(datetime_from_epoch) { snapshot.updated_at = update; } - snapshot + Ok(snapshot) } fn coding_plan_window( @@ -745,7 +755,7 @@ fn decode_arkcli_usage(bytes: &[u8]) -> Result fn fetch_arkcli_usage() -> Result { let stdout = run_arkcli_usage_plan()?; let usage = decode_arkcli_usage(&stdout)?; - Ok(coding_plan_snapshot(usage)) + coding_plan_snapshot(usage) } fn datetime_from_epoch(timestamp: f64) -> Option> { @@ -989,13 +999,14 @@ mod tests { "x-ratelimit-limit-requests", HeaderValue::from_static("100"), ); - let snapshot = - probe_result_from_response(reqwest::StatusCode::OK, &headers, &json!({})).snapshot; + let snapshot = probe_result_from_response(reqwest::StatusCode::OK, &headers, &json!({})) + .snapshot + .unwrap(); assert_eq!(snapshot.primary.used_percent, 75.0); } #[test] - fn doubao_repeated_successful_zero_remaining_falls_back_to_active() { + fn doubao_repeated_successful_zero_remaining_is_unavailable() { let mut headers = HeaderMap::new(); headers.insert( "x-ratelimit-remaining-requests", @@ -1009,22 +1020,12 @@ mod tests { let result = probe_result_from_response(reqwest::StatusCode::OK, &headers, &json!({})); assert!(result.has_ambiguous_zero_remaining()); - let snapshot = snapshot_from_parts( - result.remaining, - result.limit, - result.resets_at, - result.total_tokens, - false, - ); - assert_eq!(snapshot.primary.used_percent, 0.0); - assert_eq!( - snapshot.primary.reset_description.as_deref(), - Some("Active - check dashboard for details") - ); + let snapshot = snapshot_from_parts(result.remaining, result.limit, result.resets_at, false); + assert!(snapshot.is_err()); } #[test] - fn doubao_rate_limit_with_limit_header_reports_exhausted() { + fn doubao_rate_limit_without_remaining_is_unavailable() { let mut headers = HeaderMap::new(); headers.insert( "x-ratelimit-limit-requests", @@ -1037,15 +1038,11 @@ mod tests { ) .snapshot; - assert_eq!(snapshot.primary.used_percent, 100.0); - assert_eq!( - snapshot.primary.reset_description.as_deref(), - Some("1000/1000 requests") - ); + assert!(snapshot.is_err()); } #[test] - fn doubao_bare_rate_limit_uses_active_fallback() { + fn doubao_bare_rate_limit_is_unavailable() { let snapshot = probe_result_from_response( reqwest::StatusCode::TOO_MANY_REQUESTS, &HeaderMap::new(), @@ -1053,11 +1050,7 @@ mod tests { ) .snapshot; - assert_eq!(snapshot.primary.used_percent, 0.0); - assert_eq!( - snapshot.primary.reset_description.as_deref(), - Some("Active - check dashboard for details") - ); + assert!(snapshot.is_err()); } #[test] @@ -1073,7 +1066,7 @@ mod tests { ] } }"#; - let snapshot = coding_plan_snapshot(decode_coding_plan_usage(body).unwrap()); + let snapshot = coding_plan_snapshot(decode_coding_plan_usage(body).unwrap()).unwrap(); assert_eq!(snapshot.primary.used_percent, 12.5); assert_eq!(snapshot.secondary.unwrap().used_percent, 50.0); let monthly = snapshot.tertiary.expect("monthly"); @@ -1145,7 +1138,7 @@ mod tests { ] }"#; let usage = decode_arkcli_usage(raw).expect("arkcli json"); - let snap = coding_plan_snapshot(usage); + let snap = coding_plan_snapshot(usage).unwrap(); assert!((snap.primary.used_percent - 12.5).abs() < 0.01); assert!((snap.secondary.as_ref().unwrap().used_percent - 40.0).abs() < 0.01); assert!( @@ -1186,3 +1179,99 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + fn parts(remaining: Option, limit: Option) -> Result { + snapshot_from_parts(remaining, limit, None, true) + } + fn coding(body: &[u8]) -> Result { + coding_plan_snapshot(decode_coding_plan_usage(body)?) + } + #[test] + fn cx289a_missing_request_fields_are_error() { + for (remaining, limit) in [(None, None), (None, Some(100)), (Some(100), None)] { + assert!(parts(remaining, limit).is_err()); + } + } + #[test] + fn cx289a_empty_coding_plan_is_error() { + assert!(coding(br#"{"Result":{}}"#).is_err()); + } + #[test] + fn cx289a_weekly_only_keeps_unknown_primary() { + let snap = + coding(br#"{"Result":{"QuotaUsage":[{"Level":"weekly","Percent":0}]}}"#).unwrap(); + assert!(snap.primary.is_informational); + assert_eq!(snap.secondary.unwrap().used_percent, 0.0); + } + #[test] + fn cx289a_real_zero_is_quota() { + let snap = parts(Some(100), Some(100)).unwrap(); + assert!(!snap.primary.is_informational); + assert_eq!(snap.primary.used_percent, 0.0); + let snap = + coding(br#"{"Result":{"QuotaUsage":[{"Level":"session","Percent":0}]}}"#).unwrap(); + assert!(!snap.primary.is_informational); + assert_eq!(snap.primary.used_percent, 0.0); + } +} + +#[cfg(test)] +mod p1_03_probe_tokens_are_information_tests { + use super::*; + #[test] + fn review16_p1_03_probe_tokens_are_information() { + let result = probe_result_from_response( + reqwest::StatusCode::OK, + &reqwest::header::HeaderMap::new(), + &serde_json::json!({"usage":{"total_tokens":7}}), + ); + let snapshot = result.snapshot.unwrap(); + assert!(snapshot.primary.is_informational); + assert!( + snapshot + .primary + .reset_description + .unwrap() + .contains("7 tokens") + ); + assert!(snapshot.primary.resets_at.is_none()); + assert!(snapshot.primary.window_minutes.is_none()); + assert!(!result.request_limits_reliable); + } + + #[test] + fn review16_p2_ambiguous_zero_remaining_keeps_observed_tokens() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-ratelimit-limit-requests", "100".parse().unwrap()); + headers.insert("x-ratelimit-remaining-requests", "0".parse().unwrap()); + let result = probe_result_from_response( + reqwest::StatusCode::OK, + &headers, + &serde_json::json!({"usage":{"total_tokens":7}}), + ); + assert!(result.has_ambiguous_zero_remaining()); + let snapshot = result.without_request_quota().unwrap(); + assert!(snapshot.primary.is_informational); + assert!( + snapshot + .primary + .reset_description + .unwrap() + .contains("7 tokens") + ); + assert!(snapshot.extra_rate_windows.is_empty()); + } + + #[test] + fn review16_p2_ambiguous_zero_remaining_without_tokens_stays_error() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-ratelimit-limit-requests", "100".parse().unwrap()); + headers.insert("x-ratelimit-remaining-requests", "0".parse().unwrap()); + let result = + probe_result_from_response(reqwest::StatusCode::OK, &headers, &serde_json::json!({})); + assert!(result.without_request_quota().is_err()); + } +} diff --git a/rust/src/providers/factory/mod.rs b/rust/src/providers/factory/mod.rs index b95b0d7db1a..54257499cda 100755 --- a/rust/src/providers/factory/mod.rs +++ b/rust/src/providers/factory/mod.rs @@ -57,14 +57,10 @@ struct FactoryUsageWindow { } impl FactoryUsageWindow { - fn percent_used(&self) -> f64 { - let used = self.used.unwrap_or(0.0); - let allowance = self.allowance.unwrap_or(1.0); - if allowance > 0.0 { - ((used / allowance) * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - } + fn percent_used(&self) -> Option { + let used = self.used?; + let allowance = self.allowance.filter(|allowance| *allowance > 0.0)?; + Some(((used / allowance) * 100.0).clamp(0.0, 100.0)) } } @@ -78,22 +74,18 @@ struct FactoryTokenUsage { } impl FactoryTokenUsage { - fn percent_used(&self) -> f64 { + fn percent_used(&self) -> Option { if let Some(ratio) = self.used_ratio.filter(|r| r.is_finite()) { if (-0.001..=1.001).contains(&ratio) { - return (ratio * 100.0).clamp(0.0, 100.0); + return Some((ratio * 100.0).clamp(0.0, 100.0)); } if (-0.1..=100.1).contains(&ratio) { - return ratio.clamp(0.0, 100.0); + return Some(ratio.clamp(0.0, 100.0)); } } - let used = self.user_tokens.unwrap_or(0) as f64; - let allowance = self.total_allowance.unwrap_or(0) as f64; - if allowance > 0.0 { - ((used / allowance) * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - } + let used = self.user_tokens? as f64; + let allowance = self.total_allowance.filter(|allowance| *allowance > 0)? as f64; + Some(((used / allowance) * 100.0).clamp(0.0, 100.0)) } } @@ -124,21 +116,11 @@ struct FactoryLimitPool { monthly: FactoryBillingWindow, } -impl FactoryLimitPool { - fn has_usage_data(&self) -> bool { - [&self.five_hour, &self.weekly, &self.monthly] - .iter() - .any(|w| { - w.used_percent > 0.0 || w.window_end.is_some() || w.seconds_remaining.is_some() - }) - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FactoryBillingWindow { #[serde(default)] - used_percent: f64, + used_percent: Option, #[serde(default)] window_end: Option, #[serde(default)] @@ -146,7 +128,8 @@ struct FactoryBillingWindow { } impl FactoryBillingWindow { - fn rate_window(&self, window_minutes: Option) -> RateWindow { + fn rate_window(&self, window_minutes: Option) -> Option { + let used_percent = self.used_percent?; let resets_at = self .seconds_remaining .filter(|s| *s > 0.0 && s.is_finite()) @@ -161,7 +144,7 @@ impl FactoryBillingWindow { Utc::now() + Duration::milliseconds(millis) }); let mut window = RateWindow::with_details( - self.used_percent.clamp(0.0, 100.0), + used_percent.clamp(0.0, 100.0), window_minutes, resets_at, None, @@ -169,7 +152,7 @@ impl FactoryBillingWindow { if let Some(at) = resets_at { window.reset_description = Some(format!("Resets {}", at.format("%b %d at %H:%M UTC"))); } - window + Some(window) } } @@ -521,7 +504,7 @@ impl FactoryProvider { .await) .ok(); return Ok(Self::apply_auth_info( - snapshot_from_billing_limits(&limits, billing.overage_preference.as_deref()), + snapshot_from_billing_limits(&limits, billing.overage_preference.as_deref())?, auth, )); } @@ -566,7 +549,7 @@ impl FactoryProvider { .fetch_usage_api(client, base, None, Some(api_key)) .await?; Ok(Self::apply_auth_info( - Self::usage_snapshot_from_response(&usage_data), + Self::usage_snapshot_from_response(&usage_data)?, auth_info, )) } @@ -584,41 +567,49 @@ impl FactoryProvider { .await?; Ok(Self::apply_auth_info( - Self::usage_snapshot_from_response(&usage_data), + Self::usage_snapshot_from_response(&usage_data)?, auth_info, )) } - fn usage_snapshot_from_response(usage_data: &FactoryUsageResponse) -> UsageSnapshot { - // Prefer nested upstream `usage` only when it carries usable data. - // An empty `usage: {}` must not suppress populated top-level windows. - if let Some(nested) = &usage_data.usage - && nested_usage_has_data(nested) - { - let standard_percent = nested - .standard - .as_ref() - .map(FactoryTokenUsage::percent_used) - .unwrap_or(0.0); - let mut usage = UsageSnapshot::new(RateWindow::new(standard_percent)); - if let Some(premium) = &nested.premium { - usage = usage.with_secondary(RateWindow::new(premium.percent_used())); - } - return usage; - } - - let standard_percent = usage_data - .standard + fn usage_snapshot_from_response( + usage_data: &FactoryUsageResponse, + ) -> Result { + let standard = usage_data + .usage .as_ref() - .map(FactoryUsageWindow::percent_used) - .unwrap_or(0.0); - - let mut usage = UsageSnapshot::new(RateWindow::new(standard_percent)); - if let Some(premium) = &usage_data.premium { - usage = usage.with_secondary(RateWindow::new(premium.percent_used())); + .and_then(|nested| nested.standard.as_ref()) + .and_then(FactoryTokenUsage::percent_used) + .or_else(|| { + usage_data + .standard + .as_ref() + .and_then(FactoryUsageWindow::percent_used) + }); + let premium = usage_data + .usage + .as_ref() + .and_then(|nested| nested.premium.as_ref()) + .and_then(FactoryTokenUsage::percent_used) + .or_else(|| { + usage_data + .premium + .as_ref() + .and_then(FactoryUsageWindow::percent_used) + }); + if standard.is_none() && premium.is_none() { + return Err(ProviderError::Parse( + "Factory response contains no usable quota".into(), + )); } - - usage + let primary = standard + .map(RateWindow::new) + .unwrap_or_else(|| RateWindow::informational("Standard usage unavailable")); + let mut usage = UsageSnapshot::new(primary); + if let Some(premium) = premium { + usage = usage.with_secondary(RateWindow::new(premium)); + } + Ok(usage) } fn apply_auth_info( @@ -689,31 +680,21 @@ impl FactoryProvider { } } -fn nested_usage_has_data(nested: &FactoryUsageNested) -> bool { - let window_has = |w: &FactoryTokenUsage| { - w.user_tokens.is_some() - || w.total_allowance.is_some() - || w.used_ratio.is_some_and(|r| r.is_finite()) - }; - nested.standard.as_ref().is_some_and(window_has) - || nested.premium.as_ref().is_some_and(window_has) -} - fn snapshot_from_billing_limits( limits: &FactoryTokenRateLimits, overage_preference: Option<&str>, -) -> UsageSnapshot { +) -> Result { let primary = limits.standard.five_hour.rate_window(Some(5 * 60)); let secondary = limits.standard.weekly.rate_window(Some(7 * 24 * 60)); let tertiary = limits.standard.monthly.rate_window(None); - let mut usage = UsageSnapshot::new(primary) - .with_secondary(secondary) - .with_tertiary(tertiary); + let mut usage = UsageSnapshot::new( + primary.unwrap_or_else(|| RateWindow::informational("5-hour usage unavailable")), + ); + usage.secondary = secondary; + usage.tertiary = tertiary; - if let Some(core) = &limits.core - && core.has_usage_data() - { + if let Some(core) = &limits.core { for (id, title, window) in [ ( "factory-core-5h", @@ -731,15 +712,26 @@ fn snapshot_from_billing_limits( core.monthly.rate_window(None), ), ] { - usage = usage.with_extra_rate_window(id, title, window); + if let Some(window) = window { + usage = usage.with_extra_rate_window(id, title, window); + } } } + if usage.primary.is_informational + && usage.secondary.is_none() + && usage.tertiary.is_none() + && usage.extra_rate_windows.is_empty() + { + return Err(ProviderError::Parse( + "Factory billing limits contain no usable quota".into(), + )); + } let mut login = "Droid".to_string(); if let Some(pref) = overage_preference.filter(|p| !p.is_empty()) { login = format!("Droid - Fallback: {pref}"); } - usage.with_login_method(login) + Ok(usage.with_login_method(login)) } impl Default for FactoryProvider { @@ -907,7 +899,7 @@ mod tests { let parsed: FactoryBillingLimitsResponse = serde_json::from_str(body).unwrap(); assert!(parsed.uses_token_rate_limits_billing); let limits = parsed.limits.unwrap(); - let snap = snapshot_from_billing_limits(&limits, None); + let snap = snapshot_from_billing_limits(&limits, None).unwrap(); assert!((snap.primary.used_percent - 12.0).abs() < f64::EPSILON); assert!((snap.secondary.unwrap().used_percent - 34.0).abs() < f64::EPSILON); assert!((snap.tertiary.unwrap().used_percent - 56.0).abs() < f64::EPSILON); @@ -920,7 +912,7 @@ mod tests { "premium": { "used": 10.0, "allowance": 50.0 } }"#; let parsed: FactoryUsageResponse = serde_json::from_str(body).unwrap(); - let snap = FactoryProvider::usage_snapshot_from_response(&parsed); + let snap = FactoryProvider::usage_snapshot_from_response(&parsed).unwrap(); assert!((snap.primary.used_percent - 25.0).abs() < f64::EPSILON); assert!((snap.secondary.unwrap().used_percent - 20.0).abs() < f64::EPSILON); } @@ -934,7 +926,7 @@ mod tests { } }"#; let parsed: FactoryUsageResponse = serde_json::from_str(body).unwrap(); - let snap = FactoryProvider::usage_snapshot_from_response(&parsed); + let snap = FactoryProvider::usage_snapshot_from_response(&parsed).unwrap(); assert!((snap.primary.used_percent - 30.0).abs() < f64::EPSILON); assert!((snap.secondary.unwrap().used_percent - 10.0).abs() < f64::EPSILON); } @@ -947,7 +939,7 @@ mod tests { "premium": { "used": 5.0, "allowance": 50.0 } }"#; let parsed: FactoryUsageResponse = serde_json::from_str(body).unwrap(); - let snap = FactoryProvider::usage_snapshot_from_response(&parsed); + let snap = FactoryProvider::usage_snapshot_from_response(&parsed).unwrap(); assert!((snap.primary.used_percent - 40.0).abs() < f64::EPSILON); assert!((snap.secondary.unwrap().used_percent - 10.0).abs() < f64::EPSILON); } @@ -1026,3 +1018,59 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + use serde_json::{Value, json}; + fn legacy(value: Value) -> Result { + FactoryProvider::usage_snapshot_from_response(&serde_json::from_value(value).unwrap()) + } + fn billing(value: Value) -> Result { + snapshot_from_billing_limits(&serde_json::from_value(value).unwrap(), None) + } + #[test] + fn cx289a_legacy_missing_quota_is_error() { + for value in [ + json!({}), + json!({"standard":{"allowance":100}}), + json!({"standard":{"used":0}}), + json!({"usage":{"standard":{"totalAllowance":100}}}), + json!({"usage":{"standard":{"userTokens":0}}}), + ] { + assert!(legacy(value.clone()).is_err(), "{value}"); + } + } + #[test] + fn cx289a_billing_missing_quota_is_error() { + assert!(billing(json!({"standard":{"fiveHour":{},"weekly":{},"monthly":{}}})).is_err()); + } + #[test] + fn cx289a_secondary_only_keeps_unknown_primary() { + let snap = legacy(json!({"premium":{"used":0,"allowance":100}})).unwrap(); + assert!(snap.primary.is_informational); + assert_eq!(snap.secondary.unwrap().used_percent, 0.0); + let snap = + billing(json!({"standard":{"fiveHour":{},"weekly":{"usedPercent":0},"monthly":{}}})) + .unwrap(); + assert!(snap.primary.is_informational); + assert_eq!(snap.secondary.unwrap().used_percent, 0.0); + } + #[test] + fn cx289a_real_zero_is_quota() { + for value in [ + json!({"standard":{"used":0,"allowance":100}}), + json!({"usage":{"standard":{"userTokens":0,"totalAllowance":100}}}), + json!({"usage":{"standard":{"usedRatio":0}}}), + ] { + let snap = legacy(value).unwrap(); + assert!(!snap.primary.is_informational); + assert_eq!(snap.primary.used_percent, 0.0); + } + let snap = + billing(json!({"standard":{"fiveHour":{"usedPercent":0},"weekly":{},"monthly":{}}})) + .unwrap(); + assert!(!snap.primary.is_informational); + assert_eq!(snap.primary.used_percent, 0.0); + } +} diff --git a/rust/src/providers/fireworks/mod.rs b/rust/src/providers/fireworks/mod.rs index 7e2b7231c84..df13fbfb9ec 100644 --- a/rust/src/providers/fireworks/mod.rs +++ b/rust/src/providers/fireworks/mod.rs @@ -160,8 +160,11 @@ impl FireworksSummary { .last_30_days_spend .zip(self.currency_code.as_deref()) .map(|(spend, _)| format_money(spend)); - let mut primary = RateWindow::new(0.0); - primary.reset_description = spend_text.clone(); + let primary = RateWindow::informational( + spend_text + .clone() + .unwrap_or_else(|| "Rated spend unavailable".to_string()), + ); let mut snapshot = UsageSnapshot::new(primary); if let Some(text) = spend_text { snapshot = snapshot.with_login_method(text); @@ -597,3 +600,25 @@ mod tests { assert!(!provider.metadata().default_enabled); } } + +#[cfg(test)] +mod section_b_tests { + use super::*; + + #[test] + fn rated_spend_is_informational_and_cost_is_preserved() { + let summary = parse_summary_for_testing( + r#"{"lineItems":[{"totalCost":{"currencyCode":"USD","units":"3","nanos":500000000}}]}"#, + ) + .unwrap(); + assert!(summary.to_usage_snapshot().primary.is_informational); + assert_eq!(summary.to_cost_snapshot().unwrap().used, 3.5); + } + + #[test] + fn missing_rated_spend_is_not_zero_quota() { + let summary = parse_summary_for_testing("{}").unwrap(); + assert!(summary.to_usage_snapshot().primary.is_informational); + assert!(summary.to_cost_snapshot().is_none()); + } +} diff --git a/rust/src/providers/groq/mod.rs b/rust/src/providers/groq/mod.rs index fb0da5a6b33..b491ff9775b 100644 --- a/rust/src/providers/groq/mod.rs +++ b/rust/src/providers/groq/mod.rs @@ -194,18 +194,18 @@ fn parse_scalar(data: &[u8]) -> Result { .unwrap_or_else(|| "Groq metrics query failed.".to_string()), )); } - Ok(decoded + decoded .data - .map(|payload| { - payload - .result - .iter() - .filter_map(|series| series.value.as_ref()) - .filter_map(|value| value.last()) - .filter_map(PrometheusValue::as_f64) - .sum() + .into_iter() + .flat_map(|payload| payload.result) + .filter_map(|series| series.value) + .filter_map(|value| value.last().and_then(PrometheusValue::as_f64)) + .filter(|value| value.is_finite()) + .reduce(|total, value| total + value) + .filter(|value| value.is_finite()) + .ok_or_else(|| { + ProviderError::Parse("Groq metrics response contains no usable scalar".to_string()) }) - .unwrap_or(0.0)) } fn snapshot_from_metrics(metrics: &GroqMetrics) -> UsageSnapshot { @@ -214,30 +214,14 @@ fn snapshot_from_metrics(metrics: &GroqMetrics) -> UsageSnapshot { (metrics.input_token_rate_per_second + metrics.output_token_rate_per_second) * 60.0; let cache_hits_per_minute = metrics.prompt_cache_hit_rate_per_second * 60.0; - let mut primary = RateWindow::with_details( - 0.0, - Some(5), - None, - Some(format!("{} req/min", format_metric(requests_per_minute))), - ); - primary.used_percent = 0.0; - - let secondary = RateWindow::with_details( - 0.0, - Some(5), - None, - Some(format!("{} tok/min", format_metric(tokens_per_minute))), - ); - - let tertiary = RateWindow::with_details( - 0.0, - Some(5), - None, - Some(format!( - "{} cache/min", - format_metric(cache_hits_per_minute) - )), - ); + let primary = + RateWindow::informational(format!("{} req/min", format_metric(requests_per_minute))); + let secondary = + RateWindow::informational(format!("{} tok/min", format_metric(tokens_per_minute))); + let tertiary = RateWindow::informational(format!( + "{} cache/min", + format_metric(cache_hits_per_minute) + )); UsageSnapshot::new(primary) .with_secondary(secondary) @@ -328,3 +312,53 @@ mod tests { ); } } + +#[cfg(test)] +mod section_b_tests { + use super::*; + + #[test] + fn rates_without_limits_are_informational() { + for value in [0.0, 2.0] { + let snapshot = snapshot_from_metrics(&GroqMetrics { + request_rate_per_second: value, + input_token_rate_per_second: value, + output_token_rate_per_second: value, + prompt_cache_hit_rate_per_second: value, + }); + for window in [ + Some(snapshot.primary), + snapshot.secondary, + snapshot.tertiary, + ] + .into_iter() + .flatten() + { + assert!(window.is_informational); + assert!(window.window_minutes.is_none()); + assert!(window.resets_at.is_none()); + } + } + } + + #[test] + fn missing_scalar_is_an_error_not_zero() { + for body in [ + r#"{"status":"success"}"#, + r#"{"status":"success","data":{"result":[]}}"#, + r#"{"status":"success","data":{"result":[{"value":[123,"bad"]}]}}"#, + r#"{"status":"success","data":{"result":[{"value":[123,"NaN"]}]}}"#, + ] { + assert!(parse_scalar(body.as_bytes()).is_err(), "{body}"); + } + } + + #[test] + fn actual_zero_scalar_is_preserved() { + assert_eq!( + parse_scalar(br#"{"status":"success","data":{"result":[{"value":[123,"0"]}]}}"#) + .unwrap(), + 0.0 + ); + } +} diff --git a/rust/src/providers/jetbrains/mod.rs b/rust/src/providers/jetbrains/mod.rs index 3e5d29060a7..c89ae5a73ed 100755 --- a/rust/src/providers/jetbrains/mod.rs +++ b/rust/src/providers/jetbrains/mod.rs @@ -131,7 +131,7 @@ impl JetBrainsProvider { // let quota = Self::parse_quota_values(content); - Ok(Self::usage_from_quota(quota)) + Self::usage_from_quota(quota) } fn parse_quota_values(content: &str) -> JetBrainsQuota { @@ -145,8 +145,11 @@ impl JetBrainsProvider { quota } - fn usage_from_quota(quota: JetBrainsQuota) -> UsageSnapshot { - UsageSnapshot::new(RateWindow::new(quota.used_percent())).with_login_method("JetBrains AI") + fn usage_from_quota(quota: JetBrainsQuota) -> Result { + let percent = quota.used_percent().ok_or_else(|| { + ProviderError::Parse("JetBrains quota data contains no usable credit quota".into()) + })?; + Ok(UsageSnapshot::new(RateWindow::new(percent)).with_login_method("JetBrains AI")) } /// Extract numeric value from XML attribute @@ -168,18 +171,10 @@ impl JetBrainsProvider { } } +#[derive(Default)] struct JetBrainsQuota { - used_credits: f64, - credit_limit: f64, -} - -impl Default for JetBrainsQuota { - fn default() -> Self { - Self { - used_credits: 0.0, - credit_limit: 1000.0, - } - } + used_credits: Option, + credit_limit: Option, } impl JetBrainsQuota { @@ -187,13 +182,13 @@ impl JetBrainsQuota { if Self::is_used_credits_line(line) && let Some(value) = JetBrainsProvider::extract_xml_value(line) { - self.used_credits = value; + self.used_credits = Some(value); } if Self::is_credit_limit_line(line) && let Some(value) = JetBrainsProvider::extract_xml_value(line) { - self.credit_limit = value; + self.credit_limit = Some(value); } } @@ -210,12 +205,10 @@ impl JetBrainsQuota { || line.contains("monthlyLimit") } - fn used_percent(&self) -> f64 { - if self.credit_limit > 0.0 { - (self.used_credits / self.credit_limit) * 100.0 - } else { - 0.0 - } + fn used_percent(&self) -> Option { + let used = self.used_credits?; + let limit = self.credit_limit.filter(|limit| *limit > 0.0)?; + Some(used / limit * 100.0) } } @@ -290,3 +283,27 @@ mod tests { ); } } + +#[cfg(test)] +mod cx289a_tests { + use super::*; + #[test] + fn cx289a_missing_credit_fields_are_error() { + for xml in [ + "", + r#"