diff --git a/rust/src/providers/kilo/mod.rs b/rust/src/providers/kilo/mod.rs index 56dac1b038..2e276284a8 100644 --- a/rust/src/providers/kilo/mod.rs +++ b/rust/src/providers/kilo/mod.rs @@ -12,13 +12,14 @@ use serde_json::Value; mod pass; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; const TRPC_BASE: &str = "https://app.kilo.ai/api/trpc"; const PROCEDURES: &str = "user.getCreditBlocks,kiloPass.getState,user.getAutoTopUpPaymentMethod"; const KILO_CREDENTIAL_TARGET: &str = "codexbar-kilo"; +const AUTO_TOP_UP_DETAIL_ID: &str = "kilo-auto-top-up"; #[derive(Debug, Deserialize)] struct CreditBlock { @@ -40,7 +41,7 @@ impl KiloProvider { id: ProviderId::Kilo, display_name: "Kilo", session_label: "Credits", - weekly_label: "Pass", + weekly_label: "Kilo Pass", supports_opus: false, supports_credits: true, default_enabled: false, @@ -137,9 +138,19 @@ impl KiloProvider { } else { 0.0 }; - let mut window = RateWindow::new(percent); - window.reset_description = Some(format!("${used_usd:.2}/${total_usd:.2}")); - window + // Upstream `KiloUsageSnapshot.toUsageSnapshot`: "17.50/50 credits", + // shown as the window's detail line rather than a reset. + RateWindow::with_details( + percent, + None, + None, + Some(format!( + "{}/{} credits", + compact_number(used_usd), + compact_number(total_usd) + )), + ) + .with_description_as_detail() } else { RateWindow::informational("Credit usage unavailable") }; @@ -173,7 +184,7 @@ impl KiloProvider { Some(data) } - async fn fetch_with_key(&self, api_key: &str) -> Result { + async fn fetch_with_key(&self, api_key: &str) -> Result { let url = Self::build_url(); let response = self .client @@ -198,12 +209,36 @@ impl KiloProvider { let parsed: Value = serde_json::from_str(&body) .map_err(|e| ProviderError::Parse(format!("Failed to parse Kilo response: {}", e)))?; - Self::snapshot_from_batch(&parsed) + Self::result_from_batch(&parsed) } fn snapshot_from_batch(batch: &Value) -> Result { Self::build_snapshot(Self::extract_data(batch, 0), Self::extract_data(batch, 1)) } + + /// The usage snapshot plus the auto top-up activity line. Upstream folds + /// the activity into `loginMethod` ("Pro ยท Auto top-up: visa") and its + /// identity presenter splits it back out under the bars; here it is a + /// section-less detail row so the plan slot keeps only the plan. + fn result_from_batch(batch: &Value) -> Result { + let usage = Self::snapshot_from_batch(batch)?; + let auto_top_up = + pass::auto_top_up_label(Self::extract_data(batch, 0), Self::extract_data(batch, 2)) + .and_then(|label| { + ProviderDisplayDetail::new(AUTO_TOP_UP_DETAIL_ID, "Auto top-up", label) + }); + Ok(ProviderFetchResult::new(usage, "api").with_display_detail(auto_top_up)) + } +} + +/// Upstream `KiloUsageSnapshot.compactNumber`: whole numbers without +/// decimals, everything else with two. +fn compact_number(value: f64) -> String { + if value.trunc() == value { + format!("{value:.0}") + } else { + format!("{value:.2}") + } } fn finite_or_zero(value: Option) -> f64 { @@ -277,8 +312,7 @@ impl Provider for KiloProvider { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { let api_key = Self::get_api_key(ctx.api_key.as_deref())?; - let usage = self.fetch_with_key(&api_key).await?; - Ok(ProviderFetchResult::new(usage, "api")) + self.fetch_with_key(&api_key).await } SourceMode::Web | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) @@ -393,6 +427,43 @@ mod tests { ); assert!(pass.description_is_detail); assert_eq!(snap.login_method.as_deref(), Some("Pro")); + + // Credits read as the Mac detail line, not a "Resets ..." text. + assert_eq!( + snap.primary.reset_description.as_deref(), + Some("17.50/50 credits") + ); + assert!(snap.primary.description_is_detail); + assert!(snap.primary.resets_at.is_none()); + } + + #[test] + fn parity_pack_reports_auto_top_up_as_a_detail_row() { + let result = KiloProvider::result_from_batch(&parity_pack_batch()).unwrap(); + + let rows: Vec<_> = result + .display_details + .iter() + .map(|row| (row.id(), row.section_title(), row.title(), row.value())) + .collect(); + assert_eq!(rows, [(AUTO_TOP_UP_DETAIL_ID, None, "Auto top-up", "visa")]); + // The plan slot keeps only the plan. + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); + } + + #[test] + fn kilo_pass_label_matches_upstream() { + let metadata = KiloProvider::new().metadata; + assert_eq!(metadata.session_label, "Credits"); + assert_eq!(metadata.weekly_label, "Kilo Pass"); + } + + #[test] + fn compact_number_drops_decimals_only_for_whole_values() { + assert_eq!(compact_number(50.0), "50"); + assert_eq!(compact_number(17.5), "17.50"); + assert_eq!(compact_number(0.0), "0"); + assert_eq!(compact_number(1.06), "1.06"); } #[test] @@ -417,7 +488,7 @@ mod tests { assert!((snap.primary.used_percent - 0.0).abs() < f64::EPSILON); assert_eq!( snap.primary.reset_description.as_deref(), - Some("$0.00/$1.06") + Some("0/1.06 credits") ); assert!(snap.secondary.is_none()); } diff --git a/rust/src/providers/kilo/pass.rs b/rust/src/providers/kilo/pass.rs index 86907c3388..412fe33d01 100644 --- a/rust/src/providers/kilo/pass.rs +++ b/rust/src/providers/kilo/pass.rs @@ -284,6 +284,89 @@ fn fallback_pass_fields(payload: Option<&Value>) -> PassFields { } } +/// The auto top-up activity text after "Auto top-up: " (upstream +/// `KiloUsageSnapshot.makeLoginMethod` + `KiloUsageFetcher.autoTopUpState`): +/// the payment method when enabled ("visa"), "enabled" without one, "off" when +/// disabled, and nothing when neither payload says. +pub(super) fn auto_top_up_label( + credit_blocks: Option<&Value>, + auto_top_up: Option<&Value>, +) -> Option { + let credit_contexts = dictionary_contexts(credit_blocks); + let auto_top_up_contexts = dictionary_contexts(auto_top_up); + let enabled = first_bool(&["enabled", "isEnabled", "active"], &auto_top_up_contexts) + .or_else(|| bool_from_status(first_string(&["status"], &auto_top_up_contexts))) + .or_else(|| first_bool(&["autoTopUpEnabled"], &credit_contexts))?; + if !enabled { + return Some("off".to_string()); + } + + // Skip blank values so an empty "paymentMethod" does not hide a later alias. + let method_keys = ["paymentMethod", "paymentMethodType", "method", "cardBrand"]; + let method = auto_top_up_contexts + .iter() + .find_map(|context| { + method_keys.iter().find_map(|key| { + context + .get(*key)? + .as_str() + .map(str::trim) + .filter(|method| !method.is_empty()) + }) + }) + .map(str::to_string) + .or_else(|| { + money_amount( + &["amountCents"], + &[], + &["amount", "topUpAmount", "amountUsd"], + &auto_top_up_contexts, + ) + .filter(|amount| *amount > 0.0) + .map(currency_amount_label) + }); + Some(method.unwrap_or_else(|| "enabled".to_string())) +} + +/// Upstream `currencyAmountLabel`: "$25" for whole amounts, else "$12.50". +fn currency_amount_label(amount: f64) -> String { + if amount.trunc() == amount { + format!("${amount:.0}") + } else { + format!("${amount:.2}") + } +} + +fn first_bool(keys: &[&str], contexts: &[&Object]) -> Option { + contexts + .iter() + .find_map(|context| keys.iter().find_map(|key| bool_from(context.get(*key)))) +} + +/// Upstream `bool(from:)`: JSON booleans, numbers (non-zero is true) and the +/// usual yes/no words. +fn bool_from(raw: Option<&Value>) -> Option { + match raw? { + Value::Bool(value) => Some(*value), + Value::Number(number) => number.as_f64().map(|value| value != 0.0), + Value::String(text) => match text.trim().to_lowercase().as_str() { + "true" | "1" | "yes" | "enabled" | "on" => Some(true), + "false" | "0" | "no" | "disabled" | "off" => Some(false), + _ => None, + }, + _ => None, + } +} + +/// Upstream `boolFromStatusString`. +fn bool_from_status(status: Option<&str>) -> Option { + match status?.trim().to_lowercase().as_str() { + "enabled" | "active" | "on" => Some(true), + "disabled" | "inactive" | "off" | "none" => Some(false), + _ => None, + } +} + /// Breadth-first list of the payload object and its nested objects (including /// objects inside arrays), down to [`MAX_CONTEXT_DEPTH`]. fn dictionary_contexts(payload: Option<&Value>) -> Vec<&Object> { @@ -410,6 +493,61 @@ fn date_from_epoch(value: f64) -> Option> { mod tests { use super::*; + #[test] + fn auto_top_up_label_follows_upstream_states() { + let label = + |credits: Value, top_up: Value| auto_top_up_label(Some(&credits), Some(&top_up)); + let none = serde_json::json!({}); + + assert_eq!( + label( + none.clone(), + serde_json::json!({"enabled": true, "paymentMethod": " visa "}) + ), + Some("visa".to_string()) + ); + // A blank paymentMethod must not hide the next alias. + assert_eq!( + label( + none.clone(), + serde_json::json!({"enabled": true, "paymentMethod": "", "paymentMethodType": "visa"}) + ), + Some("visa".to_string()) + ); + assert_eq!( + label(none.clone(), serde_json::json!({"enabled": true})), + Some("enabled".to_string()) + ); + assert_eq!( + label( + none.clone(), + serde_json::json!({"enabled": true, "amountCents": 2500}) + ), + Some("$25".to_string()) + ); + assert_eq!( + label( + none.clone(), + serde_json::json!({"enabled": false, "paymentMethod": "visa"}) + ), + Some("off".to_string()) + ); + assert_eq!( + label( + none.clone(), + serde_json::json!({"status": "active", "method": "card"}) + ), + Some("card".to_string()) + ); + // Falls back to the credit-blocks flag when the top-up payload is silent. + assert_eq!( + label(serde_json::json!({"autoTopUpEnabled": false}), none.clone()), + Some("off".to_string()) + ); + assert_eq!(label(none.clone(), none), None); + assert_eq!(auto_top_up_label(None, None), None); + } + #[test] fn nested_subscription_parses_period_fields_and_tier() { let payload = serde_json::json!({