mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
committed by
copyberry
parent
76f47103fe
commit
5037919777
@@ -3415,6 +3415,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+7
@@ -17482,6 +17482,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+7
@@ -13554,6 +13554,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+7
@@ -88,6 +88,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+7
@@ -174,6 +174,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normalModelSlug": {
|
||||
"description": "Normal model whose display name and reasoning options describe this quota alias.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
-3
@@ -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
|
||||
|
||||
+10
@@ -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
|
||||
+5
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-3
@@ -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
|
||||
|
||||
+12
@@ -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
|
||||
+8
@@ -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…
|
||||
+10
@@ -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
|
||||
+8
@@ -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
|
||||
+12
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user