From 769159dfeadab8bdb2c46b4800a4f41c0b015298 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:00:09 -0500 Subject: [PATCH] fix(tui): gate rate-limit tier switch on the Anthropic provider The recovery modal opens for any provider's structured 429, but the s/h tier-switch actions unconditionally set an Anthropic Sonnet/Haiku model and persist the provider flip. A Codex-only user pressing h got a persistently broken Anthropic config with no credentials. Add tier_switch_available to RateLimitRecoveryState, set from the active provider when the modal opens, and gate both the action hints and the key handlers on it, mirroring the existing provider gate on the duplicate-profile scan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/advanced.md | 2 + src-rust/crates/tui/src/app.rs | 13 ++- .../crates/tui/src/rate_limit_recovery.rs | 95 +++++++++++++++---- 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index a00c21e..42685b9 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -430,6 +430,8 @@ The dialog offers: - **`r` / `Enter`** — retry immediately. - **`s` / `h`** — switch to Sonnet or Haiku and retry at once. Anthropic Max limits are model-tier scoped, so a lower tier usually still works. + Offered only when the active provider is Anthropic — switching a Codex + session to an Anthropic model would persist a broken provider config. - **`d`** — clean duplicate account profiles. If multiple stored profiles point at the same underlying account (repeated Claude Code credential imports), switching between them cannot escape the limit; this collapses diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index e803b90..5c03e7a 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -3004,7 +3004,8 @@ impl App { /// event handling, never in a render path) so the modal can tell the user /// when "switch accounts" would be a no-op and offer a one-key cleanup. pub fn open_rate_limit_recovery(&mut self, message: String, retry_after_secs: Option) { - let duplicates = if self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic" { + let is_anthropic = self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic"; + let duplicates = if is_anthropic { let registry = claurst_core::accounts::AccountRegistry::load(); claurst_core::accounts::count_duplicate_anthropic_profiles(®istry) } else { @@ -3012,7 +3013,7 @@ impl App { }; let model = self.model_name.clone(); self.rate_limit_recovery - .open(message, model, retry_after_secs, duplicates); + .open(message, model, retry_after_secs, duplicates, is_anthropic); } /// Handle a key press while the recovery modal is open. @@ -3029,7 +3030,8 @@ impl App { self.status_message = Some("Retrying…".to_string()); } KeyCode::Char('s') - if !self.rate_limit_recovery.model.contains("sonnet") + if self.rate_limit_recovery.tier_switch_available + && !self.rate_limit_recovery.model.contains("sonnet") && !self.rate_limit_recovery.model.contains("haiku") => { self.set_model(SONNET_MODEL.to_string()); @@ -3038,7 +3040,10 @@ impl App { .request_retry(Some(SONNET_MODEL.to_string())); self.status_message = Some(format!("Retrying on {}…", SONNET_MODEL)); } - KeyCode::Char('h') if !self.rate_limit_recovery.model.contains("haiku") => { + KeyCode::Char('h') + if self.rate_limit_recovery.tier_switch_available + && !self.rate_limit_recovery.model.contains("haiku") => + { self.set_model(HAIKU_MODEL.to_string()); self.persist_provider_and_model(); self.rate_limit_recovery diff --git a/src-rust/crates/tui/src/rate_limit_recovery.rs b/src-rust/crates/tui/src/rate_limit_recovery.rs index c630c97..e14de36 100644 --- a/src-rust/crates/tui/src/rate_limit_recovery.rs +++ b/src-rust/crates/tui/src/rate_limit_recovery.rs @@ -62,6 +62,10 @@ pub struct RateLimitRecoveryState { /// Redundant duplicate profiles detected in the account registry /// (same underlying account imported more than once). pub duplicate_profiles: usize, + /// Whether the one-key Anthropic tier-switch actions (`s`/`h`) apply. + /// False when the active provider is not Anthropic — switching a Codex + /// session to Sonnet/Haiku would persist a broken provider config. + pub tier_switch_available: bool, /// Retry directive waiting to be consumed by the main loop. pending_retry: Option, } @@ -77,12 +81,14 @@ impl RateLimitRecoveryState { model: String, retry_after_secs: Option, duplicate_profiles: usize, + tier_switch_available: bool, ) { self.visible = true; self.message = message; self.model = model; self.retry_after_secs = retry_after_secs; self.duplicate_profiles = duplicate_profiles; + self.tier_switch_available = tier_switch_available; self.pending_retry = None; self.auto_retry_deadline = retry_after_secs .map(Duration::from_secs) @@ -282,13 +288,15 @@ fn action_lines(state: &RateLimitRecoveryState) -> Vec> { let mut keys: Vec> = vec![Span::styled("[r]", key_style)]; keys.push(Span::styled(" retry now ", text_style)); - if !state.is_sonnet() && !state.is_haiku() { - keys.push(Span::styled("[s]", key_style)); - keys.push(Span::styled(" continue on Sonnet ", text_style)); - } - if !state.is_haiku() { - keys.push(Span::styled("[h]", key_style)); - keys.push(Span::styled(" continue on Haiku", text_style)); + if state.tier_switch_available { + if !state.is_sonnet() && !state.is_haiku() { + keys.push(Span::styled("[s]", key_style)); + keys.push(Span::styled(" continue on Sonnet ", text_style)); + } + if !state.is_haiku() { + keys.push(Span::styled("[h]", key_style)); + keys.push(Span::styled(" continue on Haiku", text_style)); + } } lines.push(Line::from(keys)); @@ -325,7 +333,13 @@ mod tests { #[test] fn open_arms_countdown_for_short_delays() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(30), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(30), + 0, + true, + ); assert!(s.visible); assert!(s.auto_retry_deadline.is_some()); assert!(s.countdown_secs().unwrap() <= 30); @@ -334,7 +348,13 @@ mod tests { #[test] fn open_does_not_arm_countdown_for_long_delays() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(3600), + 0, + true, + ); assert!(s.visible); assert!(s.auto_retry_deadline.is_none()); } @@ -342,7 +362,7 @@ mod tests { #[test] fn open_does_not_arm_countdown_without_retry_after() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), None, 0); + s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true); assert!(s.auto_retry_deadline.is_none()); } @@ -352,20 +372,38 @@ mod tests { auto_retries_used: MAX_AUTO_RETRIES, ..Default::default() }; - s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(10), + 0, + true, + ); assert!( s.auto_retry_deadline.is_none(), "budget exhausted — no more auto-retries" ); s.reset_episode(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(10), + 0, + true, + ); assert!(s.auto_retry_deadline.is_some()); } #[test] fn tick_fires_retry_at_deadline() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(10), + 0, + true, + ); // Force the deadline into the past. s.auto_retry_deadline = Some(Instant::now() - Duration::from_secs(1)); s.tick(); @@ -379,7 +417,13 @@ mod tests { #[test] fn manual_retry_with_model_switch() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(3600), + 0, + true, + ); s.request_retry(Some(HAIKU_MODEL.to_string())); assert!(!s.visible); let directive = s.take_retry_directive().expect("retry queued"); @@ -389,7 +433,13 @@ mod tests { #[test] fn dismiss_cancels_everything() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0); + s.open( + "limited".into(), + "claude-opus-4-8".into(), + Some(10), + 0, + true, + ); s.dismiss(); assert!(!s.visible); s.tick(); @@ -399,7 +449,7 @@ mod tests { #[test] fn tier_actions_reflect_current_model() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), None, 0); + s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true); let text = lines_text(&action_lines(&s)); assert!(text.contains("[s]")); assert!(text.contains("[h]")); @@ -415,10 +465,20 @@ mod tests { assert!(!text.contains("[h]")); } + #[test] + fn tier_actions_hidden_for_non_anthropic_provider() { + let mut s = RateLimitRecoveryState::default(); + s.open("limited".into(), "gpt-5-codex".into(), None, 0, false); + let text = lines_text(&action_lines(&s)); + assert!(!text.contains("[s]")); + assert!(!text.contains("[h]")); + assert!(text.contains("[r]")); + } + #[test] fn duplicate_cleanup_action_appears_when_duplicates_exist() { let mut s = RateLimitRecoveryState::default(); - s.open("limited".into(), "claude-opus-4-8".into(), None, 18); + s.open("limited".into(), "claude-opus-4-8".into(), None, 18, true); let text = lines_text(&action_lines(&s)); assert!(text.contains("[d]")); assert!(text.contains("18 duplicate account profiles")); @@ -436,6 +496,7 @@ mod tests { "claude-opus-4-8".into(), Some(30), 2, + true, ); let area = Rect { x: 0,