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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 82 additions & 11 deletions rust/src/providers/kilo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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")
};
Expand Down Expand Up @@ -173,7 +184,7 @@ impl KiloProvider {
Some(data)
}

async fn fetch_with_key(&self, api_key: &str) -> Result<UsageSnapshot, ProviderError> {
async fn fetch_with_key(&self, api_key: &str) -> Result<ProviderFetchResult, ProviderError> {
let url = Self::build_url();
let response = self
.client
Expand All @@ -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<UsageSnapshot, ProviderError> {
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<ProviderFetchResult, ProviderError> {
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>) -> f64 {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]
Expand All @@ -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());
}
Expand Down
138 changes: 138 additions & 0 deletions rust/src/providers/kilo/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<bool> {
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<bool> {
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<bool> {
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> {
Expand Down Expand Up @@ -410,6 +493,61 @@ fn date_from_epoch(value: f64) -> Option<DateTime<Utc>> {
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!({
Expand Down