Skip to content
Open
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
52 changes: 52 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,11 @@ pub(super) fn codex_reset_backfill(
if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") {
return;
}
// A subscription change starts a new quota baseline: reset times from the
// previous plan are not evidence for the new one. Unknown plans never block.
if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) {
return;
}

// Backfill each slot from the corresponding cached slot.
backfill_slot_window(
Expand All @@ -715,6 +720,20 @@ pub(super) fn codex_reset_backfill(
}
}

/// True only when both snapshots report a known plan and the plans differ.
fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool {
let normalize = |plan: &Option<String>| {
plan.as_deref()
.map(str::trim)
.filter(|plan| !plan.is_empty())
.map(str::to_lowercase)
};
matches!(
(normalize(&cached.plan_name), normalize(&fresh.plan_name)),
(Some(cached), Some(fresh)) if cached != fresh
)
}

/// Backfill `resets_at` and `reset_description` on a fresh window from the
/// cached window whose reset is still in the future. `used_percent` is never
/// overwritten (upstream: "fresh used_percent untouched").
Expand Down Expand Up @@ -1391,6 +1410,39 @@ mod reset_backfill_tests {
assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str()));
}

#[test]
fn codex_backfill_skips_when_known_plans_differ() {
let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339();
let mut cached = codex_snapshot(win(80.0, Some(&future)));
cached.plan_name = Some("Plus".into());
let mut fresh = codex_snapshot(win(5.0, None));
fresh.plan_name = Some("Pro".into());
codex_reset_backfill(&mut fresh, Some(&cached));
assert!(fresh.primary.resets_at.is_none(), "plan change baseline");
}

#[test]
fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() {
let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339();
for (cached_plan, fresh_plan) in [
(Some("Plus"), Some(" plus ")),
(Some("Plus"), None),
(None, Some("Pro")),
(Some("Plus"), Some(" ")),
] {
let mut cached = codex_snapshot(win(80.0, Some(&future)));
cached.plan_name = cached_plan.map(str::to_string);
let mut fresh = codex_snapshot(win(5.0, None));
fresh.plan_name = fresh_plan.map(str::to_string);
codex_reset_backfill(&mut fresh, Some(&cached));
assert_eq!(
fresh.primary.resets_at.as_deref(),
Some(future.as_str()),
"{cached_plan:?} -> {fresh_plan:?}"
);
}
}

