Add Luna Reserve usage fallback to the TUI (#42372)

## Why

When ordinary included usage is exhausted, eligible users need a way to keep working with the backend-provided Luna Reserve quota and return to their previous model once ordinary usage recovers.

## What changed

- Automatically switch eligible TUI tasks to Luna Reserve after an authoritative account usage read, preserving the prior model and reasoning effort per task.
- Restrict model selection while on Reserve to its supported reasoning levels, keep queued turns on the accepted model, and restore the saved model after a fresh read confirms recovery.
- Surface Reserve usage in the composer and `/status`, and expose `normalModelSlug` in app-server rate-limit snapshots so clients can use the associated model's display metadata without changing the request model.
- Poll account limits more frequently near exhaustion and retain compatibility with app servers that only accept the legacy null request parameters.

## Testing

- Added coverage for Reserve entry, queued-turn handling, task reconstruction, recovery authorization, account changes, model selection, usage rendering, and banner dismissal.

GitOrigin-RevId: 46a435e9531051f8cec3f6a46c4b9f7cfa479747
This commit is contained in:
dhruvgupta-oai
2026-09-02 21:50:51 +00:00
committed by copyberry
parent 76f47103fe
commit 5037919777
76 changed files with 1907 additions and 135 deletions
@@ -3415,6 +3415,13 @@
"null"
]
},
"normalModelSlug": {
"description": "Normal model whose display name and reasoning options describe this quota alias.",
"type": [
"string",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -17482,6 +17482,13 @@
"null"
]
},
"normalModelSlug": {
"description": "Normal model whose display name and reasoning options describe this quota alias.",
"type": [
"string",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -13554,6 +13554,13 @@
"null"
]
},
"normalModelSlug": {
"description": "Normal model whose display name and reasoning options describe this quota alias.",
"type": [
"string",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -88,6 +88,13 @@
"null"
]
},
"normalModelSlug": {
"description": "Normal model whose display name and reasoning options describe this quota alias.",
"type": [
"string",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -174,6 +174,13 @@
"null"
]
},
"normalModelSlug": {
"description": "Normal model whose display name and reasoning options describe this quota alias.",
"type": [
"string",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -7,7 +7,11 @@ import type { RateLimitReachedType } from "./RateLimitReachedType";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null,
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null,
/**
* Normal model whose display name and reasoning options describe this quota alias.
*/
normalModelSlug: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null,
/**
* Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
*/
@@ -585,6 +585,8 @@ pub struct AccountRateLimitsUpdatedNotification {
pub struct RateLimitSnapshot {
pub limit_id: Option<String>,
pub limit_name: Option<String>,
/// Normal model whose display name and reasoning options describe this quota alias.
pub normal_model_slug: Option<String>,
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
@@ -600,6 +602,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
Self {
limit_id: value.limit_id,
limit_name: value.limit_name,
normal_model_slug: value.normal_model_slug,
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
+1
View File
@@ -2798,6 +2798,7 @@ Field notes:
- `usedPercent` is current usage within the OpenAI quota window.
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `normalModelSlug` optionally identifies the normal model associated with an additional quota, forwarded from `/wham/usage`'s `normal_model_slug`. Clients can use that model's catalog name and reasoning choices without changing the quota alias used for requests. Missing metadata is `null`; it does not grant model access.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `accountId` identifies the account in the usage snapshot when the backend supplies it.
@@ -3845,6 +3845,7 @@ mod tests {
let rate_limits = RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 42.5,
window_minutes: Some(15),
@@ -836,6 +836,7 @@ mod tests {
rate_limits: RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 25,
window_duration_mins: Some(15),
@@ -857,6 +858,7 @@ mod tests {
"rateLimits": {
"limitId": "codex",
"limitName": null,
"normalModelSlug": null,
"primary": {
"usedPercent": 25,
"windowDurationMins": 15,
@@ -255,6 +255,7 @@ async fn get_account_rate_limits_returns_snapshot(
rate_limits: RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 42,
window_duration_mins: Some(60),
@@ -283,6 +284,7 @@ async fn get_account_rate_limits_returns_snapshot(
RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 42,
window_duration_mins: Some(60),
@@ -312,6 +314,7 @@ async fn get_account_rate_limits_returns_snapshot(
RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 88,
window_duration_mins: Some(30),
+23 -11
View File
@@ -565,21 +565,30 @@ impl Client {
rate_limit_reached_type,
)];
if let Some(additional) = payload.additional_rate_limits.flatten() {
snapshots.extend(additional.into_iter().map(|details| {
Self::make_rate_limit_snapshot(
Some(details.metered_feature),
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
}));
snapshots.extend(
additional
.into_iter()
.map(|details| Self::make_additional_rate_limit_snapshot(details, plan_type)),
);
}
snapshots
}
fn make_additional_rate_limit_snapshot(
details: codex_backend_openapi_models::models::AdditionalRateLimitDetails,
plan_type: Option<AccountPlanType>,
) -> RateLimitSnapshot {
Self::make_rate_limit_snapshot(
Some(details.metered_feature),
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
}
fn make_rate_limit_snapshot(
limit_id: Option<String>,
limit_name: Option<String>,
@@ -603,6 +612,7 @@ impl Client {
RateLimitSnapshot {
limit_id,
limit_name,
normal_model_slug: None,
primary,
secondary,
credits: Self::map_credits(credits),
@@ -953,6 +963,7 @@ mod tests {
RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 90.0,
window_minutes: Some(60),
@@ -968,6 +979,7 @@ mod tests {
RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 10.0,
window_minutes: Some(60),
@@ -42,8 +42,22 @@ impl Client {
.as_ref()
.and_then(|limit| limit.as_deref())
.map(|limit| limit.allowed);
let mut rate_limits = Self::rate_limit_snapshots_from_payload(payload.rate_limits);
let plan_type = rate_limits.first().and_then(|snapshot| snapshot.plan_type);
rate_limits.extend(
payload
.additional_rate_limits
.into_iter()
.flatten()
.map(|limit| {
let mut snapshot =
Self::make_additional_rate_limit_snapshot(limit.details, plan_type);
snapshot.normal_model_slug = limit.normal_model_slug;
snapshot
}),
);
Ok(RateLimitsWithResetCredits {
rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits),
rate_limits,
ordinary_usage_allowed,
rate_limit_reset_credits: payload.rate_limit_reset_credits,
account_id: payload.account_id,
+11
View File
@@ -56,6 +56,9 @@ pub struct RateLimitsWithResetCredits {
pub(crate) struct RateLimitStatusWithResetCredits {
#[serde(flatten)]
pub rate_limits: RateLimitStatusPayload,
// Capture the new optional metadata alongside the generated quota fields until the
// backend OpenAPI export includes it. This field owns additional_rate_limits on the wire.
pub additional_rate_limits: Option<Vec<AdditionalRateLimitWithNormalModel>>,
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
pub account_id: Option<String>,
pub user_id: Option<String>,
@@ -63,6 +66,14 @@ pub(crate) struct RateLimitStatusWithResetCredits {
pub rate_limit_upsell: Option<Value>,
}
/// Additional quota details plus the normal model whose picker metadata should be used.
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub(crate) struct AdditionalRateLimitWithNormalModel {
#[serde(flatten)]
pub details: codex_backend_openapi_models::models::AdditionalRateLimitDetails,
pub normal_model_slug: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessagesResponse {
#[serde(default)]
+2
View File
@@ -90,6 +90,7 @@ pub fn parse_rate_limit_for_limit(
Some(RateLimitSnapshot {
limit_id: Some(normalized_limit_id),
limit_name: parsed_limit_name,
normal_model_slug: None,
primary,
secondary,
credits,
@@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
Some(RateLimitSnapshot {
limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())),
limit_name: None,
normal_model_slug: None,
primary,
secondary,
credits,
+6
View File
@@ -4506,6 +4506,7 @@ async fn set_rate_limits_retains_previous_credits() {
let initial = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 10.0,
window_minutes: Some(15),
@@ -4527,6 +4528,7 @@ async fn set_rate_limits_retains_previous_credits() {
let update = RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 40.0,
window_minutes: Some(30),
@@ -4550,6 +4552,7 @@ async fn set_rate_limits_retains_previous_credits() {
Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: update.primary.clone(),
secondary: update.secondary,
credits: initial.credits,
@@ -4622,6 +4625,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
let initial = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 15.0,
window_minutes: Some(20),
@@ -4647,6 +4651,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
let update = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 35.0,
window_minutes: Some(25),
@@ -4666,6 +4671,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: update.primary,
secondary: update.secondary,
credits: initial.credits,
+7
View File
@@ -89,6 +89,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
state.set_rate_limits(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(60),
@@ -135,6 +136,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 20.0,
window_minutes: Some(60),
@@ -150,6 +152,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
state.set_rate_limits(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(60),
@@ -180,6 +183,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 10.0,
window_minutes: Some(60),
@@ -205,6 +209,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(120),
@@ -223,6 +228,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(120),
@@ -249,6 +255,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: None,
@@ -8,6 +8,7 @@ fn snapshot(
RateLimitSnapshot {
limit_id: Some(crate::guard_limits::CODEX_LIMIT_ID.to_string()),
limit_name: None,
normal_model_slug: None,
primary: primary_used_percent.map(window),
secondary: secondary_used_percent.map(window),
credits: None,
+3
View File
@@ -650,6 +650,8 @@ pub struct UsageLimitReachedError {
impl std::fmt::Display for UsageLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Reserve is a fallback for exhausted ordinary usage, so keep the standard
// promo/plan recovery copy below instead of suggesting another model.
if let Some(limit_name) = self
.rate_limits
.as_ref()
@@ -657,6 +659,7 @@ impl std::fmt::Display for UsageLimitReachedError {
.map(str::trim)
.filter(|name| !name.is_empty())
&& !limit_name.eq_ignore_ascii_case("codex")
&& !limit_name.eq_ignore_ascii_case("gpt-reserve")
{
return write!(
f,
+1
View File
@@ -88,6 +88,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 50.0,
window_minutes: Some(60),
+3
View File
@@ -2324,6 +2324,9 @@ pub struct TokenCountEvent {
pub struct RateLimitSnapshot {
pub limit_id: Option<String>,
pub limit_name: Option<String>,
/// Normal model metadata for a quota alias; never a replacement for the request model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub normal_model_slug: Option<String>,
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
@@ -1728,6 +1728,7 @@ async fn paginated_projection_accepts_float_rate_limits_and_later_final_answers(
rate_limits: Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: primary.map(|used_percent| RateLimitWindow {
used_percent,
window_minutes: Some(60),
@@ -1,27 +1,71 @@
//! Apply an accepted account usage response to the currently selected task and model.
//! Apply backend-authorized fallback and task-local Reserve settings without saving defaults.
use super::App;
use crate::app_server_session::AppServerSession;
use crate::chatwidget::AutomaticModelSwitchReason;
use crate::model_catalog::LUNA_RESERVE_MODEL;
use crate::model_catalog::model_display_name;
use crate::service_tier_resolution;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ReasoningEffort;
impl App {
pub(super) async fn update_luna_reserve_reasoning(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
effort: Option<ReasoningEffort>,
) {
// A picker left open during recovery or a task switch must not affect the new model.
if self.active_thread_id != Some(thread_id)
|| self.chat_widget.thread_id() != Some(thread_id)
|| self.chat_widget.current_model() != LUNA_RESERVE_MODEL
{
return;
}
let mut mode = self.chat_widget.effective_collaboration_mode();
mode.settings.reasoning_effort = effort.clone();
let params = ThreadSettingsUpdateParams {
thread_id: thread_id.to_string(),
effort: effort.clone(),
collaboration_mode: Some(mode.clone()),
..ThreadSettingsUpdateParams::default()
};
if self.send_thread_settings_update(app_server, params).await {
self.chat_widget.set_reasoning_effort(effort.clone());
if mode.mode == ModeKind::Plan {
self.chat_widget.set_plan_mode_reasoning_effort(effort);
}
}
}
pub(super) async fn apply_backend_banner_fallback(
&mut self,
app_server: &mut AppServerSession,
) {
// Reattached tasks must wait for the read that supersedes the latest hard stop.
if self.rate_limit_refresh_state.has_pending_recovery() {
return;
}
let Some(thread_id) = self.active_thread_id else {
return;
};
if Some(thread_id) != self.chat_widget.thread_id() {
return;
}
let Some(target) = self.chat_widget.backend_banner_fallback() else {
let Some(switch) = self.chat_widget.backend_banner_fallback() else {
return;
};
let effort = self
.chat_widget
.current_reasoning_effort()
let target = switch.model;
let entering_reserve = target.model == crate::model_catalog::LUNA_RESERVE_MODEL;
if entering_reserve && !self.chat_widget.prepare_luna_reserve_return() {
self.chat_widget.show_unavailable_reserve_recovery();
return;
}
let effort = switch
.effort
.filter(|effort| {
target
.supported_reasoning_efforts
@@ -53,13 +97,24 @@ impl App {
self.chat_widget.finish_backend_banner_fallback(mode);
self.sync_active_thread_service_tier_to_cached_session()
.await;
let mut message = format!("Automatically switched to {}", target.model);
let (prefix, suffix) = match switch.reason {
AutomaticModelSwitchReason::UsageLimit => {
("Automatically switched to", " due to usage limits.")
}
AutomaticModelSwitchReason::UsageRecovered => (
"Automatically switched back to",
" because ordinary usage is available again.",
),
};
let mut message = format!("{prefix} {}", model_display_name(&target.model));
if let Some(label) = Self::reasoning_label_for(&target.model, Some(&effort)) {
message.push(' ');
message.push_str(&label);
}
message.push_str(" due to usage limits.");
message.push_str(suffix);
self.chat_widget.add_info_message(message, /*hint*/ None);
} else if entering_reserve {
self.chat_widget.show_unavailable_reserve_recovery();
}
}
}
+28 -6
View File
@@ -14,6 +14,7 @@ use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::GetAccountRateLimitsParams;
use codex_app_server_protocol::GetAccountTokenUsageParams;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::MarketplaceAddParams;
@@ -85,6 +86,7 @@ impl App {
origin,
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. }
) {
self.chat_widget.invalidate_ordinary_usage_recovery();
self.chat_widget.hold_rate_limit_recovery();
}
let Some((request_id, hard_stop_generation)) = self
@@ -96,9 +98,10 @@ impl App {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let request = fetch_account_rate_limits(request_handle);
let request = fetch_account_rate_limits(request_handle, origin);
let result = match origin {
RateLimitRefreshOrigin::Recovery
| RateLimitRefreshOrigin::Periodic
| RateLimitRefreshOrigin::ResetConsume { .. }
| RateLimitRefreshOrigin::ResetPicker { .. } => {
tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request)
@@ -798,15 +801,34 @@ pub(super) async fn fetch_all_mcp_server_statuses(
pub(super) async fn fetch_account_rate_limits(
request_handle: AppServerRequestHandle,
origin: RateLimitRefreshOrigin,
) -> Result<GetAccountRateLimitsResponse> {
let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4()));
request_handle
let result = request_handle
.request_typed(ClientRequest::GetAccountRateLimits {
request_id,
params: None,
request_id: request_id.clone(),
params: Some(GetAccountRateLimitsParams {
supports_luna_reserve: true,
exclude_reset_credit_details: origin == RateLimitRefreshOrigin::Periodic,
}),
})
.await
.wrap_err("account/rateLimits/read failed in TUI")
.await;
// Older remote app servers accept only null params. Keep their usage reads working
// without opting them into exposure or pretending that they support the new capability.
if matches!(
&result,
Err(codex_app_server_client::TypedRequestError::Server { source, .. })
if matches!(source.code, -32600 | -32602)
) {
return request_handle
.request_typed(ClientRequest::GetAccountRateLimits {
request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed in TUI");
}
result.wrap_err("account/rateLimits/read failed in TUI")
}
pub(super) async fn fetch_account_token_activity(
+33 -5
View File
@@ -721,7 +721,16 @@ impl App {
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
AppEvent::CodexOp(op) => {
AppEvent::CodexOp(mut op) => {
if self.active_thread_id == self.chat_widget.thread_id() {
if matches!(&op, AppCommand::UserTurn { .. })
&& self.chat_widget.defer_pending_turn_for_luna_reserve()
{
return Ok(AppRunControl::Continue);
}
self.chat_widget
.apply_reserve_fallback_to_pending_turn(&mut op);
}
let is_user_turn = matches!(&op, AppCommand::UserTurn { .. });
if is_user_turn {
let screen_size = tui.terminal.last_known_screen_size;
@@ -1219,6 +1228,17 @@ impl App {
AppEvent::RefreshRateLimits { origin } => {
self.refresh_rate_limits(app_server, origin);
}
AppEvent::ApplyBackendBannerFallback { thread_id } => {
if self.active_thread_id == Some(thread_id)
&& self.chat_widget.thread_id() == Some(thread_id)
{
self.apply_backend_banner_fallback(app_server).await;
if !self.rate_limit_refresh_state.has_pending_recovery() {
self.chat_widget.finish_rate_limit_recovery();
}
self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Periodic);
}
}
AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id);
}
@@ -1300,7 +1320,7 @@ impl App {
Vec::new()
};
match origin {
RateLimitRefreshOrigin::Recovery => {
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::Periodic => {
for snapshot in snapshots {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
@@ -1361,7 +1381,11 @@ impl App {
// A failed read is not authoritative recovery. Keep the last valid banner.
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
match origin {
RateLimitRefreshOrigin::Recovery => {},
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::Periodic => {
// Re-evaluate snapshot age even when the backend cannot refresh it.
// This updates display freshness without authorizing model recovery.
self.chat_widget.refresh_status_surfaces();
},
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
} => {
@@ -1399,10 +1423,10 @@ impl App {
}
}
}
if matches!(
if (accepted || matches!(
origin,
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. }
) && !self.rate_limit_refresh_state.has_pending_recovery()
)) && !self.rate_limit_refresh_state.has_pending_recovery()
{
self.chat_widget.finish_rate_limit_recovery();
}
@@ -1541,6 +1565,10 @@ impl App {
self.sync_active_thread_reasoning_setting(app_server, effort)
.await;
}
AppEvent::UpdateLunaReserveReasoning { thread_id, effort } => {
self.update_luna_reserve_reasoning(app_server, thread_id, effort)
.await;
}
AppEvent::UpdateModel(model) => {
let model_changed = self.chat_widget.current_model() != model
|| self.chat_widget.current_collaboration_mode().model() != model;
@@ -1,12 +1,17 @@
//! Orders full usage reads and coalesces recovery refreshes within an account/limit generation.
//! Periodic reads share that ordering and wait while another periodic read or recovery is pending.
use crate::app_event::RateLimitRefreshOrigin;
use std::time::Duration;
use std::time::Instant;
#[derive(Default)]
pub(super) struct RateLimitRefreshState {
next_id: u64,
applied_id: u64,
recovery: Option<PendingRecovery>,
periodic: Option<(u64, u64)>,
last_requested_at: Option<Instant>,
}
struct PendingRecovery {
@@ -28,6 +33,17 @@ pub(super) enum RateLimitReadStatus {
}
impl RateLimitRefreshState {
pub(super) fn poll_deadline(&self, interval: Duration) -> Option<Instant> {
if self.periodic.is_some() || self.recovery.is_some() {
return None;
}
Some(
self.last_requested_at
.map(|last| last + interval)
.unwrap_or_else(Instant::now),
)
}
pub(super) fn has_pending_recovery(&self) -> bool {
self.recovery.is_some()
}
@@ -37,6 +53,11 @@ impl RateLimitRefreshState {
origin: RateLimitRefreshOrigin,
generation: &mut u64,
) -> Option<(u64, u64)> {
if origin == RateLimitRefreshOrigin::Periodic
&& (self.periodic.is_some() || self.recovery.is_some())
{
return None;
}
if matches!(origin, RateLimitRefreshOrigin::Recovery) {
// A new hard error supersedes a post-reset read that started before the error.
if self
@@ -51,6 +72,10 @@ impl RateLimitRefreshState {
}
self.next_id = self.next_id.wrapping_add(1);
let request = (self.next_id, *generation);
self.last_requested_at = Some(Instant::now());
if origin == RateLimitRefreshOrigin::Periodic {
self.periodic = Some(request);
}
// The post-reset read takes over the hold from the invalidated recovery request.
if matches!(
origin,
@@ -64,6 +89,8 @@ impl RateLimitRefreshState {
// Account changes and successful resets invalidate the need as well as the old response.
pub(super) fn invalidate_recovery(&mut self) {
self.recovery = None;
self.periodic = None;
self.last_requested_at = None;
}
pub(super) fn finish(
@@ -73,6 +100,14 @@ impl RateLimitRefreshState {
current_generation: u64,
status: RateLimitReadStatus,
) -> RateLimitRefreshOutcome {
if self.periodic == Some((request_id, generation)) {
self.periodic = None;
}
if generation == current_generation && request_id == self.next_id {
// Completion starts the next wait, including after failures/timeouts, so errors
// cannot create a tight retry loop while the account is nearly exhausted.
self.last_requested_at = Some(Instant::now());
}
if self
.recovery
.as_ref()
+15
View File
@@ -765,6 +765,10 @@ See the Codex keymap documentation for supported actions and examples."
&& has_pending_app_events
|| (!waiting_for_initial_session_configured
&& app.has_queued_startup_protected_request());
let rate_limit_poll_deadline = app
.chat_widget
.rate_limit_refresh_interval()
.and_then(|interval| app.rate_limit_refresh_state.poll_deadline(interval));
let control = select! {
Some(event) = app_event_rx.recv() => {
let is_initial_session_header = matches!(
@@ -873,6 +877,17 @@ See the Codex keymap documentation for supported actions and examples."
}
AppRunControl::Continue
}
() = async {
match rate_limit_poll_deadline {
Some(deadline) => {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
}
None => std::future::pending().await,
}
}, if listen_for_app_server_events => {
app.refresh_rate_limits(&app_server, RateLimitRefreshOrigin::Periodic);
AppRunControl::Continue
}
() = async {
match app.chat_widget.terminal_title_next_refresh {
Some(deadline) => {
+2
View File
@@ -20,6 +20,8 @@ mod connector_policy;
mod disconnect;
#[path = "tests/key_chords.rs"]
mod key_chords;
#[path = "tests/luna_reserve_recovery_tests.rs"]
mod luna_reserve_recovery_tests;
#[path = "tests/mcp_startup.rs"]
mod mcp_startup;
mod model_catalog;
@@ -29,7 +29,7 @@ fn configure_fallback_model(app: &mut App) {
app.chat_widget.set_model("gpt-5.4");
}
async fn start_fallback_thread(
pub(super) async fn start_fallback_thread(
app: &mut App,
) -> Result<session_lifecycle_requests::RecordingAppServer> {
let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server(
@@ -268,13 +268,22 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele
use futures::StreamExt;
use tokio_tungstenite::tungstenite::Message;
for error_code in [Some(-32601), Some(-32603), None] {
for (use_reserve, error_code) in [false, true]
.into_iter()
.flat_map(|reserve| [Some(-32601), Some(-32603), None].map(|error| (reserve, error)))
{
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let id = ThreadId::new();
app.active_thread_id = Some(id);
app.chat_widget
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
configure_fallback_model(&mut app);
if use_reserve {
app.primary_session_configured =
Some(test_thread_session(id, app.config.cwd.to_path_buf()));
super::luna_reserve_recovery_tests::configure_reserve_catalog(&mut app);
} else {
configure_fallback_model(&mut app);
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let websocket_url = format!("ws://{}", listener.local_addr()?);
let codex_home = app.config.codex_home.display().to_string();
@@ -322,7 +331,25 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele
crate::app_server_session::ThreadParamsMode::Embedded,
);
while events.try_recv().is_ok() {}
app.chat_widget.update_backend_banner(&fallback_response());
let pending_turn = if use_reserve && error_code.is_some() {
app.chat_widget
.restore_user_message_to_composer(UserMessage::from("queued before usage reply"));
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
Some(
std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
.expect("turn already queued before the failed switch"),
)
} else {
None
};
let response = if use_reserve {
super::luna_reserve_recovery_tests::reserve_response()
} else {
fallback_response()
};
app.chat_widget.update_backend_banner(&response);
app.apply_backend_banner_fallback(&mut server).await;
let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
let switch_notices = queued
@@ -337,7 +364,14 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele
.count();
assert_eq!(switch_notices, usize::from(error_code.is_none()));
if error_code.is_none() {
assert_eq!(app.chat_widget.current_model(), "gpt-5.2");
assert_eq!(
app.chat_widget.current_model(),
if use_reserve {
"gpt-reserve"
} else {
"gpt-5.2"
}
);
let manual_selection = queued
.into_iter()
.find(|event| matches!(event, AppEvent::UpdateModel(_)))
@@ -348,6 +382,43 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele
}
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 72).contains("View usage"));
if use_reserve && error_code.is_some() {
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90);
assert!(rendered.contains("Add credits"));
assert!(!rendered.contains("You’re now using Luna"));
if error_code == Some(-32601) {
insta::assert_snapshot!(
"luna_reserve_settings_unavailable",
normalize_snapshot_paths(rendered)
);
}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(
std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::OpenUrlInBrowser { .. }))
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_event(&mut tui, &mut server, pending_turn.unwrap())
.await?;
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued before usage reply"]
);
app.chat_widget.finish_rate_limit_recovery();
app.chat_widget
.restore_user_message_to_composer(UserMessage::from("after recovery"));
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued before usage reply", "after recovery"]
);
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
);
}
server.shutdown().await?;
fake.await??;
}
@@ -42,6 +42,85 @@ async fn next_rate_limits_loaded(
)
}
#[tokio::test]
async fn luna_reserve_periodic_refresh_adapts_without_an_experiment_banner() -> Result<()> {
let backend = MockServer::start().await;
let home = tempdir()?;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("local-test-token")
.account_id("workspace-a")
.chatgpt_user_id("user-a")
.plan_type("plus"),
AuthCredentialsStoreMode::File,
)
.expect("write synthetic auth");
std::fs::write(
home.path().join("config.toml"),
format!(
"chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n",
backend.uri()
),
)?;
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
app.config.codex_home = home.path().to_path_buf().abs();
app.config.chatgpt_base_url = backend.uri();
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs());
set_chatgpt_auth(&mut app.chat_widget);
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for (used, seconds) in [(10, 60), (75, 30), (90, 15), (99, 5), (20, 60)] {
backend.reset().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"account_id": "workspace-a", "user_id": "user-a", "plan_type": "plus",
"rate_limit": {"allowed": true, "limit_reached": false,
"primary_window": {"used_percent": used, "limit_window_seconds": 18000,
"reset_after_seconds": 1800, "reset_at": 2000000000}},
"rate_limit_reset_credits": {"available_count": 1}
})))
.expect(/*r*/ 1)
.mount(&backend)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(/*s*/ 200))
.expect(/*r*/ 0)
.mount(&backend)
.await;
app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic);
// A second timer tick while this RPC is pending must not add another request.
app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic);
assert!(
app.rate_limit_refresh_state
.poll_deadline(Duration::from_secs(/*secs*/ 60))
.is_none()
);
let loaded = next_rate_limits_loaded(&mut events).await?;
assert_matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. });
let before = std::time::Instant::now();
app.handle_event(&mut tui, &mut session, loaded).await?;
let after = std::time::Instant::now();
let interval = app.chat_widget.rate_limit_refresh_interval().unwrap();
assert_eq!(interval, Duration::from_secs(seconds));
let deadline = app
.rate_limit_refresh_state
.poll_deadline(interval)
.unwrap();
assert!(deadline >= before + interval && deadline <= after + interval);
let requests = backend.received_requests().await.unwrap();
let usage = requests
.iter()
.find(|request| request.url.path() == "/api/codex/usage")
.unwrap();
assert!(usage.headers.contains_key("x-openai-codex-luna-reserve"));
backend.verify().await;
}
session.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn backend_banner_limit_error_refreshes_again_after_intervening_rolling_hard_stop()
-> Result<()> {
@@ -0,0 +1,382 @@
//! Verify Reserve recovery through accepted usage reads and the task settings RPC.
use super::*;
use crate::chatwidget::UserMessage;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use pretty_assertions::assert_eq;
use serde_json::json;
pub(super) fn reserve_response() -> GetAccountRateLimitsResponse {
serde_json::from_value(json!({
"accountId": "workspace-a", "ordinaryUsageAllowed": false,
"rateLimits": {"limitId": "codex", "primary": {"usedPercent": 100}},
"rateLimitsByLimitId": {"base_model_inference": {
"limitId": "base_model_inference", "limitName": "gpt-reserve",
"primary": {"usedPercent": 48, "windowDurationMins": 10080}
}},
"rateLimitUpsell": {
"banner_type": "luna_reserve", "presentation": "dismissible",
"title": "You’re now using Luna, a faster model for simpler tasks.",
"description": "Add credits or upgrade to continue using the most advanced models.",
"ctas": [{"action": "add_credits", "label": "Add credits"}]
}
}))
.unwrap()
}
pub(super) fn configure_reserve_catalog(app: &mut App) {
let mut models = app.model_catalog.try_list_models().unwrap();
let mut reserve = models
.iter()
.find(|model| model.model == "gpt-5.4")
.unwrap()
.clone();
reserve.model = "gpt-reserve".into();
reserve.show_in_picker = false;
models.push(reserve);
let original = models
.iter_mut()
.find(|model| model.model == "gpt-5.4")
.unwrap();
let mut high = original.supported_reasoning_efforts[0].clone();
high.effort = ReasoningEffortConfig::High;
original.supported_reasoning_efforts.push(high);
app.model_catalog = Arc::new(ModelCatalog::new(models));
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
let mut init = app.chatwidget_init_for_forked_or_resumed_thread(
&mut tui,
app.config.clone(),
/*initial_user_message*/ None,
);
init.has_chatgpt_account = true;
init.has_codex_backend_auth = true;
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
app.chat_widget
.handle_thread_session(app.primary_session_configured.clone().unwrap());
app.chat_widget.set_model("gpt-5.4");
}
#[tokio::test]
async fn luna_reserve_recovery_survives_task_reconstruction() -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (mut server, requests, proxy) =
backend_banner_fallback_tests::start_fallback_thread(&mut app).await?;
configure_reserve_catalog(&mut app);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
app.chat_widget.update_backend_banner(&reserve_response());
app.apply_backend_banner_fallback(&mut server).await;
let mut resumed = app.primary_session_configured.clone().unwrap();
resumed.model = "gpt-reserve".into();
resumed.collaboration_mode = Some(Box::new(app.chat_widget.effective_collaboration_mode()));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut init = app.chatwidget_init_for_forked_or_resumed_thread(
&mut tui,
app.config.clone(),
/*initial_user_message*/ None,
);
init.has_chatgpt_account = true;
init.has_codex_backend_auth = true;
// No in-memory transition state is inherited: resume must recover the persisted target.
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
app.chat_widget.handle_thread_session(resumed);
assert_eq!(app.chat_widget.current_model(), "gpt-reserve");
let mut recovered = reserve_response();
recovered.ordinary_usage_allowed = Some(true);
recovered.rate_limit_upsell = None;
app.chat_widget.update_backend_banner(&recovered);
app.apply_backend_banner_fallback(&mut server).await;
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
requests
.lock()
.unwrap()
.iter()
.filter(|request| request.method == "thread/settings/update")
.count(),
2
);
assert!(
!app.config
.codex_home
.join("tui-luna-reserve")
.join(format!("{}.json", app.active_thread_id.unwrap()))
.exists()
);
server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn luna_reserve_entry_dispatches_an_already_queued_turn_with_accepted_settings() -> Result<()>
{
for mode in [ModeKind::Default, ModeKind::Plan] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let (mut server, requests, proxy) =
backend_banner_fallback_tests::start_fallback_thread(&mut app).await?;
configure_reserve_catalog(&mut app);
app.chat_widget
.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
app.chat_widget.set_service_tier(Some("fast".into()));
if mode == ModeKind::Plan {
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::BackTab));
}
app.chat_widget
.restore_user_message_to_composer(UserMessage::from("continue"));
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
let pending = std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
.expect("queued turn before the usage response");
requests.lock().unwrap().clear();
let mut tui = crate::tui::test_support::make_test_tui()?;
let generation = app.rate_limit_hard_stop_generation;
app.handle_event(
&mut tui,
&mut server,
AppEvent::RateLimitsLoaded {
request_id: 1,
origin: RateLimitRefreshOrigin::Periodic,
hard_stop_generation: generation,
result: Ok(reserve_response()),
},
)
.await?;
app.handle_event(&mut tui, &mut server, pending).await?;
let sent = requests.lock().unwrap().clone();
let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect();
assert_eq!(methods, ["thread/settings/update", "turn/start"]);
let settings: ThreadSettingsUpdateParams =
serde_json::from_value(sent[0].params.clone().unwrap())?;
let turn: codex_app_server_protocol::TurnStartParams =
serde_json::from_value(sent[1].params.clone().unwrap())?;
assert_eq!(
(
turn.model,
turn.effort,
turn.service_tier,
turn.collaboration_mode
),
(
settings.model,
settings.effort,
settings.service_tier,
settings.collaboration_mode
)
);
assert_eq!(app.chat_widget.effective_collaboration_mode().mode, mode);
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
#[tokio::test]
async fn luna_reserve_recovery_restores_task_and_pending_turn_after_fresh_backend_read()
-> Result<()> {
for has_credits in [false, true] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let (mut server, requests, proxy) =
backend_banner_fallback_tests::start_fallback_thread(&mut app).await?;
configure_reserve_catalog(&mut app);
app.chat_widget
.set_feature_enabled(Feature::FastMode, /*enabled*/ false);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let original_mode = app.chat_widget.effective_collaboration_mode();
let config_path = app.config.codex_home.join("config.toml");
let saved_config = std::fs::read(&config_path).ok();
let mut tui = crate::tui::test_support::make_test_tui()?;
requests.lock().unwrap().clear();
let generation = app.rate_limit_hard_stop_generation;
app.handle_event(
&mut tui,
&mut server,
AppEvent::RateLimitsLoaded {
request_id: 1,
origin: RateLimitRefreshOrigin::Periodic,
hard_stop_generation: generation,
result: Ok(reserve_response()),
},
)
.await?;
assert_eq!(app.chat_widget.current_model(), "gpt-reserve");
// A prompt composed before the next accepted read must use the recovered model, once.
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Esc));
app.chat_widget
.restore_user_message_to_composer(UserMessage::from("continue"));
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::Enter));
let mut pending = std::iter::from_fn(|| events.try_recv().ok())
.find_map(|event| match event {
AppEvent::CodexOp(op @ AppCommand::UserTurn { .. }) => Some(op),
_ => None,
})
.expect("queued user turn");
assert_matches!(&pending, AppCommand::UserTurn {model, ..} if model == "gpt-reserve");
while events.try_recv().is_ok() {}
let mut recovered = reserve_response();
recovered.ordinary_usage_allowed = Some(!has_credits);
recovered.rate_limits.credits = Some(codex_app_server_protocol::CreditsSnapshot {
has_credits,
unlimited: false,
balance: None,
});
recovered.rate_limit_upsell = None;
// A rounded percentage is not an authorization decision; backend allowed=true wins.
for (request_id, hard_stop_generation, expected) in [
(2, generation.wrapping_sub(1), "gpt-reserve"),
(3, generation, "gpt-5.4"),
(4, generation, "gpt-5.4"),
] {
app.handle_event(
&mut tui,
&mut server,
AppEvent::RateLimitsLoaded {
request_id,
origin: RateLimitRefreshOrigin::Periodic,
hard_stop_generation,
result: Ok(recovered.clone()),
},
)
.await?;
assert_eq!(app.chat_widget.current_model(), expected);
}
assert_eq!(
app.chat_widget.effective_collaboration_mode(),
original_mode
);
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Luna Reserve"));
app.chat_widget
.apply_reserve_fallback_to_pending_turn(&mut pending);
assert_matches!(pending, AppCommand::UserTurn { model, effort, collaboration_mode, .. }
if model == "gpt-5.4" && effort == original_mode.reasoning_effort()
&& collaboration_mode == Some(original_mode.clone()));
let sent = requests.lock().unwrap().clone();
assert_eq!(sent.len(), 2);
let restored: ThreadSettingsUpdateParams =
serde_json::from_value(sent[1].params.clone().unwrap())?;
assert_eq!(
restored,
ThreadSettingsUpdateParams {
thread_id: app.active_thread_id.unwrap().to_string(),
model: Some("gpt-5.4".into()),
effort: Some(ReasoningEffortConfig::High),
collaboration_mode: Some(original_mode),
service_tier: None,
..Default::default()
}
);
assert_eq!(std::fs::read(&config_path).ok(), saved_config);
let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
assert!(
!queued.iter().any(|event| matches!(
event,
AppEvent::CodexOp(_) | AppEvent::PersistModelSelection { .. }
)),
"recovery must not replay a turn or change global defaults"
);
let notice = queued
.iter()
.find_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 100)))
}
_ => None,
})
.expect("recovery notice");
insta::assert_snapshot!("luna_reserve_recovery_notice", notice);
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
#[tokio::test]
async fn luna_reserve_recovery_requires_permission_and_no_remaining_blocker() -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (mut server, requests, proxy) =
backend_banner_fallback_tests::start_fallback_thread(&mut app).await?;
configure_reserve_catalog(&mut app);
app.chat_widget.update_backend_banner(&reserve_response());
app.apply_backend_banner_fallback(&mut server).await;
requests.lock().unwrap().clear();
let mut recovered = reserve_response();
recovered.rate_limit_upsell = None;
recovered.rate_limits.primary.as_mut().unwrap().used_percent = 0;
recovered.rate_limits.primary.as_mut().unwrap().resets_at = Some(0);
for allowed in [None, Some(false)] {
recovered.ordinary_usage_allowed = allowed;
app.chat_widget.update_backend_banner(&recovered);
app.apply_backend_banner_fallback(&mut server).await;
assert_eq!(app.chat_widget.current_model(), "gpt-reserve");
}
recovered.ordinary_usage_allowed = Some(true);
for blocker in [
json!({"accountId": null, "ordinaryUsageAllowed": null}),
json!({"rateLimitUpsell": {"unsupported": true}}),
json!({"rateLimits": {"spendControlReached": true}}),
json!({"rateLimits": {"rateLimitReachedType": "workspace_owner_usage_limit_reached"}}),
] {
let mut raw = serde_json::to_value(&recovered)?;
for (key, value) in blocker.as_object().unwrap() {
raw[key] = value.clone();
}
app.chat_widget
.update_backend_banner(&serde_json::from_value(raw)?);
app.apply_backend_banner_fallback(&mut server).await;
assert_eq!(app.chat_widget.current_model(), "gpt-reserve");
}
// Reset redemption clears presentation before the authoritative follow-up read.
app.chat_widget.clear_backend_banner();
app.chat_widget.update_backend_banner(&recovered);
app.apply_backend_banner_fallback(&mut server).await;
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
assert_eq!(requests.lock().unwrap().len(), 1);
server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn luna_reserve_recovery_does_not_override_manual_choice_or_account_change() -> Result<()> {
for change_account in [false, true] {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (mut server, requests, proxy) =
backend_banner_fallback_tests::start_fallback_thread(&mut app).await?;
configure_reserve_catalog(&mut app);
app.chat_widget.update_backend_banner(&reserve_response());
app.apply_backend_banner_fallback(&mut server).await;
requests.lock().unwrap().clear();
let mut recovered = reserve_response();
recovered.ordinary_usage_allowed = Some(true);
recovered.rate_limit_upsell = None;
let expected = if change_account {
recovered.account_id = Some("workspace-b".into());
"gpt-reserve"
} else {
app.chat_widget.set_model("gpt-5.2");
"gpt-5.2"
};
app.chat_widget.update_backend_banner(&recovered);
app.apply_backend_banner_fallback(&mut server).await;
recovered.account_id = Some("workspace-a".into());
app.chat_widget.update_backend_banner(&recovered);
app.apply_backend_banner_fallback(&mut server).await;
assert_eq!(app.chat_widget.current_model(), expected);
assert!(requests.lock().unwrap().is_empty());
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
+9 -2
View File
@@ -20,6 +20,7 @@ fn rate_limit_snapshot(
RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent,
window_duration_mins: Some(300),
@@ -224,6 +225,12 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori
))
.await?;
app.chat_widget
.on_rate_limit_snapshot(Some(rate_limit_snapshot(
/*used_percent*/ 20,
/*rate_limit_reached_type*/ None,
Some(false),
)));
let origin = match origin_name {
"startup" => RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id: app.chat_widget.start_rate_limit_reset_startup_check(),
@@ -309,8 +316,8 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori
let status = render_status_output(&mut app, &mut app_event_rx);
assert!(
status.contains("5% left"),
"expected {origin_name} to preserve rolling limits, got: {status}"
status.contains("80% left"),
"expected {origin_name} to preserve the last account usage snapshot, got: {status}"
);
deliver_usage_limit_error(&mut app);
let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100);
@@ -1,11 +1,9 @@
---
source: tui/src/app/tests/backend_banner_fallback_tests.rs
assertion_line: 76
expression: rendered
expression: normalize_snapshot_paths(rendered)
---
Selected model usage exhausted
Add credits to use this model again.
› 1. View usage
Press a number to choose · esc to dismiss · type to continue
@@ -0,0 +1,10 @@
---
source: tui/src/app/tests/backend_banner_fallback_tests.rs
expression: normalize_snapshot_paths(rendered)
---
Usage limit reached
Your included usage is exhausted. Choose an option below to continue.
› 1. Add credits
Press enter to confirm or esc to go back
@@ -0,0 +1,5 @@
---
source: tui/src/app/tests/luna_reserve_recovery_tests.rs
expression: notice
---
• Automatically switched back to gpt-5.4 high because ordinary usage is available again.
+13
View File
@@ -175,6 +175,8 @@ pub(crate) enum RateLimitRefreshOrigin {
ResetConsume { request_id: u64 },
/// Refresh backend recovery after an inference limit error.
Recovery,
/// Background account usage read, scheduled more frequently near exhaustion.
Periodic,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -502,6 +504,11 @@ pub(crate) enum AppEvent {
origin: RateLimitRefreshOrigin,
},
/// Reconcile inherited account usage with an attached task before its queued input runs.
ApplyBackendBannerFallback {
thread_id: ThreadId,
},
/// Open the current thread goal summary/action menu.
OpenThreadGoalMenu {
thread_id: ThreadId,
@@ -962,6 +969,12 @@ pub(crate) enum AppEvent {
/// Update the current reasoning effort in the running app and widget.
UpdateReasoningEffort(Option<ReasoningEffort>),
/// Change Reserve effort only on the task that opened the picker, without saving defaults.
UpdateLunaReserveReasoning {
thread_id: ThreadId,
effort: Option<ReasoningEffort>,
},
/// Update the current model slug in the running app and widget.
UpdateModel(String),
+1
View File
@@ -2420,6 +2420,7 @@ mod tests {
RateLimitSnapshot {
limit_id: Some(limit_id.to_string()),
limit_name: None,
normal_model_slug: None,
primary: Some(codex_app_server_protocol::RateLimitWindow {
used_percent: 0,
window_duration_mins: Some(10_080),
+3
View File
@@ -8,6 +8,9 @@ use serde::Deserialize;
mod actions;
mod render;
pub(crate) const LUNA_RESERVE_BANNER: &str = "luna_reserve";
pub(crate) const LUNA_RESERVE_RECOVERY_VIEW_ID: &str = "luna-reserve-recovery";
#[cfg(test)]
#[path = "backend_banners_tests.rs"]
mod tests;
@@ -11,6 +11,8 @@ use super::list_selection_view::ListSelectionView;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_fg;
use crate::wrapping::word_wrap_lines;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -18,6 +20,7 @@ use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -122,7 +125,9 @@ impl BannerContent {
impl Renderable for BannerContent {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = self.wrapped_lines(area.width);
Widget::render(Paragraph::new(lines), area, buf);
// Explicit foreground avoids a terminal's separate bold color washing out the title.
let foreground = default_fg().map(best_color).unwrap_or(Color::Reset);
Widget::render(Paragraph::new(lines).fg(foreground), area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
@@ -140,7 +145,7 @@ impl From<ActionableBanner> for SelectionViewParams {
banner
.description
.lines()
.map(|line| Line::from(line.to_owned().dim())),
.map(|line| Line::from(line.to_owned())),
)
.collect();
Self {
@@ -164,6 +169,7 @@ impl BottomPane {
let dismissal = banner.dismissal;
let has_actions = !banner.actions.is_empty();
let mut params: SelectionViewParams = banner.into();
params.header_gap = 0;
params.allow_cancel = dismissal == BannerDismissal::Dismissible;
for item in &mut params.items {
item.dismiss_on_select = false;
@@ -6,6 +6,7 @@
//! - Routing keys to the active popup (slash commands, file search, skill/apps/task mentions).
//! - Promoting typed slash commands into atomic elements when the command name is completed.
//! - Handling submit vs newline on Enter.
//! - Showing a single yellow prompt arrow while the active model is Luna Reserve.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
//!
@@ -518,6 +519,7 @@ pub(crate) struct ChatComposer {
effort_ignition: Option<EffortIgnition>,
effort_status_line_transition: Option<EffortStatusLineTransition>,
effort_observed: bool,
luna_reserve_active: bool,
attachments: AttachmentState,
placeholder_text: String,
blocks_direct_input: bool,
@@ -697,6 +699,7 @@ impl ChatComposer {
effort_ignition: None,
effort_status_line_transition: None,
effort_observed: false,
luna_reserve_active: false,
attachments: AttachmentState::default(),
placeholder_text,
blocks_direct_input: false,
@@ -4626,6 +4629,14 @@ impl Renderable for ChatComposer {
}
impl ChatComposer {
pub(crate) fn set_luna_reserve_active(&mut self, active: bool) -> bool {
if self.luna_reserve_active == active {
return false;
}
self.luna_reserve_active = active;
true
}
pub(crate) fn desired_height_with_textarea_right_reserve(
&self,
width: u16,
@@ -4949,6 +4960,11 @@ impl ChatComposer {
let prompt = if self.draft.input_enabled {
if self.draft.is_bash_mode {
Span::from("!").light_red().bold()
} else if self.luna_reserve_active {
// Reserve keeps one arrow at every reasoning effort; only its foreground changes.
"›"
.fg(crate::terminal_palette::best_color((246, 197, 67)))
.bold()
} else if let Some(tier) = self.effort_tier {
let charge = self
.effort_ignition
@@ -183,6 +183,8 @@ pub(crate) struct SelectionViewParams {
/// Rendered left-column width to use for auto-sized rows.
pub name_column_width: Option<usize>,
pub header: Box<dyn Renderable>,
/// Blank rows after the header; defaults to one. Inline banners use zero.
pub header_gap: u16,
pub initial_selected_idx: Option<usize>,
/// Rich content rendered beside (wide terminals) or below (narrow terminals)
@@ -234,6 +236,7 @@ impl Default for SelectionViewParams {
description_layout: SelectionDescriptionLayout::Columns,
name_column_width: None,
header: Box::new(()),
header_gap: 1,
initial_selected_idx: None,
side_content: Box::new(()),
side_content_width: SideContentWidth::default(),
@@ -275,6 +278,7 @@ pub(crate) struct ListSelectionView {
last_selected_actual_idx: Option<usize>,
rendered_item_count: std::cell::Cell<usize>,
header: Box<dyn Renderable>,
header_gap: u16,
initial_selected_idx: Option<usize>,
side_content: Box<dyn Renderable>,
side_content_width: SideContentWidth,
@@ -411,6 +415,7 @@ impl ListSelectionView {
last_selected_actual_idx: None,
rendered_item_count: std::cell::Cell::new(0),
header,
header_gap: params.header_gap,
initial_selected_idx: params.initial_selected_idx,
side_content: params.side_content,
side_content_width: params.side_content_width,
@@ -1170,7 +1175,7 @@ impl Renderable for ListSelectionView {
let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width);
let mut height = header.desired_height(inner_width);
height = height.saturating_add(tab_height + u16::from(tab_height > 0));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(rows_height + 2 + self.header_gap);
if self.is_searchable {
height = height.saturating_add(1);
}
@@ -1265,7 +1270,7 @@ impl Renderable for ListSelectionView {
stacked_side_area,
] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Max(self.header_gap),
Constraint::Length(tab_height),
Constraint::Length(u16::from(tab_height > 0)),
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
+6
View File
@@ -1967,6 +1967,12 @@ impl BottomPane {
}
}
pub(crate) fn set_luna_reserve_active(&mut self, active: bool) {
if self.composer.set_luna_reserve_active(active) {
self.request_redraw();
}
}
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
if self.composer.set_status_line_hyperlink(url) {
self.request_redraw();
@@ -6,7 +6,6 @@ expression: "render_snapshot(&pane, area)"
Usage limit reached
Your included usage is depleted.
Choose an action to continue.
› 1. Open usage settings
2. View usage
3. Extra action
@@ -6,7 +6,6 @@ expression: "render_snapshot(&pane, area)"
Usage limit reached
Your included usage is depleted.
Choose an action to continue.
› 1. Open usage settings
2. Notify owner
3. Extra action
@@ -11,7 +11,6 @@ expression: "render_snapshot(&pane, area)"
A long description wraps to fit the
available terminal width.
…
› 1. Open usage settings
2. Notify owner
+7
View File
@@ -396,6 +396,9 @@ pub(crate) use self::permissions_menu::auto_review_available;
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
mod backend_banners;
mod compaction;
mod luna_reserve_model;
mod luna_reserve_return;
pub(crate) use backend_banners::AutomaticModelSwitchReason;
mod protocol;
mod protocol_requests;
mod rate_limits;
@@ -583,6 +586,7 @@ pub(crate) struct ChatWidget {
pub(crate) remote_connection: Option<RemoteConnectionStatus>,
token_info: Option<TokenUsageInfo>,
token_usage_pending: bool,
// Status and polling use account usage reads; response streams may identify meters differently.
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
@@ -602,7 +606,10 @@ pub(crate) struct ChatWidget {
codex_spend_control_reached: Option<bool>,
rate_limit_warnings: RateLimitWarningState,
backend_banner_state: backend_banners::BackendBannerState,
automatic_model_switch_state: backend_banners::AutomaticModelSwitchState,
backend_banner_notice_model: Option<String>,
// Remember the account's Reserve entry notice across chats and transient banner refreshes.
luna_reserve_notice_account_id: Option<String>,
warning_display_state: WarningDisplayState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
add_credits_nudge_email_in_flight: Option<rate_limits::PendingCreditsNudge>,
+355 -21
View File
@@ -1,70 +1,328 @@
//! Account banner state, independent of the current model and the lifetime of its rendered view.
//! Account banners and response-driven model transitions for the selected task.
//!
//! Only explicitly dismissible, previously shown occurrences can be dismissed. Model changes hide
//! content without dismissing it; an authoritative replacement/absence starts a new occurrence.
//! Reserve recovery uses a focused picker and a persisted task-local return model.
//! Its entry notice is shared across chats until the backend confirms ordinary usage has recovered.
use super::ChatWidget;
use super::luna_reserve_return::ReserveReturnModel;
use crate::app_command::AppCommand;
use crate::backend_banners::BackendBanner;
use crate::backend_banners::BannerPresentation;
use crate::backend_banners::LUNA_RESERVE_BANNER;
use crate::backend_banners::LUNA_RESERVE_RECOVERY_VIEW_ID;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::accept_cancel_hint_line;
use crate::keymap::ListAction;
use crate::model_catalog::LUNA_RESERVE_MODEL;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
#[derive(Debug, PartialEq)]
pub(crate) enum AutomaticModelSwitchReason {
UsageLimit,
UsageRecovered,
}
#[derive(Debug, PartialEq)]
pub(crate) struct AutomaticModelSwitch {
pub model: ModelPreset,
pub effort: Option<ReasoningEffort>,
pub reason: AutomaticModelSwitchReason,
}
/// Live transition state; the return target is also persisted per task for reconstruction.
#[derive(Default)]
pub(super) struct AutomaticModelSwitchState {
reserve_return: Option<ReserveReturnModel>,
replaced_model: Option<String>,
}
#[derive(Default)]
pub(super) struct BackendBannerState {
account_id: Option<String>,
ordinary_usage_recovered: bool,
banner: Option<BackendBanner>,
presented: Option<BackendBanner>,
shown: bool,
dismissed: bool,
picker_dismissed: Arc<AtomicBool>,
}
impl ChatWidget {
pub(crate) fn backend_banner_fallback(&self) -> Option<ModelPreset> {
let banner = self.backend_banner_state.banner.as_ref()?;
if !self.has_chatgpt_account
|| !self.requires_openai_auth
|| banner.blocked_model_slug.as_deref() != Some(self.current_model())
{
pub(super) fn restrict_model_picker_to_luna_reserve(&self) -> bool {
// A fresh account read can allow manual recovery even without a valid saved return model.
self.current_model() == LUNA_RESERVE_MODEL
&& !self.backend_banner_state.ordinary_usage_recovered
}
pub(crate) fn invalidate_ordinary_usage_recovery(&mut self) {
// Keep the current banner, but never reuse permission from before a new hard stop.
self.backend_banner_state.ordinary_usage_recovered = false;
}
pub(super) fn waiting_for_luna_reserve(&self) -> bool {
self.current_model() != LUNA_RESERVE_MODEL
&& self
.backend_banner_state
.banner
.as_ref()
.is_some_and(|banner| banner.banner_type == LUNA_RESERVE_BANNER)
}
pub(crate) fn backend_banner_fallback(&mut self) -> Option<AutomaticModelSwitch> {
if !self.has_chatgpt_account || !self.requires_openai_auth {
return None;
}
if self.current_model() == LUNA_RESERVE_MODEL
&& self.automatic_model_switch_state.reserve_return.is_none()
{
self.automatic_model_switch_state.reserve_return =
self.thread_id().and_then(|thread_id| {
ReserveReturnModel::load(self.config.codex_home.as_path(), thread_id).or_else(
|| {
// Forks inherit Reserve settings, but need their own return target:
// the parent's recovery will delete the parent's saved target.
let previous = ReserveReturnModel::load(
self.config.codex_home.as_path(),
self.forked_from?,
)?;
if self.backend_banner_state.account_id.as_deref()
!= Some(previous.account_id.as_str())
{
return None;
}
previous
.save(self.config.codex_home.as_path(), thread_id)
.ok()?;
Some(previous)
},
)
});
}
if self
.automatic_model_switch_state
.reserve_return
.as_ref()
.is_some_and(|previous| {
self.backend_banner_state
.account_id
.as_ref()
.is_some_and(|account_id| account_id != &previous.account_id)
})
{
self.clear_reserve_return();
}
let models = self.model_catalog.try_list_models().ok()?;
banner.fallback_model_slugs.iter().find_map(|candidate| {
models
.iter()
.find(|model| {
model.show_in_picker
&& model.model == *candidate
&& model.model != self.current_model()
})
.cloned()
if self.current_model() == LUNA_RESERVE_MODEL
&& self.backend_banner_state.ordinary_usage_recovered
{
let previous = self.automatic_model_switch_state.reserve_return.as_ref()?;
if self.backend_banner_state.account_id.as_deref() != Some(previous.account_id.as_str())
{
return None;
}
let model = models
.into_iter()
.find(|model| model.show_in_picker && model.model == previous.model)?;
return Some(AutomaticModelSwitch {
model,
effort: previous.effort.clone(),
reason: AutomaticModelSwitchReason::UsageRecovered,
});
}
let banner = self.backend_banner_state.banner.as_ref()?;
// The backend emits this banner only after ordinary usage is exhausted and Reserve
// is available. Reserve is deliberately hidden from manual model selection.
let model = if banner.banner_type == LUNA_RESERVE_BANNER {
models.into_iter().find(|model| {
model.model == LUNA_RESERVE_MODEL
&& model.model != self.current_model()
&& banner
.blocked_model_slug
.as_deref()
.is_none_or(|blocked| blocked == self.current_model())
})
} else {
if banner.blocked_model_slug.as_deref() != Some(self.current_model()) {
return None;
}
banner.fallback_model_slugs.iter().find_map(|candidate| {
models
.iter()
.find(|model| {
model.show_in_picker
&& model.model == *candidate
&& model.model != self.current_model()
})
.cloned()
})
}?;
Some(AutomaticModelSwitch {
model,
effort: self.current_reasoning_effort(),
reason: AutomaticModelSwitchReason::UsageLimit,
})
}
/// Save the return target before changing server state, including before any turn is sent.
pub(crate) fn prepare_luna_reserve_return(&mut self) -> bool {
let Some((account_id, thread_id)) = self
.backend_banner_state
.account_id
.clone()
.zip(self.thread_id())
else {
return false;
};
let previous = ReserveReturnModel {
account_id,
model: self.current_model().to_string(),
effort: self.current_reasoning_effort(),
};
previous
.save(self.config.codex_home.as_path(), thread_id)
.is_ok()
}
pub(super) fn clear_reserve_return(&mut self) {
if let Some(thread_id) = self.thread_id() {
ReserveReturnModel::clear(self.config.codex_home.as_path(), thread_id);
}
self.automatic_model_switch_state = AutomaticModelSwitchState::default();
}
pub(crate) fn show_unavailable_reserve_recovery(&mut self) {
self.clear_reserve_return();
// A failed switch still needs recovery actions, even if another chat used Reserve.
self.backend_banner_state.dismissed = false;
self.backend_banner_state.picker_dismissed = Arc::default();
self.backend_banner_state.presented = None;
self.refresh_backend_banner_visibility();
}
/// A composer submission can already be in the app queue when a Reserve switch fails.
/// Reuse the retained prompt/steer rather than sending it on the blocked model.
pub(crate) fn defer_pending_turn_for_luna_reserve(&mut self) -> bool {
if !self.waiting_for_luna_reserve() {
return false;
}
if self.input_queue.user_turn_pending_start
&& let Some(prompt) = self.safety_buffering_prompt.take()
{
self.finalize_turn();
self.input_queue
.queued_user_messages
.push_front(prompt.into());
self.input_queue
.queued_user_message_history_records
.push_front(super::UserMessageHistoryRecord::UserMessageText);
self.refresh_pending_input_preview();
return true;
}
!self.input_queue.pending_steers.is_empty() && self.enqueue_rejected_steer()
}
/// Commit a successfully applied task setting and keep its recovery notice visible together.
pub(crate) fn finish_backend_banner_fallback(&mut self, mode: CollaborationMode) {
let previous_model = self.current_model().to_string();
let reserve_return = (mode.model() == LUNA_RESERVE_MODEL)
.then(|| self.backend_banner_state.account_id.clone())
.flatten()
.map(|account_id| ReserveReturnModel {
account_id,
model: previous_model.clone(),
effort: self.current_reasoning_effort(),
});
let involves_reserve =
mode.model() == LUNA_RESERVE_MODEL || previous_model == LUNA_RESERVE_MODEL;
self.set_model(mode.model());
self.set_reasoning_effort(mode.reasoning_effort());
if mode.mode == ModeKind::Plan {
self.set_plan_mode_reasoning_effort(mode.reasoning_effort());
}
self.set_effective_collaboration_mode(mode);
self.automatic_model_switch_state = AutomaticModelSwitchState {
reserve_return,
replaced_model: involves_reserve.then_some(previous_model),
};
// Showing the post-switch notice is distinct from having seen the original blocked state.
self.backend_banner_state.shown = false;
self.backend_banner_state.dismissed = false;
self.backend_banner_state.dismissed = self.reserve_notice_already_shown();
self.backend_banner_state.picker_dismissed = Arc::default();
self.bottom_pane
.dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID);
self.backend_banner_state.presented = None;
self.backend_banner_notice_model = Some(self.current_model().to_string());
self.refresh_backend_banner_visibility();
}
/// A queued command may have been composed before the account read switched this task.
pub(crate) fn apply_reserve_fallback_to_pending_turn(&self, op: &mut AppCommand) {
let AppCommand::UserTurn {
model,
effort,
service_tier,
collaboration_mode,
..
} = op
else {
return;
};
if self.automatic_model_switch_state.replaced_model.as_deref() != Some(model.as_str()) {
return;
}
*model = self.current_model().to_string();
*effort = self.current_reasoning_effort();
*service_tier = self.service_tier_update_for_core();
if let Some(mode) = collaboration_mode {
mode.settings.model = model.clone();
mode.settings.reasoning_effort = effort.clone();
}
}
pub(crate) fn inherit_backend_banner_state(&mut self, previous: &mut ChatWidget) {
previous.observe_backend_banner_view();
self.backend_banner_state = std::mem::take(&mut previous.backend_banner_state);
self.luna_reserve_notice_account_id = previous.luna_reserve_notice_account_id.take();
self.backend_banner_state.dismissed |= self.reserve_notice_already_shown();
// Usage belongs to the account, so switching tasks must not reset the polling cadence
// or discard the already-known limits shown by /status.
self.rate_limit_snapshots_by_limit_id =
std::mem::take(&mut previous.rate_limit_snapshots_by_limit_id);
self.codex_rate_limit_reached_type = previous.codex_rate_limit_reached_type;
self.codex_spend_control_reached = previous.codex_spend_control_reached;
self.backend_banner_state.presented = None;
self.refresh_backend_banner_visibility();
}
pub(crate) fn update_backend_banner(&mut self, response: &GetAccountRateLimitsResponse) {
self.observe_backend_banner_view();
self.backend_banner_state.account_id = response.account_id.clone();
// Only a full, identity-validated backend read can authorize recovery. Unknown banners
// still block it; percentages, sparse notifications and reset timestamps cannot prove it.
let has_usable_credits = response
.rate_limits
.credits
.as_ref()
.is_some_and(|credits| credits.unlimited || credits.has_credits);
self.backend_banner_state.ordinary_usage_recovered =
response.ordinary_usage_allowed.is_some()
&& (response.ordinary_usage_allowed == Some(true) || has_usable_credits)
&& response.rate_limit_upsell.is_none()
&& response.rate_limits.spend_control_reached != Some(true)
&& response.rate_limits.rate_limit_reached_type.is_none();
if self.backend_banner_state.ordinary_usage_recovered {
self.luna_reserve_notice_account_id = None;
}
let banner = response
.rate_limit_upsell
.as_ref()
@@ -88,23 +346,47 @@ impl ChatWidget {
== new.blocked_model_slug.as_ref().or(new.model_slug.as_ref())
&& old.fallback_model_slugs == new.fallback_model_slugs
});
self.backend_banner_state.banner = banner;
if !same_occurrence {
self.backend_banner_state.shown = false;
self.backend_banner_state.dismissed = false;
self.backend_banner_state.dismissed = self.reserve_notice_already_shown();
self.backend_banner_state.picker_dismissed = Arc::default();
self.backend_banner_notice_model = None;
}
self.backend_banner_state.banner = banner;
if self.waiting_for_luna_reserve() {
self.hold_rate_limit_recovery();
}
self.refresh_backend_banner_visibility();
}
fn reserve_notice_already_shown(&self) -> bool {
self.backend_banner_state
.banner
.as_ref()
.is_some_and(|banner| {
banner.banner_type == LUNA_RESERVE_BANNER
&& self.luna_reserve_notice_account_id.as_deref()
== Some(banner.account_id.as_str())
})
}
fn observe_backend_banner_view(&mut self) {
let (shown, dismissed) = self.bottom_pane.inline_banner_lifecycle();
self.backend_banner_state.shown |= shown;
self.backend_banner_state.dismissed |= dismissed;
self.backend_banner_state.dismissed |= self
.backend_banner_state
.picker_dismissed
.load(Ordering::Relaxed);
}
pub(super) fn refresh_backend_banner_visibility(&mut self) {
let banner = self.backend_banner_state.banner.as_ref().filter(|banner| {
// Keep recovery actions available while switching, including on older servers
// without settings/update. The copy below describes the accepted model only.
if banner.banner_type == LUNA_RESERVE_BANNER {
return !self.backend_banner_state.dismissed;
}
// Explicit fallback payloads describe the selected replacement, not a pending switch.
let matches_selected_model = match banner.blocked_model_slug.as_deref() {
Some(blocked) if !banner.fallback_model_slugs.is_empty() => {
@@ -127,7 +409,19 @@ impl ChatWidget {
if banner == self.backend_banner_state.presented.as_ref() {
return;
}
let content = banner.map(BackendBanner::actionable_banner);
let is_reserve = banner.is_some_and(|banner| banner.banner_type == LUNA_RESERVE_BANNER);
let content = banner.map(|banner| {
let mut content = banner.actionable_banner();
if banner.banner_type == LUNA_RESERVE_BANNER
&& self.current_model() != LUNA_RESERVE_MODEL
{
content.title = "Usage limit reached".to_string();
content.description =
"Your included usage is exhausted. Choose an option below to continue."
.to_string();
}
content
});
self.backend_banner_state.presented = banner.cloned();
if content.is_some() {
self.bottom_pane
@@ -140,7 +434,45 @@ impl ChatWidget {
self.rate_limit_switch_prompt = super::RateLimitSwitchPromptState::Pending;
}
}
self.bottom_pane.set_inline_banner(content);
self.bottom_pane
.dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID);
match (is_reserve, content) {
(true, Some(content)) => {
self.bottom_pane.set_inline_banner(/*banner*/ None);
let mut params: SelectionViewParams = content.into();
params.view_id = Some(LUNA_RESERVE_RECOVERY_VIEW_ID);
if self.current_model() == LUNA_RESERVE_MODEL {
self.luna_reserve_notice_account_id =
self.backend_banner_state.account_id.clone();
// Continuing is a local dismissal, separate from the backend's purchase CTAs.
params.items.push(SelectionItem {
name: "Continue with Luna Reserve".to_string(),
..Default::default()
});
let list_keymap = self.bottom_pane.list_keymap();
params.footer_hint = Some(accept_cancel_hint_line(
list_keymap.primary_hint(ListAction::Accept),
"to confirm",
list_keymap.primary_hint(ListAction::Cancel),
"to continue working",
));
}
// Use the standard focused picker: arrows/Enter and numeric shortcuts select,
// Escape returns to the unchanged composer and its saved draft.
let dismissed = Arc::clone(&self.backend_banner_state.picker_dismissed);
params.on_cancel =
Some(Box::new(move |_| dismissed.store(true, Ordering::Relaxed)));
for item in &mut params.items {
item.dismiss_on_select = true;
let dismissed = Arc::clone(&self.backend_banner_state.picker_dismissed);
item.actions
.push(Box::new(move |_| dismissed.store(true, Ordering::Relaxed)));
}
self.backend_banner_state.shown = true;
self.bottom_pane.show_selection_view(params);
}
(_, content) => self.bottom_pane.set_inline_banner(content),
}
if self.backend_banner_state.presented.is_none()
&& !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
@@ -175,6 +507,8 @@ impl ChatWidget {
pub(crate) fn clear_backend_banner(&mut self) {
self.backend_banner_state = BackendBannerState::default();
self.backend_banner_notice_model = None;
self.bottom_pane
.dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID);
self.bottom_pane.set_inline_banner(/*banner*/ None);
}
}
@@ -153,7 +153,9 @@ impl ChatWidget {
codex_spend_control_reached: None,
rate_limit_warnings: RateLimitWarningState::default(),
backend_banner_state: backend_banners::BackendBannerState::default(),
automatic_model_switch_state: backend_banners::AutomaticModelSwitchState::default(),
backend_banner_notice_model: None,
luna_reserve_notice_account_id: None,
warning_display_state: WarningDisplayState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
add_credits_nudge_email_in_flight: None,
@@ -0,0 +1,62 @@
//! The Reserve-only /model picker uses normal-model metadata while keeping Reserve routing.
use super::*;
use crate::model_catalog::LUNA_MODEL;
use crate::model_catalog::LUNA_RESERVE_MODEL;
impl ChatWidget {
pub(super) fn open_luna_reserve_model_popup(
&mut self,
presets: Vec<ModelPreset>,
view_id: &'static str,
) {
let normal_model_slug = self
.rate_limit_snapshots_by_limit_id
.values()
.find(|snapshot| snapshot.limit_name == LUNA_RESERVE_MODEL)
.and_then(|snapshot| snapshot.normal_model_slug.as_deref());
let preset = normal_model_slug
.and_then(|slug| presets.iter().find(|preset| preset.model == slug))
.or_else(|| presets.iter().find(|preset| preset.model == LUNA_MODEL));
let Some(mut preset) = preset.cloned() else {
self.bottom_pane.dismiss_view_by_id(view_id);
self.add_info_message(
"Luna model settings are unavailable; please try /model again in a moment."
.to_string(),
/*hint*/ None,
);
return;
};
// Only borrow the normal model's presentation and supported efforts. Selecting an
// effort must keep the authorized Reserve model and the saved ordinary return target.
preset.model = LUNA_RESERVE_MODEL.to_string();
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let name = preset.display_name.clone();
let description = (!preset.description.is_empty()).then_some(preset.description.clone());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReasoningPopup {
model: preset.clone(),
});
})];
let header = self.model_menu_header(
"Select Model",
"Other models return when ordinary usage is available again.",
);
self.show_model_selection_view(SelectionViewParams {
view_id: Some(view_id),
header,
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items: vec![SelectionItem {
name,
description,
is_current: true,
actions,
dismiss_on_select: single_supported_effort,
dismiss_parent_on_child_accept: !single_supported_effort,
..Default::default()
}],
..Default::default()
});
}
}
@@ -0,0 +1,48 @@
//! Persist the account-bound return model for a task before automatically entering Reserve.
//! Task IDs scope the cache across reconnects/resumes without changing global model defaults.
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
#[derive(Clone, Serialize, Deserialize)]
pub(super) struct ReserveReturnModel {
pub account_id: String,
pub model: String,
pub effort: Option<ReasoningEffort>,
}
impl ReserveReturnModel {
fn path(codex_home: &Path, thread_id: ThreadId) -> PathBuf {
codex_home
.join("tui-luna-reserve")
.join(format!("{thread_id}.json"))
}
pub(super) fn load(codex_home: &Path, thread_id: ThreadId) -> Option<Self> {
let file = std::fs::File::open(Self::path(codex_home, thread_id)).ok()?;
// A corrupt or unrelated cache file must not cause an unbounded read.
serde_json::from_reader(file.take(/*limit*/ 4096)).ok()
}
pub(super) fn save(&self, codex_home: &Path, thread_id: ThreadId) -> io::Result<()> {
let path = Self::path(codex_home, thread_id);
let directory = path.parent().ok_or(io::ErrorKind::InvalidInput)?;
std::fs::create_dir_all(directory)?;
let mut file = tempfile::NamedTempFile::new_in(directory)?;
serde_json::to_writer(&mut file, self)?;
file.as_file().sync_all()?;
file.persist(path).map_err(|error| error.error)?;
Ok(())
}
pub(super) fn clear(codex_home: &Path, thread_id: ThreadId) {
// A missing cache needs no cleanup; the live model is authoritative after leaving Reserve.
let _ = std::fs::remove_file(Self::path(codex_home, thread_id));
}
}
@@ -36,16 +36,22 @@ impl ChatWidget {
{
return false;
}
self.model_catalog = Arc::new(ModelCatalog::new(presets.clone()));
self.model_catalog = Arc::new(ModelCatalog::new(presets));
self.refresh_effective_service_tier();
self.refresh_model_dependent_surfaces();
true
}
/// Keep a present model picker aligned with the active task, including Reserve entry/exit.
pub(super) fn refresh_open_model_picker(&mut self) {
// Refresh an existing parent without reopening it or interrupting its reasoning child.
match self.model_popup_view_id() {
Some(MODEL_SELECTION_VIEW_ID) => self.open_model_popup_with_presets(presets),
Some(MODEL_SELECTION_VIEW_ID) => self.open_model_popup_with_presets(
self.model_catalog.try_list_models().unwrap_or_default(),
),
Some(ALL_MODELS_SELECTION_VIEW_ID) => self.open_all_models_popup(),
_ => {}
}
true
}
pub(super) fn show_model_selection_view(&mut self, mut params: SelectionViewParams) {
+35 -24
View File
@@ -4,6 +4,7 @@
//! into another, especially while Plan mode is active.
use super::*;
use crate::model_catalog::LUNA_RESERVE_MODEL;
const ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD: usize = 8;
pub(super) const MODEL_SELECTION_VIEW_ID: &str = "model-selection";
@@ -38,7 +39,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::FetchModels { request_id });
}
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
pub(super) fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
let title = title.to_string();
let subtitle = subtitle.to_string();
let mut header = ColumnRenderable::new();
@@ -78,6 +79,10 @@ impl ChatWidget {
}
pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec<ModelPreset>) {
if self.restrict_model_picker_to_luna_reserve() {
self.open_luna_reserve_model_popup(presets, MODEL_SELECTION_VIEW_ID);
return;
}
let presets: Vec<ModelPreset> = presets
.into_iter()
.filter(|preset| preset.show_in_picker)
@@ -191,6 +196,13 @@ impl ChatWidget {
}
pub(crate) fn open_all_models_popup(&mut self) {
if self.restrict_model_picker_to_luna_reserve() {
self.open_luna_reserve_model_popup(
self.model_catalog.try_list_models().unwrap_or_default(),
ALL_MODELS_SELECTION_VIEW_ID,
);
return;
}
let presets = self
.model_catalog
.try_list_models()
@@ -262,8 +274,17 @@ impl ChatWidget {
let warning = effort_for_action
.as_ref()
.and_then(|effort| self.ultra_reasoning_concurrency_warning(effort));
let thread_id = self.thread_id();
vec![Box::new(move |tx| {
if effort_for_action == Some(ReasoningEffortConfig::Ultra) {
if model_for_action == LUNA_RESERVE_MODEL {
// Reserve is temporary: update the active task without persisting a model default.
if let Some(thread_id) = thread_id {
tx.send(AppEvent::UpdateLunaReserveReasoning {
thread_id,
effort: effort_for_action.clone(),
});
}
} else if effort_for_action == Some(ReasoningEffortConfig::Ultra) {
tx.send(AppEvent::ApplyAdvancedReasoning {
model: model_for_action.clone(),
effort: ReasoningEffortConfig::Ultra,
@@ -295,6 +316,7 @@ impl ChatWidget {
selected_effort: Option<ReasoningEffortConfig>,
) -> bool {
if !self.collaboration_modes_enabled()
|| selected_model == LUNA_RESERVE_MODEL
|| self.active_mode_kind() != ModeKind::Plan
|| selected_model != self.current_model()
{
@@ -478,6 +500,11 @@ impl ChatWidget {
.then(|| default_effort.clone());
let model_slug = preset.model.to_string();
let model_label = if model_slug == LUNA_RESERVE_MODEL {
preset.display_name.clone()
} else {
model_slug.clone()
};
let is_current_model = self.current_model() == preset.model.as_str();
let highlight_choice = if is_current_model {
if in_plan_mode {
@@ -575,7 +602,7 @@ impl ChatWidget {
let mut header = ColumnRenderable::new();
header.push(Line::from(
format!("Select Reasoning Level for {model_slug}").bold(),
format!("Select Reasoning Level for {model_label}").bold(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -704,27 +731,11 @@ impl ChatWidget {
))
}
pub(super) fn apply_model_and_effort_without_persist(
&self,
model: String,
effort: Option<ReasoningEffortConfig>,
) {
let warning = effort
.as_ref()
.and_then(|effort| self.ultra_reasoning_concurrency_warning(effort));
self.app_event_tx.send(AppEvent::UpdateModel(model));
self.app_event_tx
.send(AppEvent::UpdateReasoningEffort(effort));
if let Some(warning) = warning {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(warning),
)));
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
for action in self
.model_selection_actions(model, effort, /*should_prompt_plan_mode_scope*/ false)
{
action(&self.app_event_tx);
}
}
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
self.apply_model_and_effort_without_persist(model.clone(), effort.clone());
self.app_event_tx
.send(AppEvent::PersistModelSelection { model, effort });
}
}
+49 -23
View File
@@ -1,8 +1,9 @@
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
//! Rate-limit warning, prompt, and notice surfaces, plus quota-aware account refresh cadence.
use super::*;
pub(super) const WORKSPACE_NUDGE_VIEW_ID: &str = "workspace-usage-nudge";
use crate::bottom_pane::ActionableBanner;
use crate::model_catalog::LUNA_RESERVE_MODEL;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use uuid::Uuid;
@@ -11,7 +12,7 @@ pub(super) struct PendingCreditsNudge {
credit_type: AddCreditsNudgeCreditType,
}
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.6-luna";
pub(super) const NUDGE_MODEL_SLUG: &str = crate::model_catalog::LUNA_MODEL;
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt";
@@ -185,11 +186,43 @@ fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool {
}
impl ChatWidget {
/// Poll more often near exhaustion for every ChatGPT account, independently of experiments.
pub(crate) fn rate_limit_refresh_interval(&self) -> Option<std::time::Duration> {
if !self.should_prefetch_rate_limits() {
return None;
}
// Ignore unrelated model buckets; watch ordinary usage and the selected model's bucket.
let used_percent = self
.rate_limit_snapshots_by_limit_id
.iter()
.filter(|(limit_id, snapshot)| {
limit_id.as_str() == "codex" || snapshot.limit_name == self.current_model()
})
.flat_map(|(_, snapshot)| snapshot.primary.iter().chain(snapshot.secondary.iter()))
.map(|window| window.used_percent)
.filter(|percent| percent.is_finite())
.max_by(f64::total_cmp)
.unwrap_or_default();
let seconds = if used_percent >= 99.0 {
5
} else if used_percent >= 90.0 {
15
} else if used_percent >= 75.0 {
30
} else {
60
};
Some(std::time::Duration::from_secs(seconds))
}
pub(crate) fn hold_rate_limit_recovery(&mut self) -> bool {
std::mem::replace(&mut self.input_queue.rate_limit_recovery_pending, true)
}
pub(crate) fn finish_rate_limit_recovery(&mut self) {
if self.waiting_for_luna_reserve() {
return;
}
if std::mem::take(&mut self.input_queue.rate_limit_recovery_pending) {
self.submit_initial_user_message_if_pending();
self.maybe_send_next_queued_input();
@@ -224,10 +257,6 @@ impl ChatWidget {
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
let limit_label = snapshot
.limit_name
.clone()
.unwrap_or_else(|| limit_id.clone());
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
&& snapshot.credits.is_none()
{
@@ -241,16 +270,6 @@ impl ChatWidget {
balance: credits.balance.clone(),
});
}
let preserved_individual_limit =
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
&& snapshot.individual_limit.is_none()
{
self.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.individual_limit.clone())
} else {
None
};
self.plan_type = snapshot.plan_type.or(self.plan_type);
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
@@ -341,13 +360,18 @@ impl ChatWidget {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
}
let mut display =
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
if display.individual_limit.is_none() {
display.individual_limit = preserved_individual_limit;
// /wham/usage identifies ordinary and additional model limits separately. Streamed
// updates still drive warnings/recovery above, but must not overwrite status data.
if matches!(source, RateLimitSnapshotSource::AccountUsage) {
let limit_label = snapshot
.limit_name
.clone()
.unwrap_or_else(|| limit_id.clone());
let display =
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
self.rate_limit_snapshots_by_limit_id
.insert(limit_id, display);
}
self.rate_limit_snapshots_by_limit_id
.insert(limit_id, display);
if !warnings.is_empty() {
for warning in warnings {
@@ -394,7 +418,9 @@ impl ChatWidget {
if self.has_applicable_backend_banner() {
return;
}
if self.rate_limit_switch_prompt_hidden() || self.current_model() == NUDGE_MODEL_SLUG {
if self.rate_limit_switch_prompt_hidden()
|| matches!(self.current_model(), NUDGE_MODEL_SLUG | LUNA_RESERVE_MODEL)
{
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
return;
}
@@ -26,6 +26,8 @@ impl ChatWidget {
.set_queue_submissions(/*queue_submissions*/ false);
if previous_thread_id != self.thread_id {
self.backend_banner_notice_model = None;
self.automatic_model_switch_state =
backend_banners::AutomaticModelSwitchState::default();
self.pending_automatic_thread_names.clear();
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
self.clear_thread_usage_state();
@@ -114,6 +116,19 @@ impl ChatWidget {
.set_active_reasoning_effort_baseline(effort.as_ref());
self.refresh_model_display();
self.refresh_status_surfaces();
if previous_thread_id != self.thread_id
&& self.should_prefetch_rate_limits()
&& (self.current_model() == crate::model_catalog::LUNA_RESERVE_MODEL
|| self.backend_banner_fallback().is_some())
{
// Reconcile this task with retained account state before sending its initial/queued
// prompt. Do not wait for the next usage poll after /new, /resume or a thread switch.
self.hold_rate_limit_recovery();
self.app_event_tx
.send(AppEvent::ApplyBackendBannerFallback {
thread_id: session.thread_id,
});
}
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
+12 -1
View File
@@ -221,6 +221,8 @@ impl ChatWidget {
self.clear_pending_token_activity_refreshes();
self.clear_pending_rate_limit_reset_requests();
self.clear_backend_banner();
self.luna_reserve_notice_account_id = None;
self.automatic_model_switch_state = backend_banners::AutomaticModelSwitchState::default();
self.input_queue.rate_limit_recovery_pending = false;
self.add_credits_nudge_email_in_flight = None;
self.codex_rate_limit_reached_type = None;
@@ -261,6 +263,14 @@ impl ChatWidget {
/// Set the model in the widget's config copy and stored collaboration mode.
pub(crate) fn set_model(&mut self, model: &str) {
if model != self.current_model() {
if self.current_model() == crate::model_catalog::LUNA_RESERVE_MODEL {
self.clear_reserve_return();
} else {
self.automatic_model_switch_state =
backend_banners::AutomaticModelSwitchState::default();
}
}
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model.to_string()),
/*effort*/ None,
@@ -452,6 +462,7 @@ impl ChatWidget {
self.sync_backend_banner_view();
self.refresh_model_display();
self.refresh_status_line();
self.refresh_open_model_picker();
}
fn apply_thread_settings(&mut self, mut settings: ThreadSettings) {
@@ -551,7 +562,7 @@ impl ChatWidget {
if model.is_empty() {
DEFAULT_MODEL_DISPLAY_NAME
} else {
model
crate::model_catalog::model_display_name(model)
}
}
@@ -9,6 +9,7 @@ use crate::branch_summary;
use crate::chatwidget::limit_label_for_window;
use crate::chatwidget::rate_limits::get_limits_duration;
use crate::legacy_core::config::Config;
use crate::model_catalog::LUNA_RESERVE_MODEL;
use crate::status::format_credit_micros;
use crate::status::format_estimated_usd_micros;
use crate::status::format_tokens_compact;
@@ -293,6 +294,8 @@ impl ChatWidget {
/// warnings once, synchronizes shared cached state (such as git-branch
/// lookup), then renders each surface from that shared snapshot.
pub(crate) fn refresh_status_surfaces(&mut self) {
self.bottom_pane
.set_luna_reserve_active(self.current_model() == LUNA_RESERVE_MODEL);
let selections = self.status_surface_selections();
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
+2
View File
@@ -246,6 +246,8 @@ mod goal_validation;
mod guardian;
pub(crate) mod helpers;
mod history_replay;
#[path = "tests/luna_reserve_usage_tests.rs"]
mod luna_reserve_usage_tests;
mod mcp_startup;
#[path = "tests/misalignment_policy_tests.rs"]
mod misalignment_policy;
@@ -319,7 +319,10 @@ async fn backend_banner_fallback_candidates_and_notice_follow_selected_model() {
response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] =
json!(["hidden-model", "test-model-c", "test-model-b"]);
chat.update_backend_banner(&response);
assert_eq!(chat.backend_banner_fallback(), Some(models[2].clone()));
assert_eq!(
chat.backend_banner_fallback().map(|switch| switch.model),
Some(models[2].clone())
);
// A retained ChatGPT login must not change a task whose server does not require auth.
chat.requires_openai_auth = false;
assert_eq!(chat.backend_banner_fallback(), None);
@@ -107,6 +107,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: percent.round() as i32,
window_duration_mins: Some(60),
@@ -0,0 +1,254 @@
//! Exercise Reserve picker focus, its status label, and the composer palette across entry and exit.
use super::helpers::normalize_snapshot_paths;
use super::*;
use crate::terminal_palette::with_test_default_colors;
use crate::terminal_probe::DefaultColors;
use pretty_assertions::assert_eq;
use serde_json::json;
fn reserve_snapshot(primary_used: i32, weekly_used: i32) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: Some("base_model_inference".into()),
limit_name: Some("gpt-reserve".into()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: primary_used,
window_duration_mins: Some(300),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: weekly_used,
window_duration_mins: Some(10080),
resets_at: None,
}),
..snapshot(/*percent*/ 0.0)
}
}
#[tokio::test]
async fn luna_reserve_selector_supports_arrows_enter_shortcuts_and_escape_without_losing_draft() {
for (keys, destination) in [
(
vec![KeyCode::Down, KeyCode::Enter],
Some("https://chatgpt.com/?cta_tab=personal&highlight_plan=plus#pricing"),
),
(
vec![KeyCode::Down, KeyCode::Up, KeyCode::Enter],
Some("https://chatgpt.com/codex/settings/usage?credits_modal=true"),
),
(
vec![KeyCode::Char('1')],
Some("https://chatgpt.com/codex/settings/usage?credits_modal=true"),
),
(
vec![KeyCode::Char('2')],
Some("https://chatgpt.com/?cta_tab=personal&highlight_plan=plus#pricing"),
),
(vec![KeyCode::Down, KeyCode::Esc], None),
] {
let (mut chat, mut events, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await;
chat.has_chatgpt_account = true;
chat.apply_external_edit("saved draft".into());
chat.on_rate_limit_snapshot(Some(reserve_snapshot(
/*primary_used*/ 48, /*weekly_used*/ 20,
)));
let response = serde_json::from_value(json!({
"accountId": "account-preview", "rateLimits": {},
"rateLimitUpsell": {
"banner_type": "luna_reserve", "presentation": "dismissible",
"title": "You’re now using Luna, a faster model for simpler tasks.",
"description": "Add credits or upgrade to continue using the most advanced models.",
"ctas": [{"action": "add_credits", "label": "Add credits"},
{"action": "open_pricing_dialog", "label": "Upgrade"}]
}
}))
.unwrap();
chat.update_backend_banner(&response);
let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90));
assert!(
!rendered.contains("saved draft"),
"selector owns input focus"
);
assert!(rendered.contains("esc to continue working"));
while events.try_recv().is_ok() {}
for key in keys {
chat.handle_key_event(KeyEvent::from(key));
if key == KeyCode::Down {
insta::assert_snapshot!(
"luna_reserve_selector_second_choice",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90))
);
}
}
let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
let urls: Vec<_> = queued
.iter()
.filter_map(|event| match event {
AppEvent::OpenUrlInBrowser { url } => Some(url.as_str()),
_ => None,
})
.collect();
assert_eq!(urls, destination.into_iter().collect::<Vec<_>>());
assert!(
!queued
.iter()
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
);
assert_eq!(chat.composer_text_with_pending(), "saved draft");
// A periodic usage reply must not reopen a selector the user just closed.
chat.update_backend_banner(&response);
let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90));
assert!(!rendered.contains("You’re now using Luna"));
assert!(rendered.contains("saved draft"));
assert!(rendered.contains("Luna Reserve"));
}
}
#[tokio::test]
async fn luna_reserve_status_tracks_the_active_model() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.6-sol")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(reserve_snapshot(
/*primary_used*/ 25, /*weekly_used*/ 60,
)));
assert!(
!normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
.contains("Luna Reserve")
);
chat.set_model("gpt-reserve");
let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80));
assert!(rendered.contains("Luna Reserve default"));
insta::assert_snapshot!("luna_reserve_usage_wide", rendered);
insta::assert_snapshot!(
"luna_reserve_usage_narrow",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 34))
);
chat.set_model("gpt-5.6-sol");
assert!(
!normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
.contains("Luna Reserve")
);
chat.set_model("gpt-reserve");
assert!(
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)).contains("Luna Reserve")
);
}
#[tokio::test]
async fn luna_reserve_usage_survives_banner_dismissal_and_typing_during_a_turn() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(reserve_snapshot(
/*primary_used*/ 48, /*weekly_used*/ 20,
)));
let response = codex_app_server_protocol::GetAccountRateLimitsResponse {
ordinary_usage_allowed: Some(false),
account_id: Some("account-preview".into()),
rate_limits: snapshot(/*percent*/ 100.0),
rate_limits_by_limit_id: None,
rate_limit_reset_credits: None,
rate_limit_upsell: Some(json!({
"banner_type": "luna_reserve", "presentation": "dismissible",
"title": "You’re now using Luna, a faster model for simpler tasks.",
"description": "Add credits or upgrade to continue using the most advanced models.",
"ctas": [
{"action": "add_credits", "label": "Add credits"},
{"action": "open_pricing_dialog", "label": "Upgrade"}
]
})),
};
chat.update_backend_banner(&response);
for colors in [
DefaultColors {
fg: (230, 230, 230),
bg: (20, 20, 20),
},
DefaultColors {
fg: (25, 25, 25),
bg: (255, 255, 255),
},
] {
with_test_default_colors(colors, || {
let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80));
let mut buffer = Buffer::empty(area);
chat.bottom_pane.render(area, &mut buffer);
// Do not rely on the terminal's bold default foreground or dim important copy.
for (y, text) in buffer
.content
.chunks(80)
.enumerate()
.filter_map(|(y, row)| {
let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect();
(text.contains("You’re now using Luna")
|| text.contains("Add credits or upgrade"))
.then_some((y as u16, text))
})
{
let copy = &buffer[(2, y)];
assert_eq!(copy.fg, crate::terminal_palette::rgb_color(colors.fg));
assert!(
!copy.modifier.contains(ratatui::style::Modifier::DIM),
"{text}"
);
}
});
}
insta::assert_snapshot!(
"luna_reserve_usage_with_upsell",
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
!normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
.contains("You’re now using Luna")
);
chat.bottom_pane.set_task_running(/*running*/ true);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80));
assert_eq!(chat.composer_text_with_pending(), "x");
insta::assert_snapshot!("luna_reserve_usage_running", rendered);
}
#[tokio::test]
async fn luna_reserve_prompt_preserves_the_composer_palette_on_exit() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(reserve_snapshot(
/*primary_used*/ 25, /*weekly_used*/ 60,
)));
for colors in [
DefaultColors {
fg: (230, 230, 230),
bg: (20, 20, 20),
},
DefaultColors {
fg: (25, 25, 25),
bg: (255, 255, 255),
},
] {
with_test_default_colors(colors, || {
chat.set_model("gpt-reserve");
let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80));
let mut active = Buffer::empty(area);
chat.bottom_pane.render(area, &mut active);
let cursor = chat.bottom_pane.cursor_pos(area).expect("composer cursor");
let ordinary_style = crate::style::user_message_style();
assert_eq!(active[(0, cursor.1)].bg, ordinary_style.bg.unwrap());
assert_eq!(active[(0, cursor.1)].bg, active[(0, cursor.1 - 1)].bg);
chat.set_model("gpt-5.6-sol");
let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80));
let mut inactive = Buffer::empty(area);
chat.bottom_pane.render(area, &mut inactive);
let restored_cursor = chat.bottom_pane.cursor_pos(area).expect("composer cursor");
assert_eq!(
inactive[(0, restored_cursor.1)].bg,
ordinary_style.bg.unwrap()
);
assert_eq!(restored_cursor.1, cursor.1);
});
}
}
@@ -1,11 +1,9 @@
---
source: tui/src/chatwidget/tests/backend_banners_tests.rs
assertion_line: 50
expression: "render_bottom_popup(&chat, 70)"
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 70))"
---
Selected model usage exhausted
Ask your owner for credits or switch to another model.
› 1. Notify owner
Press a number to choose
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 90))"
---
You’re now using Luna, a faster model for simpler tasks.
Add credits or upgrade to continue using the most advanced models.
1. Add credits
› 2. Upgrade
3. Continue with Luna Reserve
Press enter to confirm or esc to continue working
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs
expression: "render_bottom_popup(&chat, 34)"
---
› Ask Codex to do anything
Luna Reserve default · /tmp/pro…
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs
expression: rendered
---
• Working (0s • esc to interrupt)
› x
tab to queue message 100% context left
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs
expression: rendered
---
› Ask Codex to do anything
Luna Reserve default · /tmp/project
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
---
You’re now using Luna, a faster model for simpler tasks.
Add credits or upgrade to continue using the most advanced models.
› 1. Add credits
2. Upgrade
3. Continue with Luna Reserve
Press enter to confirm or esc to continue working
@@ -706,6 +706,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: Some(RateLimitWindow {
used_percent: 50,
@@ -732,6 +733,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 94,
window_duration_mins: Some(7 * 24 * 60),
@@ -766,6 +768,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 94,
window_duration_mins: Some(7 * 24 * 60),
@@ -800,6 +803,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 9,
window_duration_mins: Some(7 * 24 * 60),
@@ -830,6 +834,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
@@ -860,6 +865,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item()
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: Some(RateLimitWindow {
used_percent: 35,
@@ -890,7 +896,12 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 10,
window_duration_mins: Some(60),
resets_at: Some(123),
}),
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
@@ -912,6 +923,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
chat.on_rolling_rate_limit_snapshot(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 80,
window_duration_mins: Some(60),
@@ -938,7 +950,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
assert!(!credits.unlimited);
assert_eq!(
display.primary.as_ref().map(|window| window.used_percent),
Some(80.0)
Some(10.0)
);
}
@@ -983,6 +995,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 10,
window_duration_mins: Some(60),
@@ -1004,6 +1017,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 25,
window_duration_mins: Some(30),
@@ -1025,6 +1039,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 30,
window_duration_mins: Some(60),
@@ -1051,6 +1066,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 20,
window_duration_mins: Some(300),
@@ -1071,6 +1087,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 90,
window_duration_mins: Some(60),
@@ -1126,6 +1143,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 95,
window_duration_mins: Some(60),
@@ -67,6 +67,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
@@ -353,6 +354,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 9,
window_duration_mins: Some(7 * 24 * 60),
@@ -401,6 +401,7 @@ impl ChatWidget {
}
pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) {
self.invalidate_ordinary_usage_recovery();
// on_error can drain queued input, before the asynchronous recovery read completes.
self.input_queue.rate_limit_recovery_pending = self.has_chatgpt_account;
let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit);
+1 -1
View File
@@ -266,7 +266,7 @@ impl SessionHeaderHistoryCell {
) -> Self {
Self {
version,
model,
model: crate::model_catalog::model_display_name(&model).to_string(),
model_style,
reasoning_effort,
show_fast_status,
+13
View File
@@ -1,6 +1,19 @@
//! TUI model inventory and user-facing labels; wire requests retain their original model slugs.
use codex_protocol::openai_models::ModelPreset;
use std::convert::Infallible;
pub(crate) const LUNA_RESERVE_MODEL: &str = "gpt-reserve";
pub(crate) const LUNA_MODEL: &str = "gpt-5.6-luna";
pub(crate) fn model_display_name(model: &str) -> &str {
if model.eq_ignore_ascii_case(LUNA_RESERVE_MODEL) {
"Luna Reserve"
} else {
model
}
}
#[derive(Debug, Clone)]
pub(crate) struct ModelCatalog {
models: Vec<ModelPreset>,
+29 -8
View File
@@ -95,6 +95,8 @@ impl RateLimitWindowDisplay {
pub(crate) struct RateLimitSnapshotDisplay {
/// Canonical limit identifier (for example: `codex` or `codex_other`).
pub limit_name: String,
/// Optional normal model associated with this account-read quota alias.
pub normal_model_slug: Option<String>,
/// Local timestamp representing when this display snapshot was captured.
pub captured_at: DateTime<Local>,
/// Primary usage window.
@@ -147,6 +149,7 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
normal_model_slug: snapshot.normal_model_slug.clone(),
limit_name,
captured_at,
primary: snapshot
@@ -216,7 +219,19 @@ pub(crate) fn compose_rate_limit_data_many(
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
let mut stale = false;
for snapshot in snapshots {
// Show ordinary plan usage before model-specific quotas, regardless of backend bucket IDs.
let mut ordered_snapshots = snapshots.iter().collect::<Vec<_>>();
ordered_snapshots.sort_by_key(|snapshot| !snapshot.limit_name.eq_ignore_ascii_case("codex"));
for snapshot in ordered_snapshots {
let credit_row = snapshot.credits.as_ref().and_then(credit_status_row);
// Metadata-only buckets have no visible values and should not leave an empty heading.
if snapshot.primary.is_none()
&& snapshot.secondary.is_none()
&& credit_row.is_none()
&& snapshot.individual_limit.is_none()
{
continue;
}
stale |= now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
stale |= snapshot
@@ -228,7 +243,7 @@ pub(crate) fn compose_rate_limit_data_many(
})
.unwrap_or(false);
let limit_bucket_label = snapshot.limit_name.clone();
let limit_bucket_label = crate::model_catalog::model_display_name(&snapshot.limit_name);
let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
let primary_label = snapshot
.primary
@@ -307,9 +322,7 @@ pub(crate) fn compose_rate_limit_data_many(
});
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
if let Some(row) = credit_row {
rows.push(row);
}
if let Some(individual_limit) = snapshot.individual_limit.as_ref() {
@@ -341,10 +354,15 @@ pub(crate) fn compose_rate_limit_data_many(
/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input.
/// Passing a used percentage by mistake will invert the bar and mislead users.
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
render_limit_progress_bar(percent_remaining, STATUS_LIMIT_BAR_SEGMENTS)
}
/// Shared bar geometry for the status card and compact composer usage indicators.
pub(crate) fn render_limit_progress_bar(percent_remaining: f64, segments: usize) -> String {
let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
let filled = (ratio * segments as f64).round() as usize;
let filled = filled.min(segments);
let empty = segments.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
@@ -438,6 +456,7 @@ mod tests {
let now = Local::now();
let codex = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
normal_model_slug: None,
captured_at: now,
primary: Some(window(/*used_percent*/ 10.0)),
secondary: None,
@@ -450,6 +469,7 @@ mod tests {
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
normal_model_slug: None,
captured_at: now,
primary: Some(window(/*used_percent*/ 20.0)),
secondary: None,
@@ -484,6 +504,7 @@ mod tests {
let now = Local::now();
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
normal_model_slug: None,
captured_at: now,
primary: Some(RateLimitWindowDisplay {
used_percent: 20.0,
+17
View File
@@ -64,6 +64,7 @@ fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() {
let now = Local::now();
let snapshot = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
normal_model_slug: None,
captured_at: now,
primary: Some(RateLimitWindowDisplay {
used_percent: 20.0,
@@ -306,6 +307,7 @@ async fn status_snapshot_includes_reasoning_details() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 72,
window_duration_mins: Some(300),
@@ -999,6 +1001,7 @@ async fn status_snapshot_includes_monthly_limit() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 12,
window_duration_mins: Some(43_200),
@@ -1063,6 +1066,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: None,
@@ -1141,6 +1145,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(2 * 60),
@@ -1199,6 +1204,7 @@ async fn status_snapshot_shows_unlimited_credits() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -1251,6 +1257,7 @@ async fn status_snapshot_shows_positive_credits() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -1312,6 +1319,7 @@ async fn status_snapshot_shows_available_credits_without_display_balance() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -1363,6 +1371,7 @@ async fn status_snapshot_respects_unlimited_without_has_credits_flag() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
@@ -1473,6 +1482,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 72,
window_duration_mins: Some(300),
@@ -1678,6 +1688,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 45,
window_duration_mins: Some(300),
@@ -1769,6 +1780,7 @@ async fn transcript_overlay_remeasures_status_after_rate_limit_refresh() {
handle.finish_rate_limit_refresh(
&[RateLimitSnapshotDisplay {
limit_name: "spark".to_string(),
normal_model_slug: None,
captured_at: now,
primary: Some(RateLimitWindowDisplay {
used_percent: 45.0,
@@ -1831,6 +1843,7 @@ async fn status_snapshot_includes_credits_and_limits() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 45,
window_duration_mins: Some(300),
@@ -1899,6 +1912,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: None,
@@ -1958,6 +1972,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: None,
secondary: None,
credits: None,
@@ -2023,6 +2038,7 @@ async fn status_snapshot_shows_stale_limits_message() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 72,
window_duration_mins: Some(300),
@@ -2092,6 +2108,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
normal_model_slug: None,
primary: Some(RateLimitWindow {
used_percent: 60,
window_duration_mins: Some(300),
+1
View File
@@ -263,6 +263,7 @@ fn grouped_usage(
}
fn format_model_display_name(model: &str) -> String {
let model = crate::model_catalog::model_display_name(model);
let Some((prefix, remainder)) = model.split_once('-') else {
return model.to_string();
};