@@ -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