#[test]
fn f6_skips_non_codex_provider() {
let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339();
Expand Down
48 changes: 38 additions & 10 deletions rust/src/providers/codex/weekly_reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ pub(super) fn initial_decision(
exact_oauth: bool,
observed_at: DateTime<Utc>,
) -> InitialDecision {
if exact_oauth && plan_changed(state, current) {
// A new subscription has its own quota baseline, not evidence of a
// reset on the previous plan: drop the stored weekly window, pending
// candidate, and credit inventory so the old plan cannot be pinned.
log_reset_diagnostic("planBaseline", "reset", ResetDiagnosticReason::PlanChanged);
*state = AccountState::default();
}
if let Some(candidate) = state.candidate.clone() {
match delayed_candidate_decision(
state,
Expand Down Expand Up @@ -325,7 +332,12 @@ pub(super) fn confirmation_decision(
if confirmation_weekly.used_percent > RESET_THRESHOLD {
return ConfirmationDecision::Publish;
}
if initial_weekly.used_percent > RESET_THRESHOLD {
// A near-zero reading is only trusted when both observations report the
// same plan; a plan flip between them is not a confirmation.
if initial_weekly.used_percent > RESET_THRESHOLD
|| normalized_plan(initial.login_method.as_deref())
!= normalized_plan(confirmation.login_method.as_deref())
{
return ConfirmationDecision::Preserve;
}

Expand Down Expand Up @@ -696,16 +708,32 @@ fn supported_delayed_boundary(previous: &RateWindow, current: &RateWindow) -> bo
distance.abs() < STABLE_BOUNDARY_TOLERANCE_SECONDS || distance >= RESET_TOLERANCE_SECONDS
}

fn normalized_plan(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_lowercase)
}

/// True only when the stored and fresh plans are both known and differ;
/// an unknown plan never resets the baseline.
fn plan_changed(state: &AccountState, current: &UsageSnapshot) -> bool {
if current.updated_at <= state.published_at {
return false;
}
match (
normalized_plan(state.plan.as_deref()),
normalized_plan(current.login_method.as_deref()),
) {
(Some(previous), Some(current)) => previous != current,
_ => false,
}
}

fn plans_match(previous: Option<&str>, left: &UsageSnapshot, right: &UsageSnapshot) -> bool {
let normalize = |value: Option<&str>| {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_lowercase)
};
let previous = normalize(previous);
let left = normalize(left.login_method.as_deref());
let right = normalize(right.login_method.as_deref());
let previous = normalized_plan(previous);
let left = normalized_plan(left.login_method.as_deref());
let right = normalized_plan(right.login_method.as_deref());
previous.is_some() && previous == left && left == right
}

Expand Down
2 changes: 2 additions & 0 deletions rust/src/providers/codex/weekly_reset/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(super) enum ResetDiagnosticReason {
InconsistentResetBoundary,
UnsupportedResetBoundary,
PlanMismatch,
PlanChanged,
MissingCreditInventory,
ChangedCreditInventory,
EvidenceVersionMismatch,
Expand All @@ -33,6 +34,7 @@ impl ResetDiagnosticReason {
Self::InconsistentResetBoundary => "inconsistentResetBoundary",
Self::UnsupportedResetBoundary => "unsupportedResetBoundary",
Self::PlanMismatch => "planMismatch",
Self::PlanChanged => "planChanged",
Self::MissingCreditInventory => "missingCreditInventory",
Self::ChangedCreditInventory => "changedCreditInventory",
Self::EvidenceVersionMismatch => "evidenceVersionMismatch",
Expand Down
183 changes: 183 additions & 0 deletions rust/src/providers/codex/weekly_reset/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,186 @@ fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() {
"candidate expired"
);
}

fn plan_snapshot(plan: Option<&str>, used: f64, captured_minutes: i64) -> UsageSnapshot {
let mut snapshot = snapshot(used, 9, captured_minutes);
snapshot.login_method = plan.map(str::to_string);
snapshot
}

/// Plus subscription with a stale 80% weekly baseline that resets in one day.
fn plus_baseline() -> AccountState {
let mut previous = snapshot(80.0, 1, 0);
previous.login_method = Some("ChatGPT Plus".to_string());
AccountState {
published_weekly: previous.secondary.clone(),
published_at: previous.updated_at,
plan: previous.login_method.clone(),
credit_inventory: Some(inventory("credit-a")),
candidate: None,
}
}

#[test]
fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() {
let mut state = plus_baseline();
let inv = inventory("credit-a");
let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10);
let confirmation = plan_snapshot(Some("ChatGPT Pro"), 0.0, 11);
assert_eq!(
initial_decision(&mut state, &initial, Some(&inv), true, now()),
InitialDecision::RequiresConfirmation
);
assert!(state.published_weekly.is_none());
assert!(state.credit_inventory.is_none());
assert!(state.candidate.is_none());
assert_eq!(
confirmation_decision(
&mut state,
&initial,
Some(&inv),
&confirmation,
Some(&inv),
true,
now(),
),
ConfirmationDecision::Publish
);
}

#[test]
fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() {
// Identical to the upgrade scenario, but the plan did not change: the old
// weekly window stays pinned until the confirmation is trustworthy.
let mut state = plus_baseline();
let inv = inventory("credit-a");
let initial = plan_snapshot(Some("ChatGPT Plus"), 0.0, 10);
let confirmation = plan_snapshot(Some("ChatGPT Plus"), 0.0, 11);
assert_eq!(
initial_decision(&mut state, &initial, Some(&inv), true, now()),
InitialDecision::RequiresConfirmation
);
assert!(state.published_weekly.is_some());
assert_eq!(
confirmation_decision(
&mut state,
&initial,
Some(&inv),
&confirmation,
Some(&inv),
true,
now(),
),
ConfirmationDecision::Preserve
);
}

#[test]
fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() {
let mut state = plus_baseline();
let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10);
assert_eq!(
initial_decision(&mut state, &current, None, true, now()),
InitialDecision::Publish
);
let preserved = preserve_weekly(&state, current.clone());
let used = |snapshot: &UsageSnapshot| snapshot.secondary.as_ref().map(|w| w.used_percent);
assert_eq!(used(&preserved), Some(5.0));
assert_eq!(used(&preserved), used(&current));
}

#[test]
fn plan_change_discards_a_pending_candidate() {
let mut state = plus_baseline();
state.candidate = Some(DelayedCandidate {
evidence_version: EVIDENCE_VERSION,
first_observed_at: now(),
created_at: now(),
snapshot_updated_at: now(),
weekly: RateWindow::new(0.0),
plan: Some("ChatGPT Plus".to_string()),
inventory: inventory("credit-a"),
});
let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10);
assert_eq!(
initial_decision(&mut state, &current, None, true, now()),
InitialDecision::Publish
);
assert!(state.candidate.is_none());
}

#[test]
fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() {
let cases: [(&str, Option<&str>, bool); 4] = [
(
"same plan with case and spacing",
Some(" chatgpt plus "),
true,
),
("unknown fresh plan", None, true),
("blank fresh plan", Some(" "), true),
("not exact OAuth", Some("ChatGPT Pro"), false),
];
for (name, plan, exact_oauth) in cases {
let mut state = plus_baseline();
let current = plan_snapshot(plan, 5.0, 10);
initial_decision(&mut state, &current, None, exact_oauth, now());
assert!(state.published_weekly.is_some(), "{name}");
assert!(state.credit_inventory.is_some(), "{name}");
}

let mut unknown_stored = plus_baseline();
unknown_stored.plan = None;
let fresh = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10);
initial_decision(&mut unknown_stored, &fresh, None, true, now());
assert!(
unknown_stored.published_weekly.is_some(),
"unknown stored plan"
);

let mut older = plus_baseline();
let stale = plan_snapshot(Some("ChatGPT Pro"), 5.0, -1);
initial_decision(&mut older, &stale, None, true, now());
assert!(older.published_weekly.is_some(), "older observation");
}

#[test]
fn near_zero_confirmation_must_report_the_initial_plan() {
let inv = inventory("credit-a");
for confirmation_plan in [Some("ChatGPT Plus"), None] {
for has_baseline in [false, true] {
let mut state = if has_baseline {
baseline()
} else {
AccountState::default()
};
let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10);
let confirmation = plan_snapshot(confirmation_plan, 0.0, 11);
assert_eq!(
confirmation_decision(
&mut state,
&initial,
Some(&inv),
&confirmation,
Some(&inv),
true,
now(),
),
ConfirmationDecision::Preserve,
"{confirmation_plan:?} baseline {has_baseline}"
);
assert!(state.candidate.is_none());
}
}
}

#[test]
fn nonzero_confirmation_can_publish_its_own_plan() {
let mut state = AccountState::default();
let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10);
let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11);
assert_eq!(
confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()),
ConfirmationDecision::Publish
);
}