Skip to content

Commit d5a7116

Browse files
BunsDevCopilot
andauthored
fix(tui): gate rate-limit tier switch on the Anthropic provider (#136)
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>
1 parent 3dad66c commit d5a7116

3 files changed

Lines changed: 89 additions & 21 deletions

File tree

docs/advanced.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,8 @@ The dialog offers:
430430
- **`r` / `Enter`** — retry immediately.
431431
- **`s` / `h`** — switch to Sonnet or Haiku and retry at once. Anthropic
432432
Max limits are model-tier scoped, so a lower tier usually still works.
433+
Offered only when the active provider is Anthropic — switching a Codex
434+
session to an Anthropic model would persist a broken provider config.
433435
- **`d`** — clean duplicate account profiles. If multiple stored profiles
434436
point at the same underlying account (repeated Claude Code credential
435437
imports), switching between them cannot escape the limit; this collapses

src-rust/crates/tui/src/app.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3009,15 +3009,16 @@ impl App {
30093009
/// event handling, never in a render path) so the modal can tell the user
30103010
/// when "switch accounts" would be a no-op and offer a one-key cleanup.
30113011
pub fn open_rate_limit_recovery(&mut self, message: String, retry_after_secs: Option<u64>) {
3012-
let duplicates = if self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic" {
3012+
let is_anthropic = self.config.provider.as_deref().unwrap_or("anthropic") == "anthropic";
3013+
let duplicates = if is_anthropic {
30133014
let registry = claurst_core::accounts::AccountRegistry::load();
30143015
claurst_core::accounts::count_duplicate_anthropic_profiles(&registry)
30153016
} else {
30163017
0
30173018
};
30183019
let model = self.model_name.clone();
30193020
self.rate_limit_recovery
3020-
.open(message, model, retry_after_secs, duplicates);
3021+
.open(message, model, retry_after_secs, duplicates, is_anthropic);
30213022
}
30223023

30233024
/// Handle a key press while the recovery modal is open.
@@ -3034,7 +3035,8 @@ impl App {
30343035
self.status_message = Some("Retrying…".to_string());
30353036
}
30363037
KeyCode::Char('s')
3037-
if !self.rate_limit_recovery.model.contains("sonnet")
3038+
if self.rate_limit_recovery.tier_switch_available
3039+
&& !self.rate_limit_recovery.model.contains("sonnet")
30383040
&& !self.rate_limit_recovery.model.contains("haiku") =>
30393041
{
30403042
self.set_model(SONNET_MODEL.to_string());
@@ -3043,7 +3045,10 @@ impl App {
30433045
.request_retry(Some(SONNET_MODEL.to_string()));
30443046
self.status_message = Some(format!("Retrying on {}…", SONNET_MODEL));
30453047
}
3046-
KeyCode::Char('h') if !self.rate_limit_recovery.model.contains("haiku") => {
3048+
KeyCode::Char('h')
3049+
if self.rate_limit_recovery.tier_switch_available
3050+
&& !self.rate_limit_recovery.model.contains("haiku") =>
3051+
{
30473052
self.set_model(HAIKU_MODEL.to_string());
30483053
self.persist_provider_and_model();
30493054
self.rate_limit_recovery

src-rust/crates/tui/src/rate_limit_recovery.rs

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ pub struct RateLimitRecoveryState {
6262
/// Redundant duplicate profiles detected in the account registry
6363
/// (same underlying account imported more than once).
6464
pub duplicate_profiles: usize,
65+
/// Whether the one-key Anthropic tier-switch actions (`s`/`h`) apply.
66+
/// False when the active provider is not Anthropic — switching a Codex
67+
/// session to Sonnet/Haiku would persist a broken provider config.
68+
pub tier_switch_available: bool,
6569
/// Retry directive waiting to be consumed by the main loop.
6670
pending_retry: Option<RetryDirective>,
6771
}
@@ -77,12 +81,14 @@ impl RateLimitRecoveryState {
7781
model: String,
7882
retry_after_secs: Option<u64>,
7983
duplicate_profiles: usize,
84+
tier_switch_available: bool,
8085
) {
8186
self.visible = true;
8287
self.message = message;
8388
self.model = model;
8489
self.retry_after_secs = retry_after_secs;
8590
self.duplicate_profiles = duplicate_profiles;
91+
self.tier_switch_available = tier_switch_available;
8692
self.pending_retry = None;
8793
self.auto_retry_deadline = retry_after_secs
8894
.map(Duration::from_secs)
@@ -282,13 +288,15 @@ fn action_lines(state: &RateLimitRecoveryState) -> Vec<Line<'static>> {
282288

283289
let mut keys: Vec<Span<'static>> = vec![Span::styled("[r]", key_style)];
284290
keys.push(Span::styled(" retry now ", text_style));
285-
if !state.is_sonnet() && !state.is_haiku() {
286-
keys.push(Span::styled("[s]", key_style));
287-
keys.push(Span::styled(" continue on Sonnet ", text_style));
288-
}
289-
if !state.is_haiku() {
290-
keys.push(Span::styled("[h]", key_style));
291-
keys.push(Span::styled(" continue on Haiku", text_style));
291+
if state.tier_switch_available {
292+
if !state.is_sonnet() && !state.is_haiku() {
293+
keys.push(Span::styled("[s]", key_style));
294+
keys.push(Span::styled(" continue on Sonnet ", text_style));
295+
}
296+
if !state.is_haiku() {
297+
keys.push(Span::styled("[h]", key_style));
298+
keys.push(Span::styled(" continue on Haiku", text_style));
299+
}
292300
}
293301
lines.push(Line::from(keys));
294302

@@ -325,7 +333,13 @@ mod tests {
325333
#[test]
326334
fn open_arms_countdown_for_short_delays() {
327335
let mut s = RateLimitRecoveryState::default();
328-
s.open("limited".into(), "claude-opus-4-8".into(), Some(30), 0);
336+
s.open(
337+
"limited".into(),
338+
"claude-opus-4-8".into(),
339+
Some(30),
340+
0,
341+
true,
342+
);
329343
assert!(s.visible);
330344
assert!(s.auto_retry_deadline.is_some());
331345
assert!(s.countdown_secs().unwrap() <= 30);
@@ -334,15 +348,21 @@ mod tests {
334348
#[test]
335349
fn open_does_not_arm_countdown_for_long_delays() {
336350
let mut s = RateLimitRecoveryState::default();
337-
s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0);
351+
s.open(
352+
"limited".into(),
353+
"claude-opus-4-8".into(),
354+
Some(3600),
355+
0,
356+
true,
357+
);
338358
assert!(s.visible);
339359
assert!(s.auto_retry_deadline.is_none());
340360
}
341361

342362
#[test]
343363
fn open_does_not_arm_countdown_without_retry_after() {
344364
let mut s = RateLimitRecoveryState::default();
345-
s.open("limited".into(), "claude-opus-4-8".into(), None, 0);
365+
s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true);
346366
assert!(s.auto_retry_deadline.is_none());
347367
}
348368

@@ -352,20 +372,38 @@ mod tests {
352372
auto_retries_used: MAX_AUTO_RETRIES,
353373
..Default::default()
354374
};
355-
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
375+
s.open(
376+
"limited".into(),
377+
"claude-opus-4-8".into(),
378+
Some(10),
379+
0,
380+
true,
381+
);
356382
assert!(
357383
s.auto_retry_deadline.is_none(),
358384
"budget exhausted — no more auto-retries"
359385
);
360386
s.reset_episode();
361-
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
387+
s.open(
388+
"limited".into(),
389+
"claude-opus-4-8".into(),
390+
Some(10),
391+
0,
392+
true,
393+
);
362394
assert!(s.auto_retry_deadline.is_some());
363395
}
364396

365397
#[test]
366398
fn tick_fires_retry_at_deadline() {
367399
let mut s = RateLimitRecoveryState::default();
368-
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
400+
s.open(
401+
"limited".into(),
402+
"claude-opus-4-8".into(),
403+
Some(10),
404+
0,
405+
true,
406+
);
369407
// Force the deadline into the past.
370408
s.auto_retry_deadline = Some(Instant::now() - Duration::from_secs(1));
371409
s.tick();
@@ -379,7 +417,13 @@ mod tests {
379417
#[test]
380418
fn manual_retry_with_model_switch() {
381419
let mut s = RateLimitRecoveryState::default();
382-
s.open("limited".into(), "claude-opus-4-8".into(), Some(3600), 0);
420+
s.open(
421+
"limited".into(),
422+
"claude-opus-4-8".into(),
423+
Some(3600),
424+
0,
425+
true,
426+
);
383427
s.request_retry(Some(HAIKU_MODEL.to_string()));
384428
assert!(!s.visible);
385429
let directive = s.take_retry_directive().expect("retry queued");
@@ -389,7 +433,13 @@ mod tests {
389433
#[test]
390434
fn dismiss_cancels_everything() {
391435
let mut s = RateLimitRecoveryState::default();
392-
s.open("limited".into(), "claude-opus-4-8".into(), Some(10), 0);
436+
s.open(
437+
"limited".into(),
438+
"claude-opus-4-8".into(),
439+
Some(10),
440+
0,
441+
true,
442+
);
393443
s.dismiss();
394444
assert!(!s.visible);
395445
s.tick();
@@ -399,7 +449,7 @@ mod tests {
399449
#[test]
400450
fn tier_actions_reflect_current_model() {
401451
let mut s = RateLimitRecoveryState::default();
402-
s.open("limited".into(), "claude-opus-4-8".into(), None, 0);
452+
s.open("limited".into(), "claude-opus-4-8".into(), None, 0, true);
403453
let text = lines_text(&action_lines(&s));
404454
assert!(text.contains("[s]"));
405455
assert!(text.contains("[h]"));
@@ -415,10 +465,20 @@ mod tests {
415465
assert!(!text.contains("[h]"));
416466
}
417467

468+
#[test]
469+
fn tier_actions_hidden_for_non_anthropic_provider() {
470+
let mut s = RateLimitRecoveryState::default();
471+
s.open("limited".into(), "gpt-5-codex".into(), None, 0, false);
472+
let text = lines_text(&action_lines(&s));
473+
assert!(!text.contains("[s]"));
474+
assert!(!text.contains("[h]"));
475+
assert!(text.contains("[r]"));
476+
}
477+
418478
#[test]
419479
fn duplicate_cleanup_action_appears_when_duplicates_exist() {
420480
let mut s = RateLimitRecoveryState::default();
421-
s.open("limited".into(), "claude-opus-4-8".into(), None, 18);
481+
s.open("limited".into(), "claude-opus-4-8".into(), None, 18, true);
422482
let text = lines_text(&action_lines(&s));
423483
assert!(text.contains("[d]"));
424484
assert!(text.contains("18 duplicate account profiles"));
@@ -436,6 +496,7 @@ mod tests {
436496
"claude-opus-4-8".into(),
437497
Some(30),
438498
2,
499+
true,
439500
);
440501
let area = Rect {
441502
x: 0,

0 commit comments

Comments
 (0)