Files
codex/codex-rs/tui/src/status/helpers.rs
bwanner-oai 82e8031338 Add usage-based business plan types (#15934)
## Summary
- add `self_serve_business_usage_based` and `enterprise_cbp_usage_based`
to the public/internal plan enums and regenerate the app-server + Python
SDK artifacts
- map both plans through JWT login and backend rate-limit payloads, then
bucket them with the existing Team/Business entitlement behavior in
cloud requirements, usage-limit copy, tooltips, and status display
- keep the earlier display-label remap commit on this branch so the new
Team-like and Business-like plans render consistently in the UI

## Testing
- `just write-app-server-schema`
- `uv run --project sdk/python python
sdk/python/scripts/update_sdk_artifacts.py generate-types`
- `just fix -p codex-protocol -p codex-login -p codex-core -p
codex-backend-client -p codex-cloud-requirements -p codex-tui -p
codex-tui-app-server -p codex-backend-openapi-models`
- `just fmt`
- `just argument-comment-lint`
- `cargo test -p codex-protocol
usage_based_plan_types_use_expected_wire_names`
- `cargo test -p codex-login usage_based`
- `cargo test -p codex-backend-client usage_based`
- `cargo test -p codex-cloud-requirements usage_based`
- `cargo test -p codex-core usage_limit_reached_error_formats_`
- `cargo test -p codex-tui plan_type_display_name_remaps_display_labels`
- `cargo test -p codex-tui remapped`
- `cargo test -p codex-tui-app-server
plan_type_display_name_remaps_display_labels`
- `cargo test -p codex-tui-app-server remapped`
- `cargo test -p codex-tui-app-server
preserves_usage_based_plan_type_wire_name`

## Notes
- a broader multi-crate `cargo test` run still hits unrelated existing
guardian-approval config failures in
`codex-rs/core/src/config/config_tests.rs`
2026-03-27 14:25:13 -07:00

245 lines
7.7 KiB
Rust

use crate::exec_command::relativize_to_home;
use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_core::AuthManager;
use codex_core::auth::AuthMode as CoreAuthMode;
use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths;
use codex_protocol::account::PlanType;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
use super::account::StatusAccountDisplay;
fn normalize_agents_display_path(path: &Path) -> String {
dunce::simplified(path).display().to_string()
}
pub(crate) fn compose_model_display(
model_name: &str,
entries: &[(&str, String)],
) -> (String, Vec<String>) {
let mut details: Vec<String> = Vec::new();
if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") {
details.push(format!("reasoning {}", effort.to_ascii_lowercase()));
}
if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") {
let summary = summary.trim();
if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") {
details.push("summaries off".to_string());
} else if !summary.is_empty() {
details.push(format!("summaries {}", summary.to_ascii_lowercase()));
}
}
(model_name.to_string(), details)
}
pub(crate) fn compose_agents_summary(config: &Config) -> String {
match discover_project_doc_paths(config) {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let file_name = p
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let display = if let Some(parent) = p.parent() {
if parent == config.cwd.as_path() {
file_name.clone()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
let mut reached = false;
while let Some(c) = cur.parent() {
if cur == parent {
reached = true;
break;
}
cur = c;
ups += 1;
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}{}", up.repeat(ups), file_name)
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
normalize_agents_display_path(stripped)
} else {
normalize_agents_display_path(&p)
}
}
} else {
normalize_agents_display_path(&p)
};
rels.push(display);
}
if rels.is_empty() {
"<none>".to_string()
} else {
rels.join(", ")
}
}
Err(_) => "<none>".to_string(),
}
}
pub(crate) fn compose_account_display(
auth_manager: &AuthManager,
plan: Option<PlanType>,
) -> Option<StatusAccountDisplay> {
let auth = auth_manager.auth_cached()?;
match auth.auth_mode() {
CoreAuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
let email = auth.get_account_email();
let plan = plan.map(plan_type_display_name);
let plan = plan.or_else(|| Some("Unknown".to_string()));
Some(StatusAccountDisplay::ChatGpt { email, plan })
}
}
}
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
if plan_type.is_team_like() {
"Business".to_string()
} else if plan_type.is_business_like() {
"Enterprise".to_string()
} else {
title_case(format!("{plan_type:?}").as_str())
}
}
pub(crate) fn format_tokens_compact(value: i64) -> String {
let value = value.max(0);
if value == 0 {
return "0".to_string();
}
if value < 1_000 {
return value.to_string();
}
let value_f64 = value as f64;
let (scaled, suffix) = if value >= 1_000_000_000_000 {
(value_f64 / 1_000_000_000_000.0, "T")
} else if value >= 1_000_000_000 {
(value_f64 / 1_000_000_000.0, "B")
} else if value >= 1_000_000 {
(value_f64 / 1_000_000.0, "M")
} else {
(value_f64 / 1_000.0, "K")
};
let decimals = if scaled < 10.0 {
2
} else if scaled < 100.0 {
1
} else {
0
};
let mut formatted = format!("{scaled:.decimals$}");
if formatted.contains('.') {
while formatted.ends_with('0') {
formatted.pop();
}
if formatted.ends_with('.') {
formatted.pop();
}
}
format!("{formatted}{suffix}")
}
pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize>) -> String {
let formatted = if let Some(rel) = relativize_to_home(directory) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
}
} else {
directory.display().to_string()
};
if let Some(max_width) = max_width {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
return text_formatting::center_truncate_path(&formatted, max_width);
}
}
formatted
}
pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<Local>) -> String {
let time = dt.format("%H:%M").to_string();
if dt.date_naive() == captured_at.date_naive() {
time
} else {
format!("{time} on {}", dt.format("%-d %b"))
}
}
pub(crate) fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let first = match chars.next() {
Some(c) => c,
None => return String::new(),
};
let rest: String = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::auth::CodexAuth;
use pretty_assertions::assert_eq;
#[test]
fn plan_type_display_name_remaps_display_labels() {
let cases = [
(PlanType::Free, "Free"),
(PlanType::Go, "Go"),
(PlanType::Plus, "Plus"),
(PlanType::Pro, "Pro"),
(PlanType::Team, "Business"),
(PlanType::SelfServeBusinessUsageBased, "Business"),
(PlanType::Business, "Enterprise"),
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
(PlanType::Enterprise, "Enterprise"),
(PlanType::Edu, "Edu"),
(PlanType::Unknown, "Unknown"),
];
for (plan_type, expected) in cases {
assert_eq!(plan_type_display_name(plan_type), expected);
}
}
#[test]
fn compose_account_display_uses_remapped_plan_label() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let display = compose_account_display(
auth_manager.as_ref(),
Some(PlanType::SelfServeBusinessUsageBased),
);
assert!(matches!(
display,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Business"
));
}
}