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
56 changes: 50 additions & 6 deletions rust/src/providers/amp/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use crate::core::{
use crate::providers::format::usd;

use super::subscription::{
next_free_tier_reset, parse_amp_number, parse_amp_subscription_usage,
AmpSubscriptionUsage, next_free_tier_reset, parse_amp_number, parse_amp_subscription_usage,
usage_snapshot_from_subscription,
};

const AMOUNT: &str = r"([0-9][0-9,]*(?:\.[0-9]+)?)";
const CREDITS_SECTION: &str = "Credits";
const ALLOWANCES_SECTION: &str = "Monthly allowances";

/// Parsed Amp usage: quota windows plus the credit balances shown as rows.
pub(super) struct AmpDisplayUsage {
Expand All @@ -25,10 +26,28 @@ pub(super) struct AmpDisplayUsage {

impl AmpDisplayUsage {
pub fn into_fetch_result(self, source_label: &str) -> ProviderFetchResult {
ProviderFetchResult::new(self.usage, source_label).with_display_details(self.details)
let allows_pace = window_allows_pace(&self.usage.primary);
let result =
ProviderFetchResult::new(self.usage, source_label).with_display_details(self.details);
if allows_pace {
result
} else {
result.with_non_authoritative_pace()
}
}
}

/// Upstream `AmpProviderDescriptor` pace rule: only a subscription window
/// (a known length and a "renews in ..." reset) has a pace. The Amp Free
/// allowance refills hourly or daily, so a pace line under it is noise.
pub(super) fn window_allows_pace(window: &RateWindow) -> bool {
window.window_minutes.is_some()
&& window
.reset_description
.as_deref()
.is_some_and(|text| text.starts_with("renews in "))
}

/// Amp Free allowance, from either the dollar or the percentage line.
#[derive(Debug, Clone, PartialEq)]
pub(super) struct AmpFreeTierUsage {
Expand Down Expand Up @@ -67,6 +86,10 @@ pub(super) fn parse_amp_display_text(
}

let free_window = free.as_ref().map(|free| free_tier_window(free, now));
let mut details = subscription
.as_ref()
.map(allowance_details)
.unwrap_or_default();
let mut usage = match (subscription, free_window) {
(Some(subscription), free_window) => {
let usage = usage_snapshot_from_subscription(subscription);
Expand All @@ -89,10 +112,31 @@ pub(super) fn parse_amp_display_text(
}
}

Ok(AmpDisplayUsage {
usage,
details: credit_details(individual_credits, &workspaces),
})
details.extend(credit_details(individual_credits, &workspaces));
Ok(AmpDisplayUsage { usage, details })
}

/// Upstream "Monthly allowances" section: the Tier agent balance in dollars
/// and the orb balance in whole hours.
fn allowance_details(subscription: &AmpSubscriptionUsage) -> Vec<ProviderDisplayDetail> {
let agent = subscription.agent_remaining().and_then(|remaining| {
ProviderDisplayDetail::new("amp-allowance-agent", "Agent", usd(remaining))?
.with_section_title(ALLOWANCES_SECTION)
});
let orb = subscription.orb_hours_remaining().and_then(|remaining| {
ProviderDisplayDetail::new("amp-allowance-orb", "Orb", orb_hours_text(remaining))?
.with_section_title(ALLOWANCES_SECTION)?
.with_secondary_value("a1.small-equivalent hours")
});
agent.into_iter().chain(orb).collect()
}

fn orb_hours_text(remaining: f64) -> String {
if remaining > 0.0 && remaining < 1.0 {
"< 1h".to_string()
} else {
format!("{:.0}h", remaining.floor())
}
}

/// Test helper: the quota snapshot only.
Expand Down
147 changes: 143 additions & 4 deletions rust/src/providers/amp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ impl AmpProvider {
metadata: ProviderMetadata {
id: ProviderId::Amp,
display_name: "Amp",
session_label: "Usage",
weekly_label: "Monthly",
session_label: "Amp Free",
weekly_label: "Balance",
supports_opus: false,
supports_credits: true,
default_enabled: false,
is_primary: false,
dashboard_url: Some("https://ampcode.com/settings/usage"),
status_page_url: Some("https://sourcegraphstatus.com"),
status_page_url: None,
tertiary_label_key: None,
},
}
Expand Down Expand Up @@ -367,6 +367,9 @@ Subscription Megawatt: 42% other usage and 88% orb usage remaining - resets upon
.or(Some(AMP_MONTHLY_WINDOW_MINUTES))
);
assert_eq!(snapshot.login_method.as_deref(), Some("Megawatt"));
// Upstream primaryLabel: "Other usage" when there is an orb lane.
assert_eq!(snapshot.primary_label.as_deref(), Some("Other usage"));
assert_eq!(snapshot.secondary_label.as_deref(), Some("Orb usage"));
}

#[test]
Expand Down Expand Up @@ -511,7 +514,7 @@ period 2026-02-30 to 2026-03-30, resets upon renewal in 27 days";
assert!(snapshot.primary.resets_at.is_none());
assert_eq!(
snapshot.primary.reset_description.as_deref(),
Some("renews in 27 days · $18.00/$20.00 remaining")
Some("renews in 27 days")
);
}

Expand Down Expand Up @@ -543,3 +546,139 @@ period 2026-09-13 to 2026-10-13, resets upon renewal in 999999999999999999999999
assert_eq!(snapshot.primary.used_percent, 50.0);
}
}

