Files
codex/codex-rs/tui/src/backend_banners.rs
T
dhruvgupta-oai 5037919777 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
2026-09-02 21:50:51 +00:00

78 lines
2.5 KiB
Rust

//! Backend banner payload and CTA mapping, supplied by the existing account usage read.
//!
//! The backend owns eligibility, copy, actions, and ordered model fallback instructions.
use codex_protocol::account::PlanType;
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;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub(crate) struct BackendBanner {
pub(crate) banner_type: String,
pub(crate) title: String,
pub(crate) description: String,
pub(crate) ctas: Vec<BackendBannerCta>,
pub(crate) reset_at: Option<i64>,
pub(crate) model_slug: Option<String>,
pub(crate) blocked_model_slug: Option<String>,
#[serde(default)]
pub(crate) fallback_model_slugs: Vec<String>,
#[serde(default)]
pub(crate) presentation: BannerPresentation,
request_url: Option<String>,
#[serde(skip)]
pub(crate) account_id: String,
#[serde(skip)]
pub(crate) plan_type: Option<PlanType>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum BannerPresentation {
#[default]
Inline,
Dismissible,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub(crate) struct BackendBannerCta {
action: String,
label: String,
}
impl BackendBanner {
/// Parse supported, bounded content before constructing rendered copy or CTA closures.
pub(crate) fn parse(raw: &serde_json::Value) -> Option<Self> {
serde_json::from_value::<Self>(raw.clone())
.ok()
.filter(|banner| {
let valid_slug = |slug: &str| {
!slug.trim().is_empty()
&& slug.len() <= 256
&& !slug.chars().any(char::is_control)
};
banner.title.len() <= 1024
&& banner.description.len() <= 4096
&& banner.ctas.len() <= 8
&& !banner.title.trim().is_empty()
&& banner.title.lines().count() <= 3
&& banner.description.lines().count() <= 12
&& banner.blocked_model_slug.as_deref().is_none_or(valid_slug)
&& banner.fallback_model_slugs.len() <= 16
&& banner
.fallback_model_slugs
.iter()
.all(|slug| valid_slug(slug))
})
}
}