use anyhow::Result;
use super::{UsageMetric, UsageOutput};
const BILLING_URL: &str = "https://console.sakana.ai/billing";
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
fn session_cookie() -> Option<String> {
if let Ok(val) = std::env::var("SAKANA_SESSION_COOKIE") {
let trimmed = val.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let path = crate::paths::get_config_dir().join("sakana-session");
if let Ok(content) = std::fs::read_to_string(&path) {
let trimmed = content.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}
pub fn has_credentials() -> bool {
session_cookie().is_some()
}
#[derive(Debug, Default, PartialEq)]
struct ParsedBilling {
plan: Option<String>,
monthly_price: Option<u32>,
next_renewal: Option<String>,
windows: Vec<ParsedWindow>,
}
#[derive(Debug, PartialEq)]
struct ParsedWindow {
label: String,
used_percent: f64,
resets_at: Option<String>,
}
fn looks_logged_out(html: &str) -> bool {
if !html.contains("Billing") {
return true;
}
let has_real_window = !find_window_label_positions(html).is_empty();
let has_real_price = find_monthly_price(html).is_some();
!(has_real_window || has_real_price)
}
fn find_monthly_price(html: &str) -> Option<(u32, usize)> {
let bytes = html.as_bytes();
let mut search_from = 0usize;
while let Some(rel) = html[search_from..].find('$') {
let dollar_idx = search_from + rel;
let mut i = dollar_idx + 1;
let digits_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == digits_start {
search_from = dollar_idx + 1;
continue;
}
let digits = &html[digits_start..i];
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'/' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if html[i..].starts_with("mo") {
if let Ok(price) = digits.parse::<u32>() {
return Some((price, dollar_idx));
}
}
}
search_from = dollar_idx + 1;
}
None
}
fn find_plan(html: &str, price_idx: Option<usize>) -> Option<String> {
const TIERS: [&str; 3] = ["Standard", "Pro", "Max"];
if let Some(idx) = price_idx {
let prefix = &html[..idx];
let mut best: Option<(usize, &str)> = None;
for tier in TIERS {
if let Some(pos) = prefix.rfind(tier) {
match best {
Some((bp, _)) if bp >= pos => {}
_ => best = Some((pos, tier)),
}
}
}
if let Some((_, tier)) = best {
return Some(tier.to_string());
}
}
let start = html
.find("Billing")
.map(|i| i + "Billing".len())
.unwrap_or(0);
let region = &html[start..];
let mut best: Option<(usize, &str)> = None;
for tier in TIERS {
if let Some(pos) = region.find(tier) {
match best {
Some((bp, _)) if bp <= pos => {}
_ => best = Some((pos, tier)),
}
}
}
best.map(|(_, tier)| tier.to_string())
}
fn find_used_percents(html: &str) -> Vec<f64> {
const NEEDLE: &str = "% used";
let bytes = html.as_bytes();
let mut out = Vec::new();
let mut search_from = 0usize;
while let Some(rel) = html[search_from..].find(NEEDLE) {
let pct_sign = search_from + rel;
let mut start = pct_sign;
let mut seen_dot = false;
while start > 0 {
let b = bytes[start - 1];
if b.is_ascii_digit() {
start -= 1;
} else if b == b'.' && !seen_dot {
seen_dot = true;
start -= 1;
} else {
break;
}
}
let token = &html[start..pct_sign];
if token.bytes().any(|b| b.is_ascii_digit()) {
if let Ok(v) = token.parse::<f64>() {
out.push(v.clamp(0.0, 100.0));
}
}
search_from = pct_sign + NEEDLE.len();
}
out
}
fn find_window_label_positions(html: &str) -> Vec<(usize, String)> {
const LABELS: [&str; 2] = ["5-hour", "Weekly"];
let mut found: Vec<(usize, String)> = Vec::new();
for label in LABELS {
let needle = format!(">{label}<");
let mut search_from = 0usize;
while let Some(rel) = html[search_from..].find(&needle) {
let idx = search_from + rel;
found.push((idx, label.to_string()));
search_from = idx + needle.len();
}
}
found.sort_by_key(|(idx, _)| *idx);
found
}
fn parse_windows(html: &str) -> Vec<ParsedWindow> {
let labels = find_window_label_positions(html);
if !labels.is_empty() {
let mut windows = Vec::with_capacity(labels.len());
for (k, (pos, label)) in labels.iter().enumerate() {
let end = labels.get(k + 1).map(|(p, _)| *p).unwrap_or(html.len());
let segment = &html[*pos..end];
if let Some(&pct) = find_used_percents(segment).first() {
windows.push(ParsedWindow {
label: label.clone(),
used_percent: pct,
resets_at: find_reset_times(segment).into_iter().next(),
});
}
}
return windows;
}
const FALLBACK_LABELS: [&str; 2] = ["5-hour", "Weekly"];
find_used_percents(html)
.into_iter()
.take(FALLBACK_LABELS.len())
.enumerate()
.map(|(i, pct)| ParsedWindow {
label: FALLBACK_LABELS[i].to_string(),
used_percent: pct,
resets_at: None,
})
.collect()
}
fn find_reset_times(html: &str) -> Vec<String> {
const PREFIX: &str = "Resets on";
let mut out = Vec::new();
let mut search_from = 0usize;
while let Some(rel) = html[search_from..].find(PREFIX) {
let idx = search_from + rel;
let after = &html[idx + PREFIX.len()..];
let trimmed = after.trim_start();
if let Some(reset) = parse_reset_value(trimmed) {
out.push(reset);
}
search_from = idx + PREFIX.len();
}
out
}
fn parse_reset_value(s: &str) -> Option<String> {
let (month, rest) = take_capitalized_word(s)?;
let rest = rest.strip_prefix(' ')?;
let (_day, rest) = take_digits(rest, 1, 2)?;
let rest = rest.strip_prefix(", ")?;
let (_year, rest) = take_digits(rest, 4, 4)?;
let rest = rest.strip_prefix(" at ")?;
let (_hour, rest) = take_digits(rest, 1, 2)?;
let rest = rest.strip_prefix(':')?;
let (_min, rest) = take_digits(rest, 2, 2)?;
let rest = rest.strip_prefix(' ')?;
let meridiem = if rest.starts_with("AM") {
"AM"
} else if rest.starts_with("PM") {
"PM"
} else {
return None;
};
let consumed = s.len() - rest.len() + meridiem.len();
let _ = month;
Some(s[..consumed].to_string())
}
fn find_next_renewal(html: &str) -> Option<String> {
const PREFIX: &str = "Next renewal";
let idx = html.find(PREFIX)?;
let mut after = &html[idx + PREFIX.len()..];
after = after.strip_prefix(':').unwrap_or(after);
let after = after.trim_start();
parse_date_value(after)
}
fn parse_date_value(s: &str) -> Option<String> {
let (_month, rest) = take_capitalized_word(s)?;
let rest = rest.strip_prefix(' ')?;
let (_day, rest) = take_digits(rest, 1, 2)?;
let rest = rest.strip_prefix(", ")?;
let (_year, rest) = take_digits(rest, 4, 4)?;
let consumed = s.len() - rest.len();
Some(s[..consumed].to_string())
}
fn take_capitalized_word(s: &str) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_uppercase() {
return None;
}
let mut i = 1;
while i < bytes.len() && bytes[i].is_ascii_lowercase() {
i += 1;
}
if i < 2 {
return None;
}
Some((&s[..i], &s[i..]))
}
fn take_digits(s: &str, min: usize, max: usize) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() && i < max && bytes[i].is_ascii_digit() {
i += 1;
}
if i < min {
return None;
}
Some((&s[..i], &s[i..]))
}
fn parse_billing(html: &str) -> Result<ParsedBilling> {
if looks_logged_out(html) {
anyhow::bail!("NEEDS_AUTH");
}
let price = find_monthly_price(html);
let monthly_price = price.map(|(p, _)| p);
let price_idx = price.map(|(_, i)| i);
let plan = find_plan(html, price_idx);
let next_renewal = find_next_renewal(html);
let windows = parse_windows(html);
if windows.is_empty() {
anyhow::bail!("NEEDS_AUTH");
}
Ok(ParsedBilling {
plan,
monthly_price,
next_renewal,
windows,
})
}
fn build_output(parsed: ParsedBilling) -> UsageOutput {
let metrics = parsed
.windows
.into_iter()
.map(|w| UsageMetric {
label: w.label,
used_percent: w.used_percent,
remaining_percent: 100.0 - w.used_percent,
remaining_label: None,
resets_at: w.resets_at,
})
.collect();
let plan = match (parsed.plan, parsed.monthly_price, parsed.next_renewal) {
(Some(tier), Some(price), Some(renew)) => {
Some(format!("{tier} (${price}/mo, renews {renew})"))
}
(Some(tier), Some(price), None) => Some(format!("{tier} (${price}/mo)")),
(Some(tier), None, Some(renew)) => Some(format!("{tier} (renews {renew})")),
(Some(tier), None, None) => Some(tier),
(None, Some(price), _) => Some(format!("${price}/mo")),
(None, None, _) => None,
};
UsageOutput {
provider: "Sakana".into(),
account: None,
plan,
email: None,
metrics,
reset_credits: None,
credit_status: None,
spend_control: None,
}
}
async fn fetch_billing_html(client: &reqwest::Client, cookie: &str) -> Result<String> {
let resp = client
.get(BILLING_URL)
.header("Cookie", cookie)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html")
.send()
.await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
anyhow::bail!("NEEDS_AUTH");
}
if !status.is_success() {
anyhow::bail!("Sakana billing request failed (HTTP {status})");
}
Ok(resp.text().await?)
}
pub fn fetch() -> Result<UsageOutput> {
let cookie = session_cookie().ok_or_else(|| {
anyhow::anyhow!(
"No Sakana session cookie. Set SAKANA_SESSION_COOKIE or write a \
`sakana-session` file in the tokmesh config dir."
)
})?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(10))
.build()?;
let html = fetch_billing_html(&client, &cookie).await.map_err(|e| {
if e.to_string().contains("NEEDS_AUTH") {
anyhow::anyhow!(
"Sakana session expired or invalid. Refresh SAKANA_SESSION_COOKIE \
(re-copy the __Secure-authjs.session-token cookie from \
console.sakana.ai)."
)
} else {
e
}
})?;
let parsed = parse_billing(&html).map_err(|e| {
if e.to_string().contains("NEEDS_AUTH") {
anyhow::anyhow!(
"Sakana session expired or invalid (login page returned). Refresh \
SAKANA_SESSION_COOKIE from console.sakana.ai."
)
} else {
e
}
})?;
Ok(build_output(parsed))
})
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_STANDARD: &str = r#"
<html><body>
<nav>Billing</nav>
<div class="plan-card">
<span>Standard</span>
<span>$20 / mo</span>
<button>Upgrade to Pro</button>
<button>Upgrade to Max</button>
</div>
<div class="window">
<span>5-hour</span>
<span>55% used</span>
<span>Resets on June 22, 2026 at 9:58 AM</span>
</div>
<div class="window">
<span>Weekly</span>
<span>19% used</span>
<span>Resets on June 28, 2026 at 12:00 PM</span>
</div>
<div>Next renewal: July 22, 2026</div>
</body></html>
"#;
#[test]
fn parses_valid_standard_billing() {
let parsed = parse_billing(VALID_STANDARD).expect("should parse");
assert_eq!(parsed.plan.as_deref(), Some("Standard"));
assert_eq!(parsed.monthly_price, Some(20));
assert_eq!(parsed.next_renewal.as_deref(), Some("July 22, 2026"));
assert_eq!(parsed.windows.len(), 2);
assert_eq!(parsed.windows[0].label, "5-hour");
assert_eq!(parsed.windows[0].used_percent, 55.0);
assert_eq!(
parsed.windows[0].resets_at.as_deref(),
Some("June 22, 2026 at 9:58 AM")
);
assert_eq!(parsed.windows[1].label, "Weekly");
assert_eq!(parsed.windows[1].used_percent, 19.0);
assert_eq!(
parsed.windows[1].resets_at.as_deref(),
Some("June 28, 2026 at 12:00 PM")
);
}
#[test]
fn build_output_shapes_metrics_and_plan() {
let parsed = parse_billing(VALID_STANDARD).unwrap();
let out = build_output(parsed);
assert_eq!(out.provider, "Sakana");
assert_eq!(
out.plan.as_deref(),
Some("Standard ($20/mo, renews July 22, 2026)")
);
assert_eq!(out.metrics.len(), 2);
assert_eq!(out.metrics[0].label, "5-hour");
assert_eq!(out.metrics[0].used_percent, 55.0);
assert_eq!(out.metrics[0].remaining_percent, 45.0);
assert_eq!(out.metrics[1].label, "Weekly");
assert_eq!(out.metrics[1].used_percent, 19.0);
}
const VALID_PRO: &str = r#"
<html><body>
<nav>Billing</nav>
<div class="plan-card">
<span>Pro</span>
<span>$100 / mo</span>
<button>Upgrade to Max</button>
</div>
<div class="window">
<span>5-hour</span>
<span>8% used</span>
<span>Resets on June 22, 2026 at 3:15 PM</span>
</div>
<div class="window">
<span>Weekly</span>
<span>72% used</span>
<span>Resets on June 29, 2026 at 1:00 AM</span>
</div>
<div>Next renewal: July 22, 2026</div>
</body></html>
"#;
#[test]
fn disambiguates_active_pro_tier_from_upgrade_button() {
let parsed = parse_billing(VALID_PRO).expect("should parse");
assert_eq!(parsed.plan.as_deref(), Some("Pro"));
assert_eq!(parsed.monthly_price, Some(100));
assert_eq!(parsed.windows.len(), 2);
assert_eq!(parsed.windows[0].used_percent, 8.0);
assert_eq!(parsed.windows[1].used_percent, 72.0);
}
const LOGIN_PAGE: &str = r#"
<html><body>
<h1>Sign in to Sakana</h1>
<a href="/login">Continue with Google</a>
</body></html>
"#;
#[test]
fn login_page_returns_needs_auth_error() {
let err = parse_billing(LOGIN_PAGE).expect_err("login page must error");
assert!(
err.to_string().contains("NEEDS_AUTH"),
"expected NEEDS_AUTH, got: {err}"
);
}
const MISSING_META: &str = r#"
<html><body>
<nav>Billing</nav>
<div class="plan-card">
<span>Standard</span>
<span>$20 / mo</span>
</div>
<div class="window">
<span>5-hour</span>
<span>33% used</span>
</div>
<div class="window">
<span>Weekly</span>
<span>5% used</span>
</div>
</body></html>
"#;
#[test]
fn graceful_degradation_when_meta_missing() {
let parsed = parse_billing(MISSING_META).expect("should still parse percentages");
assert_eq!(parsed.plan.as_deref(), Some("Standard"));
assert_eq!(parsed.monthly_price, Some(20));
assert_eq!(parsed.next_renewal, None);
assert_eq!(parsed.windows.len(), 2);
assert_eq!(parsed.windows[0].label, "5-hour");
assert_eq!(parsed.windows[0].used_percent, 33.0);
assert_eq!(parsed.windows[0].resets_at, None);
assert_eq!(parsed.windows[1].label, "Weekly");
assert_eq!(parsed.windows[1].used_percent, 5.0);
assert_eq!(parsed.windows[1].resets_at, None);
}
const DUPLICATED_RSC: &str = r#"
<html><body>
<script>self.__next_f.push([1,"...stuff 19% used more 55% used trailing..."])</script>
<nav>Billing</nav>
<div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
<div class="window"><span>5-hour</span><span>55% used</span><span>Resets on June 22, 2026 at 9:58 AM</span></div>
<div class="window"><span>Weekly</span><span>19% used</span><span>Resets on June 29, 2026 at 12:00 AM</span></div>
<script>self.__next_f.push([1,"...echo 55% used and 19% used again..."])</script>
</body></html>
"#;
#[test]
fn ignores_duplicated_rsc_percentages() {
let parsed = parse_billing(DUPLICATED_RSC).expect("should parse");
assert_eq!(
parsed.windows.len(),
2,
"must bind to the 2 labels, not collect every % used"
);
assert_eq!(parsed.windows[0].label, "5-hour");
assert_eq!(parsed.windows[0].used_percent, 55.0);
assert_eq!(
parsed.windows[0].resets_at.as_deref(),
Some("June 22, 2026 at 9:58 AM")
);
assert_eq!(parsed.windows[1].label, "Weekly");
assert_eq!(parsed.windows[1].used_percent, 19.0);
assert_eq!(
parsed.windows[1].resets_at.as_deref(),
Some("June 29, 2026 at 12:00 AM")
);
}
#[test]
fn percents_parse_in_document_order() {
let pcts = find_used_percents("a 55% used b 19% used c 100% used");
assert_eq!(pcts, vec![55.0, 19.0, 100.0]);
}
#[test]
fn decimal_percents_parse_in_full() {
assert_eq!(find_used_percents("7.5% used"), vec![7.5]);
assert_eq!(find_used_percents("42% used"), vec![42.0]);
assert_eq!(
find_used_percents("a 7.5% used b 100% used c 12.25% used"),
vec![7.5, 100.0, 12.25]
);
assert_eq!(find_used_percents("250.5% used"), vec![100.0]);
}
#[test]
fn decimal_percent_flows_through_parse() {
let html = r#"
<html><body>
<nav>Billing</nav>
<div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
<div class="window"><span>5-hour</span><span>7.5% used</span></div>
<div class="window"><span>Weekly</span><span>19% used</span></div>
</body></html>
"#;
let parsed = parse_billing(html).expect("should parse");
assert_eq!(parsed.windows[0].used_percent, 7.5);
assert_eq!(parsed.windows[1].used_percent, 19.0);
}
const WEAK_MARKERS_ERROR_PAGE: &str = r#"
<html><body>
<h1>Something went wrong</h1>
<script>self.__next_f.push([1,"...Billing strings like /mo and 0% used in RSC noise..."])</script>
</body></html>
"#;
#[test]
fn weak_marker_error_page_returns_needs_auth() {
let err = parse_billing(WEAK_MARKERS_ERROR_PAGE).expect_err("must error, not empty card");
assert!(
err.to_string().contains("NEEDS_AUTH"),
"expected NEEDS_AUTH, got: {err}"
);
}
const PRICE_ONLY_NO_WINDOWS: &str = r#"
<html><body>
<h1>Session expired</h1>
<nav>Billing</nav>
<div class="plan-card"><span>Standard</span><span>$20 / mo</span></div>
</body></html>
"#;
#[test]
fn price_only_no_windows_returns_needs_auth() {
assert!(
!looks_logged_out(PRICE_ONLY_NO_WINDOWS),
"price marker should pass the cheap logged-out heuristic"
);
let err =
parse_billing(PRICE_ONLY_NO_WINDOWS).expect_err("price-only shell must be needs-auth");
assert!(
err.to_string().contains("NEEDS_AUTH"),
"expected NEEDS_AUTH, got: {err}"
);
}
#[test]
#[serial_test::serial]
fn session_cookie_reads_from_overridden_config_dir() {
use std::env;
let prev_dir = env::var_os("TOKMESH_CONFIG_DIR");
let prev_cookie = env::var_os("SAKANA_SESSION_COOKIE");
let tmp = env::temp_dir().join(format!("tokmesh-sakana-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("sakana-session"), " cookie-from-file \n").unwrap();
unsafe {
env::set_var("TOKMESH_CONFIG_DIR", &tmp);
env::remove_var("SAKANA_SESSION_COOKIE");
}
let got = session_cookie();
unsafe {
match prev_dir {
Some(v) => env::set_var("TOKMESH_CONFIG_DIR", v),
None => env::remove_var("TOKMESH_CONFIG_DIR"),
}
match prev_cookie {
Some(v) => env::set_var("SAKANA_SESSION_COOKIE", v),
None => env::remove_var("SAKANA_SESSION_COOKIE"),
}
}
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(got.as_deref(), Some("cookie-from-file"));
}
#[test]
fn monthly_price_tolerates_spacing_variants() {
assert_eq!(find_monthly_price("$20/mo").map(|(p, _)| p), Some(20));
assert_eq!(find_monthly_price("$20 / mo").map(|(p, _)| p), Some(20));
assert_eq!(find_monthly_price("$200 / mo").map(|(p, _)| p), Some(200));
assert_eq!(find_monthly_price("no price here"), None);
}
}