From 555608d3bac8e644e65c27c3be3d9b79e10d4fa5 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 12 Aug 2026 21:55:56 +0300 Subject: [PATCH 1/2] fix(usage): score every rate-limit lane instead of only the codex lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `usage_health_for_snapshots` selected a single snapshot — the `codex` lane, or the first one — and discarded the rest. Accounts now expose two lanes, and when the generic `codex` lane reaches 100% the whole profile was reported exhausted while the `codex_bengalfox` (GPT-5.3-Codex-Spark) lane sat untouched. Measured on station01: 22 of 22 authenticated profiles reported exhausted, every one of them holding a lane under 100%. Each lane is now scored independently and a profile is exhausted only when EVERY lane is exhausted. The naive repair — scoring all lanes and taking the max — is the mirror failure: it converts a false Exhausted into a false Healthy and routes default-model work onto a genuinely spent lane. The per-lane result is therefore carried, so callers can name which model still has capacity rather than assuming any model will do. `remaining_percent` on the Exhausted arm was the literal `Some(0.0)` in both the CLI report and the model-visible tool handler. The classifier already computed the real limiting figure and threw it away. A lane blocked by spend control or depleted credits can still hold capacity — the regression test covers exactly that case at 80% remaining — so the measured figure is now reported. Two-sided fixtures: a profile with codex at 100% and codex_bengalfox at 0% must report usable and name the usable lane; a profile with every lane at 100% must stay exhausted and offer no lane. --- codex-rs/cli/src/usage_cmd.rs | 41 ++- codex-rs/core/src/auth_profile_usage.rs | 304 ++++++++++++++++-- .../handlers/auth_profile_usage_control.rs | 8 +- 3 files changed, 324 insertions(+), 29 deletions(-) diff --git a/codex-rs/cli/src/usage_cmd.rs b/codex-rs/cli/src/usage_cmd.rs index 0441399e5..9e25c8a57 100644 --- a/codex-rs/cli/src/usage_cmd.rs +++ b/codex-rs/cli/src/usage_cmd.rs @@ -5,7 +5,7 @@ use codex_backend_client::AccountEntry; use codex_backend_client::Client as BackendClient; use codex_core::auth_profile_usage::AuthProfileUsageHealth; use codex_core::auth_profile_usage::TokenUsageProfileResponse; -use codex_core::auth_profile_usage::usage_health_for_snapshots; +use codex_core::auth_profile_usage::usage_assessment_for_snapshots; use codex_core::config::AuthProfileAutoSwitchConfig; use codex_core::config::Config; use codex_login::AuthManager; @@ -170,6 +170,21 @@ struct CliUsageHealth { remaining_percent: Option, resets_at: Option, reason: Option<&'static str>, + /// Lanes that still have capacity, best-remaining first. + /// + /// A profile is reported healthy when any lane has capacity, so this names which model + /// to route to. It is empty when the profile is exhausted. + usable_lanes: Vec, +} + +/// One rate-limit lane with capacity left, as reported to the operator. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CliUsageLane { + limit_id: Option, + limit_name: Option, + remaining_percent: f64, + resets_at: Option, } #[derive(Debug, Serialize)] @@ -646,7 +661,19 @@ impl CliUsageHealth { snapshots: &[RateLimitSnapshot], config: &AuthProfileAutoSwitchConfig, ) -> Self { - match usage_health_for_snapshots(snapshots, config) { + let assessment = usage_assessment_for_snapshots(snapshots, config); + let usable_lanes = assessment + .usable_lanes() + .into_iter() + .map(|lane| CliUsageLane { + limit_id: lane.limit_id.clone(), + limit_name: lane.limit_name.clone(), + remaining_percent: lane.remaining_percent, + resets_at: lane.resets_at, + }) + .collect::>(); + + match assessment.health { AuthProfileUsageHealth::Healthy { remaining_percent, resets_at, @@ -655,18 +682,24 @@ impl CliUsageHealth { remaining_percent: Some(remaining_percent), resets_at, reason: None, + usable_lanes, }, - AuthProfileUsageHealth::Exhausted { retry_at } => Self { + AuthProfileUsageHealth::Exhausted { + remaining_percent, + retry_at, + } => Self { status: CliUsageHealthStatus::Exhausted, - remaining_percent: Some(0.0), + remaining_percent: Some(remaining_percent), resets_at: retry_at, reason: None, + usable_lanes, }, AuthProfileUsageHealth::Unknown => Self { status: CliUsageHealthStatus::Unknown, remaining_percent: None, resets_at: None, reason: Some("unsupported_or_missing_usage_windows"), + usable_lanes, }, } } diff --git a/codex-rs/core/src/auth_profile_usage.rs b/codex-rs/core/src/auth_profile_usage.rs index a12aa274a..fb1d7e17c 100644 --- a/codex-rs/core/src/auth_profile_usage.rs +++ b/codex-rs/core/src/auth_profile_usage.rs @@ -23,8 +23,14 @@ pub enum AuthProfileUsageHealth { /// Reset timestamp for the limiting window, if known. resets_at: Option, }, - /// At least one enabled Codex window is exhausted. + /// Every scored lane is exhausted. Exhausted { + /// Highest remaining percentage still left on any lane. + /// + /// This is a measurement, not a placeholder: a lane can be blocked by the backend + /// (spend control, depleted credits) while its windows still report capacity, and + /// callers need the real figure rather than an assumed zero. + remaining_percent: f64, /// Earliest reset timestamp across exhausted enabled windows, if known. retry_at: Option, }, @@ -116,22 +122,128 @@ impl From for TokenUsageProfileResponse { } } +/// One rate-limit lane (`limit_id`), scored on its own. +/// +/// An account exposes several lanes and they are spent independently — the generic `codex` +/// lane can be at 100% while the `codex_bengalfox` (GPT-5.3-Codex-Spark) lane is untouched. +/// Callers use these to route work to a model that still has capacity. +#[derive(Clone, Debug, PartialEq)] +pub struct AuthProfileUsageLane { + /// Backend lane identifier, e.g. `codex` or `codex_bengalfox`. + pub limit_id: Option, + /// Human-facing lane name when the backend supplies one, e.g. `GPT-5.3-Codex-Spark`. + pub limit_name: Option, + /// Lowest remaining percentage among this lane's enabled windows. + pub remaining_percent: f64, + /// Reset timestamp for this lane's limiting window, if known. + pub resets_at: Option, + /// Earliest reset across this lane's exhausted windows, if known. + pub retry_at: Option, + /// Whether this lane has no capacity left. + pub exhausted: bool, +} + +/// Lane-aware usage assessment across every lane an account exposes. +#[derive(Clone, Debug, PartialEq)] +pub struct AuthProfileUsageAssessment { + /// Aggregate health. Exhausted only when every scored lane is exhausted. + pub health: AuthProfileUsageHealth, + /// Every lane that carried an enabled window, scored independently. + pub lanes: Vec, +} + +impl AuthProfileUsageAssessment { + /// Lanes that still have capacity, best-remaining first. + /// + /// A caller that needs to name a usable model reads the first entry. + pub fn usable_lanes(&self) -> Vec<&AuthProfileUsageLane> { + let mut usable = self + .lanes + .iter() + .filter(|lane| !lane.exhausted) + .collect::>(); + usable.sort_by(|left, right| { + right + .remaining_percent + .total_cmp(&left.remaining_percent) + }); + usable + } +} + +/// Scores every rate-limit lane using the Codewith auth-profile auto-switch window settings. +/// +/// Each lane is scored independently and the profile is exhausted only when EVERY scored lane +/// is exhausted. Scoring a single lane and discarding the rest reports a profile as unusable +/// while real capacity sits on a sibling lane; taking the best lane without keeping the others +/// is the mirror failure, because work routed at the default model still dies on a spent lane. +/// Both are avoided by carrying the per-lane result. +/// +/// Only the 5h and weekly windows of a lane participate. A lane with no enabled window does not +/// score; when no lane scores the result is [`AuthProfileUsageHealth::Unknown`] rather than +/// exhausted. +pub fn usage_assessment_for_snapshots( + snapshots: &[RateLimitSnapshot], + config: &AuthProfileAutoSwitchConfig, +) -> AuthProfileUsageAssessment { + let lanes = snapshots + .iter() + .filter_map(|snapshot| score_usage_lane(snapshot, config)) + .collect::>(); + + if lanes.is_empty() { + return AuthProfileUsageAssessment { + health: AuthProfileUsageHealth::Unknown, + lanes, + }; + } + + // The best lane decides the profile: any lane with capacity makes the profile usable. + let best = lanes + .iter() + .filter(|lane| !lane.exhausted) + .max_by(|left, right| left.remaining_percent.total_cmp(&right.remaining_percent)); + + let health = match best { + Some(lane) => AuthProfileUsageHealth::Healthy { + remaining_percent: lane.remaining_percent, + resets_at: lane.resets_at, + }, + None => { + let mut retry_at = None; + for lane in &lanes { + merge_retry_at(&mut retry_at, lane.retry_at); + } + let remaining_percent = lanes + .iter() + .map(|lane| lane.remaining_percent) + .fold(0.0_f64, f64::max); + AuthProfileUsageHealth::Exhausted { + remaining_percent, + retry_at, + } + } + }; + + AuthProfileUsageAssessment { health, lanes } +} + /// Scores rate-limit snapshots using the Codewith auth-profile auto-switch window settings. /// -/// Only the 5h and weekly Codex windows participate. Missing or unsupported windows return -/// [`AuthProfileUsageHealth::Unknown`] rather than exhausted. +/// Aggregate-only view of [`usage_assessment_for_snapshots`]; callers that need to know which +/// lane still has capacity should use that instead. pub fn usage_health_for_snapshots( snapshots: &[RateLimitSnapshot], config: &AuthProfileAutoSwitchConfig, ) -> AuthProfileUsageHealth { - let Some(snapshot) = snapshots - .iter() - .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) - .or_else(|| snapshots.first()) - else { - return AuthProfileUsageHealth::Unknown; - }; + usage_assessment_for_snapshots(snapshots, config).health +} +/// Scores one lane. Returns `None` when the lane carries no enabled window. +fn score_usage_lane( + snapshot: &RateLimitSnapshot, + config: &AuthProfileAutoSwitchConfig, +) -> Option { let mut backend_blocked = backend_usage_is_blocked(snapshot); let mut has_enabled_window = false; let mut limiting_remaining_percent = 100.0; @@ -166,19 +278,32 @@ pub fn usage_health_for_snapshots( } } - if backend_blocked { - return AuthProfileUsageHealth::Exhausted { retry_at }; - } + // A backend-blocked lane still scores even with no enabled window: the backend has said + // it is unusable, which outranks the absence of window data. Without window evidence its + // remaining capacity is reported as zero rather than assumed full, so it cannot inflate + // the aggregate figure. if !has_enabled_window { - return AuthProfileUsageHealth::Unknown; - } - if limiting_remaining_percent <= 0.0 || retry_at.is_some() { - return AuthProfileUsageHealth::Exhausted { retry_at }; + if !backend_blocked { + return None; + } + return Some(AuthProfileUsageLane { + limit_id: snapshot.limit_id.clone(), + limit_name: snapshot.limit_name.clone(), + remaining_percent: 0.0, + resets_at: None, + retry_at, + exhausted: true, + }); } - AuthProfileUsageHealth::Healthy { + + Some(AuthProfileUsageLane { + limit_id: snapshot.limit_id.clone(), + limit_name: snapshot.limit_name.clone(), remaining_percent: limiting_remaining_percent, resets_at: limiting_resets_at, - } + retry_at, + exhausted: backend_blocked || limiting_remaining_percent <= 0.0 || retry_at.is_some(), + }) } /// Returns configured ChatGPT auth profiles in the order used for auth-profile decisions. @@ -456,6 +581,122 @@ mod tests { } } + /// Builds one named rate-limit lane (a `limit_id`) with both windows at `used_percent`. + fn lane( + limit_id: &str, + limit_name: Option<&str>, + used_percent: f64, + reached: Option, + ) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some(limit_id.to_string()), + limit_name: limit_name.map(str::to_string), + primary: Some(RateLimitWindow { + used_percent, + window_minutes: Some(7 * 24 * 60), + resets_at: Some(200), + }), + secondary: Some(RateLimitWindow { + used_percent, + window_minutes: Some(5 * 60), + resets_at: Some(100), + }), + credits: None, + individual_limit: None, + plan_type: None, + rate_limit_reached_type: reached, + } + } + + /// The station01 shape: the generic `codex` lane is spent while the + /// `codex_bengalfox` (GPT-5.3-Codex-Spark) lane is untouched. A profile in this + /// state is usable and MUST NOT be reported exhausted. + fn mixed_lanes() -> Vec { + vec![ + lane( + "codex", + None, + 100.0, + Some(RateLimitReachedType::RateLimitReached), + ), + lane("codex_bengalfox", Some("GPT-5.3-Codex-Spark"), 0.0, None), + ] + } + + /// Every lane spent. This profile really is unusable and MUST stay exhausted. + fn all_lanes_spent() -> Vec { + vec![ + lane( + "codex", + None, + 100.0, + Some(RateLimitReachedType::RateLimitReached), + ), + lane( + "codex_bengalfox", + Some("GPT-5.3-Codex-Spark"), + 100.0, + Some(RateLimitReachedType::RateLimitReached), + ), + ] + } + + #[test] + fn usage_health_reports_capacity_when_a_sibling_lane_is_free() { + let assessment = usage_assessment_for_snapshots(&mixed_lanes(), &config()); + + // `resets_at` is None because an entirely unused lane never drops below the 100% + // starting point that records the limiting window. That is the pre-existing + // single-lane behaviour, carried over unchanged. + assert_eq!( + AuthProfileUsageHealth::Healthy { + remaining_percent: 100.0, + resets_at: None, + }, + assessment.health, + "a profile with a free codex_bengalfox lane must not be reported exhausted" + ); + + // The report must name which model is still usable, not merely say "usable". + let usable = assessment.usable_lanes(); + assert_eq!( + vec![Some("codex_bengalfox")], + usable + .iter() + .map(|lane| lane.limit_id.as_deref()) + .collect::>() + ); + assert_eq!(Some("GPT-5.3-Codex-Spark"), usable[0].limit_name.as_deref()); + + // The spent lane is still carried, so a caller cannot mistake this for + // "every model is available". + assert_eq!(2, assessment.lanes.len()); + assert!( + assessment + .lanes + .iter() + .any(|lane| lane.limit_id.as_deref() == Some("codex") && lane.exhausted) + ); + } + + #[test] + fn usage_health_stays_exhausted_when_every_lane_is_spent() { + let assessment = usage_assessment_for_snapshots(&all_lanes_spent(), &config()); + + assert_eq!( + AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, + retry_at: Some(100), + }, + assessment.health, + "a profile whose every lane is spent must stay exhausted" + ); + assert!( + assessment.usable_lanes().is_empty(), + "no lane may be offered as usable when every lane is spent" + ); + } + fn profile(name: &str, provider: AuthProfileSubscriptionProvider) -> AuthProfile { AuthProfile { name: name.to_string(), @@ -489,6 +730,7 @@ mod tests { fn usage_health_detects_exhausted_5h_and_weekly_windows() { assert_eq!( AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, retry_at: Some(100) }, usage_health_for_snapshots( @@ -500,6 +742,7 @@ mod tests { ); assert_eq!( AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, retry_at: Some(200) }, usage_health_for_snapshots( @@ -560,7 +803,10 @@ mod tests { resets_at: 300, }); assert_eq!( + // Spend control blocks the lane while its windows still hold 80% capacity. + // The reported figure is that measurement, not an assumed zero. AuthProfileUsageHealth::Exhausted { + remaining_percent: 80.0, retry_at: Some(300) }, usage_health_for_snapshots(&[spend_control_blocked], &config()) @@ -570,7 +816,10 @@ mod tests { reached.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); assert_eq!( - AuthProfileUsageHealth::Exhausted { retry_at: None }, + AuthProfileUsageHealth::Exhausted { + remaining_percent: 80.0, + retry_at: None + }, usage_health_for_snapshots(&[reached], &config()) ); } @@ -635,7 +884,10 @@ mod tests { let health = vec![ ( Some("work".to_string()), - AuthProfileUsageHealth::Exhausted { retry_at: Some(1) }, + AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, + retry_at: Some(1), + }, ), ( Some("second".to_string()), @@ -691,7 +943,10 @@ mod tests { let health = vec![ ( Some("work".to_string()), - AuthProfileUsageHealth::Exhausted { retry_at: Some(1) }, + AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, + retry_at: Some(1), + }, ), (Some("second".to_string()), AuthProfileUsageHealth::Unknown), ]; @@ -716,7 +971,10 @@ mod tests { let health = vec![ ( Some("work".to_string()), - AuthProfileUsageHealth::Exhausted { retry_at: Some(1) }, + AuthProfileUsageHealth::Exhausted { + remaining_percent: 0.0, + retry_at: Some(1), + }, ), (Some("second".to_string()), AuthProfileUsageHealth::Unknown), ( diff --git a/codex-rs/core/src/tools/handlers/auth_profile_usage_control.rs b/codex-rs/core/src/tools/handlers/auth_profile_usage_control.rs index 2d7791cb3..27a2ab19d 100644 --- a/codex-rs/core/src/tools/handlers/auth_profile_usage_control.rs +++ b/codex-rs/core/src/tools/handlers/auth_profile_usage_control.rs @@ -651,6 +651,7 @@ fn auth_profile_usage_health_from_summary( resets_at: summary.resets_at, }, AuthProfileUsageStatus::Exhausted => AuthProfileUsageHealth::Exhausted { + remaining_percent: summary.remaining_percent.unwrap_or(0.0), retry_at: summary.resets_at, }, AuthProfileUsageStatus::Unknown => AuthProfileUsageHealth::Unknown, @@ -777,9 +778,12 @@ impl AuthProfileUsageSummary { stale, reason: None, }, - AuthProfileUsageHealth::Exhausted { retry_at } => Self { + AuthProfileUsageHealth::Exhausted { + remaining_percent, + retry_at, + } => Self { status: AuthProfileUsageStatus::Exhausted, - remaining_percent: Some(0.0), + remaining_percent: Some(remaining_percent), resets_at: retry_at, captured_at: Some(captured_at), stale, From c722a49442f69632fcb3800fdf74ade31917649f Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 12 Aug 2026 22:43:55 +0300 Subject: [PATCH 2/2] fix(review): satisfy usage health review gates Agent: unresolved-account011 --- codex-rs/cli/src/usage_cmd.rs | 8 +++++++- codex-rs/core/src/auth_profile_usage.rs | 23 ++++++++++++----------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/codex-rs/cli/src/usage_cmd.rs b/codex-rs/cli/src/usage_cmd.rs index 9e25c8a57..85217d0e0 100644 --- a/codex-rs/cli/src/usage_cmd.rs +++ b/codex-rs/cli/src/usage_cmd.rs @@ -1070,7 +1070,13 @@ mod tests { "status": "healthy", "remainingPercent": 20.0, "resetsAt": 100, - "reason": null + "reason": null, + "usableLanes": [{ + "limitId": "codex", + "limitName": null, + "remainingPercent": 20.0, + "resetsAt": 100 + }] }) ); } diff --git a/codex-rs/core/src/auth_profile_usage.rs b/codex-rs/core/src/auth_profile_usage.rs index fb1d7e17c..abdbedc4c 100644 --- a/codex-rs/core/src/auth_profile_usage.rs +++ b/codex-rs/core/src/auth_profile_usage.rs @@ -162,11 +162,7 @@ impl AuthProfileUsageAssessment { .iter() .filter(|lane| !lane.exhausted) .collect::>(); - usable.sort_by(|left, right| { - right - .remaining_percent - .total_cmp(&left.remaining_percent) - }); + usable.sort_by(|left, right| right.remaining_percent.total_cmp(&left.remaining_percent)); usable } } @@ -615,11 +611,16 @@ mod tests { vec![ lane( "codex", - None, - 100.0, + /*limit_name*/ None, + /*used_percent*/ 100.0, Some(RateLimitReachedType::RateLimitReached), ), - lane("codex_bengalfox", Some("GPT-5.3-Codex-Spark"), 0.0, None), + lane( + "codex_bengalfox", + Some("GPT-5.3-Codex-Spark"), + /*used_percent*/ 0.0, + /*reached*/ None, + ), ] } @@ -628,14 +629,14 @@ mod tests { vec![ lane( "codex", - None, - 100.0, + /*limit_name*/ None, + /*used_percent*/ 100.0, Some(RateLimitReachedType::RateLimitReached), ), lane( "codex_bengalfox", Some("GPT-5.3-Codex-Spark"), - 100.0, + /*used_percent*/ 100.0, Some(RateLimitReachedType::RateLimitReached), ), ]