/// Upstream 0.70 `AmpProviderDescriptor` / `AmpUsageSnapshot` parity: labels,
/// status page, pace rule and the "Monthly allowances" rows.
#[cfg(test)]
mod mac_parity_tests {
use super::display::{parse_amp_display_text, window_allows_pace};
use super::*;
use chrono::TimeZone;

const TIER_TEXT: &str = "Amp Megawatt Tier: agent usage $18.57 of $20 remaining (93%), \
orb usage 732.8h of 750h a1.small orb hours remaining (98%) - \
period 2026-09-13 to 2026-10-13, resets upon renewal in 27 days";

fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap()
}

#[test]
fn metadata_matches_upstream_descriptor() {
let provider = AmpProvider::new();
let metadata = provider.metadata();
assert_eq!(metadata.session_label, "Amp Free");
assert_eq!(metadata.weekly_label, "Balance");
assert_eq!(metadata.status_page_url, None);
}

#[test]
fn free_tier_has_no_pace() {
// The parity pack's dollar line (hourly refill) and the daily percent line.
for text in [
"Amp Free: $12.50 / $20 remaining (replenishes +$0.42 / hour)",
"Amp Free: 72% remaining (resets daily)",
] {
let result = parse_amp_display_text(text, now())
.expect("free tier")
.into_fetch_result("cli");
assert!(!result.pace_authoritative, "{text}");
}
}

#[test]
fn subscription_windows_keep_pace() {
let legacy = "Subscription Megawatt: 42% other usage and 88% orb usage remaining - resets upon renewal in 12 days";
for text in [TIER_TEXT, legacy] {
let result = parse_amp_display_text(text, now())
.expect("subscription")
.into_fetch_result("cli");
assert!(result.pace_authoritative, "{text}");
}
// A subscription with a free allowance still paces on the subscription.
let both = format!("Amp Free: 72% remaining (resets daily)\n{TIER_TEXT}");
let result = parse_amp_display_text(&both, now())
.expect("both")
.into_fetch_result("cli");
assert!(result.pace_authoritative);
}

#[test]
fn pace_rule_needs_window_length_and_renewal_text() {
let window = |minutes: Option<u32>, text: Option<&str>| {
RateWindow::with_details(10.0, minutes, None, text.map(str::to_string))
};
assert!(window_allows_pace(&window(
Some(43_200),
Some("renews in 27 days")
)));
assert!(!window_allows_pace(&window(
None,
Some("renews in 27 days")
)));
assert!(!window_allows_pace(&window(
Some(1_440),
Some("resets daily")
)));
assert!(!window_allows_pace(&window(Some(2_880), None)));
}

