mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
Show estimated thread usage in /status (#38281)
## What changed - Extend `account/usage/read` with an optional `threadId` request and a backward-compatible `threadUsage` response containing estimated credits, optional USD cost, and model, reasoning, speed, and token breakdowns. - Fetch thread usage for eligible Business and Enterprise plans when `/status` is opened, then update the rendered status card asynchronously. - Preserve terminal scrollback when replacing a visible status-card tail, and append a refreshed card when the original tail can no longer be updated. ## Testing - Add app-server coverage for authentication, canonical thread IDs, unavailable billing routes, and malformed IDs. - Add TUI coverage for usage formatting, stale responses, retries, history updates, terminal reflow, and scrollback preservation. GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
1 parent
27a98dde4d
commit
f1a1fce26a
55 files changed
+2903
-60
No files matched your search
@@ -246,6 +246,7 @@ impl ChatWidget {
|
||||
next_status_line_workspace_headline_request_id: 0,
|
||||
status_line_workspace_headline_last_requested_at: None,
|
||||
status_line_workspace_messages_disabled: false,
|
||||
thread_usage: thread_usage::ThreadUsageState::default(),
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
|
||||
@@ -24,6 +24,7 @@ impl ChatWidget {
|
||||
.set_queue_submissions(/*queue_submissions*/ false);
|
||||
if previous_thread_id != self.thread_id {
|
||||
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
|
||||
self.clear_thread_usage_state();
|
||||
}
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.turn_lifecycle.reset_thread();
|
||||
|
||||
@@ -236,6 +236,7 @@ impl ChatWidget {
|
||||
self.status_line_workspace_headline_pending_request_id = None;
|
||||
self.status_line_workspace_headline_last_requested_at = None;
|
||||
self.status_line_workspace_messages_disabled = false;
|
||||
self.clear_thread_usage_state();
|
||||
self.status_account_display = status_account_display;
|
||||
self.plan_type = plan_type;
|
||||
self.has_chatgpt_account = has_chatgpt_account;
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_command_tests.rs
|
||||
expression: thread_usage
|
||||
---
|
||||
│ Thread usage: 50 credits · ~$1.82 │
|
||||
│ Models: GPT-5.4 80%, GPT-5 Mini 20% │
|
||||
│ Reasoning: Medium 20%, High 80% │
|
||||
│ Speed: Fast mode 80%, Standard 20% │
|
||||
│ Billed tokens: 120 input (25 cached) + 50 output │
|
||||
╰──────────────────────────────────────────────────────────────────────╯
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_command_tests.rs
|
||||
expression: thread_usage
|
||||
---
|
||||
│ Thread usage: 50 credits │
|
||||
│ Models: GPT-5.4 100% │
|
||||
│ Reasoning: High 100% │
|
||||
│ Speed: Fast mode 100% │
|
||||
│ Billed tokens: 100 input (20 cached) + 40 output │
|
||||
╰──────────────────────────────────────────────────────────────────────╯
|
||||
@@ -245,9 +245,17 @@ impl ChatWidget {
|
||||
refreshing_rate_limits,
|
||||
);
|
||||
if let Some(request_id) = request_id {
|
||||
self.refreshing_status_outputs.push((request_id, handle));
|
||||
self.refreshing_status_outputs
|
||||
.push((request_id, handle.clone()));
|
||||
}
|
||||
if self.thread_usage_is_available() {
|
||||
handle.reserve_thread_usage_label_width();
|
||||
handle.set_thread_usage(self.estimated_thread_usage().cloned());
|
||||
self.add_to_history(cell);
|
||||
self.request_thread_usage_for_status(handle);
|
||||
} else {
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_status_rate_limit_refresh(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -197,3 +200,417 @@ async fn account_update_rejects_stale_status_rate_limit_snapshots() {
|
||||
|
||||
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_renders_immediately_and_updates_first_card_without_status_line() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id =
|
||||
ThreadId::from_string("019fc8ab-1fb2-7000-8000-000000000001").expect("valid thread id");
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
let first_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
let initial = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(!initial.contains("Thread usage"));
|
||||
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage {
|
||||
thread_id: requested_thread_id,
|
||||
request_id,
|
||||
}) => {
|
||||
assert_eq!(requested_thread_id, thread_id);
|
||||
request_id
|
||||
}
|
||||
event => panic!("expected /status to request thread usage, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: vec![
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 40_000_000,
|
||||
net_new_input_tokens: Some(80),
|
||||
cached_input_tokens: Some(20),
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(40),
|
||||
total_tokens: Some(140),
|
||||
},
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
reasoning_effort: Some("medium".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
net_new_input_tokens: Some(15),
|
||||
cached_input_tokens: Some(5),
|
||||
input_tokens: Some(20),
|
||||
output_tokens: Some(10),
|
||||
total_tokens: Some(30),
|
||||
},
|
||||
],
|
||||
})),
|
||||
));
|
||||
|
||||
let settled = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(settled.contains("50 credits · ~$1.82"));
|
||||
assert!(settled.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected /status output with cached thread usage, got {event:?}"),
|
||||
};
|
||||
let rendered =
|
||||
normalize_snapshot_paths(lines_to_single_string(&cell.display_lines(/*width*/ 90)));
|
||||
assert!(rendered.contains("50 credits · ~$1.82"));
|
||||
assert!(rendered.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
|
||||
assert!(rendered.contains("Medium 20%, High 80%"));
|
||||
assert!(rendered.contains("Fast mode 80%, Standard 20%"));
|
||||
assert!(rendered.contains("120 input (25 cached) + 50 output"));
|
||||
assert!(
|
||||
rendered.find("Limits:") < rendered.find("Thread usage:"),
|
||||
"account limits should appear above the thread usage group: {rendered}"
|
||||
);
|
||||
let thread_usage = rendered
|
||||
.lines()
|
||||
.skip_while(|line| !line.contains("Thread usage:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_chatwidget_snapshot!("status_command_estimated_thread_usage", thread_usage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() {
|
||||
for plan_type in [
|
||||
Some(PlanType::Enterprise),
|
||||
Some(PlanType::Ent26),
|
||||
Some(PlanType::Pro),
|
||||
Some(PlanType::Edu),
|
||||
Some(PlanType::SelfServeBusinessUsageBased),
|
||||
Some(PlanType::Unknown),
|
||||
None,
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.plan_type = plan_type;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
let rendered = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
assert!(!rendered.contains("Thread usage"));
|
||||
assert!(
|
||||
!std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::RefreshThreadUsage { .. })),
|
||||
"unsupported plan {plan_type:?} must not request thread usage",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_stays_visible_and_updates_after_thread_usage_retry() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
assert!(!lines_to_single_string(&cell.display_lines(/*width*/ 90)).contains("Thread usage"));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Err("thread usage temporarily unavailable".to_string()),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let retry_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected another immediate /status output, got {event:?}"),
|
||||
};
|
||||
let retry_request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage retry, got {event:?}"),
|
||||
};
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
retry_request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
for status_cell in [&cell, &retry_cell] {
|
||||
assert!(
|
||||
lines_to_single_string(&status_cell.display_lines(/*width*/ 90))
|
||||
.contains("50 credits · ~$1.82")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let first_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: None,
|
||||
groups: vec![ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
net_new_input_tokens: Some(80),
|
||||
cached_input_tokens: Some(20),
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(40),
|
||||
total_tokens: Some(140),
|
||||
}],
|
||||
})),
|
||||
));
|
||||
|
||||
let first_rendered = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(first_rendered.contains("50 credits"));
|
||||
assert!(first_rendered.contains("GPT-5.4 100%"));
|
||||
assert!(!first_rendered.contains("~$"));
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let rendered = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected cached credits-only /status output, got {event:?}"),
|
||||
};
|
||||
assert!(rendered.contains("50 credits"));
|
||||
assert!(rendered.contains("GPT-5.4 100%"));
|
||||
assert!(rendered.contains("High 100%"));
|
||||
assert!(rendered.contains("Fast mode 100%"));
|
||||
assert!(rendered.contains("100 input (20 cached) + 40 output"));
|
||||
assert!(!rendered.contains("~$"));
|
||||
let thread_usage = rendered
|
||||
.lines()
|
||||
.skip_while(|line| !line.contains("Thread usage:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_command_estimated_thread_usage_without_usd",
|
||||
thread_usage
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected initial thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 40_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cached = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected cached /status output, got {event:?}"),
|
||||
};
|
||||
assert!(cached.contains("40 credits · ~$1.82"));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected refreshed thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: None,
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let refreshed = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected credits-only /status output, got {event:?}"),
|
||||
};
|
||||
assert!(refreshed.contains("50 credits"));
|
||||
assert!(!refreshed.contains("~$1.82"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_requests_thread_usage_for_remote_connection_metadata() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None,
|
||||
Some(PlanType::Business),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
);
|
||||
chat.remote_connection = Some(crate::status::remote_connection::RemoteConnectionStatus {
|
||||
address: "wss://remote.example.com".to_string(),
|
||||
version: "v1.0.0".to_string(),
|
||||
});
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_requests_thread_usage_with_backend_only_authentication() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None,
|
||||
Some(PlanType::Business),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
);
|
||||
assert!(!chat.has_chatgpt_account());
|
||||
assert!(chat.has_codex_backend_auth());
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_remains_visible_when_account_changes_during_usage_refresh() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
|
||||
);
|
||||
|
||||
assert!(!chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_remains_visible_when_thread_changes_during_usage_refresh() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.clear_thread_usage_state();
|
||||
|
||||
assert!(!chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Demand-driven estimated-cost state for the currently visible enterprise thread.
|
||||
|
||||
use super::AppEvent;
|
||||
use super::ChatWidget;
|
||||
use super::PlanType;
|
||||
use super::ThreadId;
|
||||
use crate::status::StatusHistoryHandle;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadUsageOutcome {
|
||||
Available(ThreadUsage),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct ThreadUsageState {
|
||||
thread_id: Option<ThreadId>,
|
||||
estimate: Option<ThreadUsage>,
|
||||
pending_request_id: Option<u64>,
|
||||
next_request_id: u64,
|
||||
feature_disabled: bool,
|
||||
status_history_handles: Vec<StatusHistoryHandle>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn clear_thread_usage_state(&mut self) {
|
||||
let next_request_id = self.thread_usage.next_request_id;
|
||||
self.thread_usage = ThreadUsageState {
|
||||
next_request_id,
|
||||
..ThreadUsageState::default()
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn request_thread_usage_for_status(&mut self, handle: StatusHistoryHandle) {
|
||||
if !self.thread_usage_is_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
return;
|
||||
};
|
||||
if self.thread_usage.thread_id != Some(thread_id) {
|
||||
self.clear_thread_usage_state();
|
||||
self.thread_usage.thread_id = Some(thread_id);
|
||||
}
|
||||
|
||||
self.thread_usage.status_history_handles.push(handle);
|
||||
self.request_thread_usage();
|
||||
}
|
||||
|
||||
pub(crate) fn finish_thread_usage_refresh(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
result: Result<ThreadUsageOutcome, String>,
|
||||
) -> bool {
|
||||
if self.thread_id != Some(thread_id)
|
||||
|| self.thread_usage.thread_id != Some(thread_id)
|
||||
|| self.thread_usage.pending_request_id != Some(request_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.thread_usage.pending_request_id = None;
|
||||
let mut usage_updated = false;
|
||||
match result {
|
||||
Ok(ThreadUsageOutcome::Disabled) => {
|
||||
self.thread_usage.feature_disabled = true;
|
||||
self.thread_usage.estimate = None;
|
||||
usage_updated = true;
|
||||
}
|
||||
Ok(ThreadUsageOutcome::Available(usage))
|
||||
if usage.thread_id != thread_id.to_string() =>
|
||||
{
|
||||
tracing::warn!(
|
||||
requested_thread_id = %thread_id,
|
||||
returned_thread_id = %usage.thread_id,
|
||||
"thread usage response referred to another thread"
|
||||
);
|
||||
}
|
||||
Ok(ThreadUsageOutcome::Available(usage)) => {
|
||||
self.thread_usage.estimate = Some(usage);
|
||||
usage_updated = true;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "failed to fetch estimated thread usage");
|
||||
}
|
||||
}
|
||||
if usage_updated && !self.thread_usage.status_history_handles.is_empty() {
|
||||
for handle in self.thread_usage.status_history_handles.drain(..) {
|
||||
handle.set_thread_usage(self.thread_usage.estimate.clone());
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> {
|
||||
self.thread_usage.estimate.as_ref()
|
||||
}
|
||||
|
||||
fn request_thread_usage(&mut self) {
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
return;
|
||||
};
|
||||
if !self.thread_usage_is_available() || self.thread_usage.pending_request_id.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let request_id = self.thread_usage.next_request_id;
|
||||
self.thread_usage.next_request_id =
|
||||
self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1);
|
||||
self.thread_usage.pending_request_id = Some(request_id);
|
||||
self.thread_usage.thread_id = Some(thread_id);
|
||||
self.app_event_tx.send(AppEvent::RefreshThreadUsage {
|
||||
thread_id,
|
||||
request_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn thread_usage_is_available(&self) -> bool {
|
||||
self.has_codex_backend_auth
|
||||
&& !self.thread_usage.feature_disabled
|
||||
&& self.thread_id.is_some()
|
||||
&& matches!(
|
||||
self.plan_type,
|
||||
Some(
|
||||
PlanType::Business
|
||||
| PlanType::EnterpriseCbpUsageBased
|
||||
| PlanType::EnterpriseCbpAutomation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ fn summary_snapshot_left_aligns_and_splits_when_needed() {
|
||||
longest_streak_days: Some(54),
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: None,
|
||||
};
|
||||
let rendered = |width| {
|
||||
summary_lines(&response, graph_width(width))
|
||||
|
||||
@@ -21,6 +21,7 @@ fn loaded_state_freezes_chart_anchor_date_at_completion() {
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: None,
|
||||
}),
|
||||
today,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user