#[test]
fn tier_reports_monthly_allowance_rows() {
let parsed = parse_amp_display_text(TIER_TEXT, now()).expect("tier");
let rows: Vec<_> = parsed
.details
.iter()
.map(|row| {
(
row.section_title().map(str::to_string),
row.title().to_string(),
row.value().to_string(),
row.secondary_value().map(str::to_string),
)
})
.collect();
assert_eq!(
rows,
vec![
(
Some("Monthly allowances".to_string()),
"Agent".to_string(),
"$18.57".to_string(),
None,
),
(
Some("Monthly allowances".to_string()),
"Orb".to_string(),
"732h".to_string(),
Some("a1.small-equivalent hours".to_string()),
),
]
);
assert_eq!(
parsed.usage.primary.reset_description.as_deref(),
Some("renews in 27 days")
);
}

#[test]
fn orb_under_one_hour_reads_less_than_one() {
let text = "Amp Example Tier: agent usage $3 of $20 remaining, \
orb usage 0.4h of 750h a1.small orb hours remaining - \
period 2026-09-13 to 2026-10-13, resets upon renewal in 27 days";
let parsed = parse_amp_display_text(text, now()).expect("tier");
let orb = parsed
.details
.iter()
.find(|row| row.title() == "Orb")
.expect("orb row");
assert_eq!(orb.value(), "< 1h");
}

#[test]
fn legacy_subscription_has_no_allowance_rows() {
let text = "Subscription Megawatt: 42% other usage and 88% orb usage remaining - resets upon renewal in 12 days";
let parsed = parse_amp_display_text(text, now()).expect("legacy");
assert!(parsed.details.is_empty());
}
}
38 changes: 5 additions & 33 deletions rust/src/providers/amp/subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,12 @@ pub(super) fn usage_snapshot_from_subscription(sub: AmpSubscriptionUsage) -> Usa
Some(resets_at),
Some(reset_description),
);
// Upstream `AmpProviderDescriptor.primaryLabel` / `secondaryLabel`.
UsageSnapshot::new(primary)
.with_secondary(secondary)
.with_login_method(plan)
.with_primary_label("Other usage")
.with_secondary_label("Orb usage")
}
AmpSubscriptionKind::Tier {
agent,
Expand All @@ -351,11 +354,7 @@ pub(super) fn usage_snapshot_from_subscription(sub: AmpSubscriptionUsage) -> Usa
agent.used_percent(),
window_minutes,
resets_at,
Some(tier_allowance_description(
&reset_description,
&agent,
AmpAllowanceUnit::Dollars,
)),
Some(reset_description.clone()),
)
};
let mut usage = UsageSnapshot::new(primary)
Expand All @@ -366,11 +365,7 @@ pub(super) fn usage_snapshot_from_subscription(sub: AmpSubscriptionUsage) -> Usa
orb.used_percent(),
window_minutes,
resets_at,
Some(tier_allowance_description(
&reset_description,
&orb,
AmpAllowanceUnit::Hours,
)),
Some(reset_description),
);
usage = usage
.with_secondary(secondary)
Expand All @@ -381,29 +376,6 @@ pub(super) fn usage_snapshot_from_subscription(sub: AmpSubscriptionUsage) -> Usa
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AmpAllowanceUnit {
Dollars,
Hours,
}

fn tier_allowance_description(
reset_description: &str,
allowance: &AmpAllowance,
unit: AmpAllowanceUnit,
) -> String {
match unit {
AmpAllowanceUnit::Hours => format!(
"{reset_description} · {:.2}h/{:.2}h remaining",
allowance.remaining, allowance.limit
),
AmpAllowanceUnit::Dollars => format!(
"{reset_description} · ${:.2}/${:.2} remaining",
allowance.remaining, allowance.limit
),
}
}

/// Next 8:00 PM America/New_York boundary strictly after `now`.
pub(super) fn next_free_tier_reset(
now: chrono::DateTime<chrono::Utc>,
Expand Down