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`
This commit is contained in:
bwanner-oai
2026-03-27 14:25:13 -07:00
committed by GitHub
Unverified
parent 81abb44f68
commit 82e8031338
27 changed files with 1177 additions and 388 deletions
@@ -1777,7 +1777,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -9503,7 +9503,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -6317,7 +6317,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -29,7 +29,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -34,7 +34,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -29,7 +29,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -52,7 +52,9 @@
"plus",
"pro",
"team",
"self_serve_business_usage_based",
"business",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"unknown"
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown";
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
+18
View File
@@ -474,7 +474,13 @@ impl Client {
crate::types::PlanType::Plus => AccountPlanType::Plus,
crate::types::PlanType::Pro => AccountPlanType::Pro,
crate::types::PlanType::Team => AccountPlanType::Team,
crate::types::PlanType::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
}
crate::types::PlanType::Business => AccountPlanType::Business,
crate::types::PlanType::EnterpriseCbpUsageBased => {
AccountPlanType::EnterpriseCbpUsageBased
}
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
crate::types::PlanType::Guest
@@ -499,6 +505,18 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn map_plan_type_supports_usage_based_business_variants() {
assert_eq!(
Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased),
AccountPlanType::SelfServeBusinessUsageBased
);
assert_eq!(
Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased),
AccountPlanType::EnterpriseCbpUsageBased
);
}
#[test]
fn usage_payload_maps_primary_and_additional_rate_limits() {
let payload = RateLimitStatusPayload {
+50 -8
View File
@@ -327,11 +327,11 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return Ok(None);
};
let Some(plan_type) = auth.account_plan_type() else {
return Ok(None);
};
if !auth.is_chatgpt_auth()
|| !matches!(
auth.account_plan_type(),
Some(PlanType::Business | PlanType::Enterprise)
)
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
return Ok(None);
}
@@ -547,11 +547,11 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return false;
};
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
if !auth.is_chatgpt_auth()
|| !matches!(
auth.account_plan_type(),
Some(PlanType::Business | PlanType::Enterprise)
)
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
return false;
}
@@ -1125,6 +1125,20 @@ mod tests {
assert_eq!(result, Ok(None));
}
#[tokio::test]
async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() {
let codex_home = tempdir().expect("tempdir");
let service = CloudRequirementsService::new(
auth_manager_with_plan("self_serve_business_usage_based"),
Arc::new(StaticFetcher {
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
}),
codex_home.path().to_path_buf(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
assert_eq!(service.fetch().await, Ok(None));
}
#[tokio::test]
async fn fetch_cloud_requirements_allows_business_plan() {
let codex_home = tempdir().expect("tempdir");
@@ -1153,6 +1167,34 @@ mod tests {
);
}
#[tokio::test]
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
let codex_home = tempdir().expect("tempdir");
let service = CloudRequirementsService::new(
auth_manager_with_plan("enterprise_cbp_usage_based"),
Arc::new(StaticFetcher {
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
}),
codex_home.path().to_path_buf(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
assert_eq!(
service.fetch().await,
Ok(Some(ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
guardian_developer_instructions: None,
feature_requirements: None,
mcp_servers: None,
apps: None,
rules: None,
enforce_residency: None,
network: None,
}))
);
}
#[tokio::test]
async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() {
let codex_home = tempdir().expect("tempdir");
@@ -69,8 +69,12 @@ pub enum PlanType {
FreeWorkspace,
#[serde(rename = "team")]
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
#[serde(rename = "business")]
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
#[serde(rename = "education")]
Education,
#[serde(rename = "quorum")]
+6 -16
View File
@@ -439,7 +439,12 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
retry_suffix_after_or(self.resets_at.as_ref())
),
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
Some(PlanType::Known(
KnownPlan::Team
| KnownPlan::SelfServeBusinessUsageBased
| KnownPlan::Business
| KnownPlan::EnterpriseCbpUsageBased,
)) => {
format!(
"You've hit your usage limit. To get more access now, send a request to your admin{}",
retry_suffix_after_or(self.resets_at.as_ref())
@@ -460,21 +465,6 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
),
Some(PlanType::Unknown(plan))
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
{
match self
.rate_limits
.as_ref()
.and_then(|snapshot| snapshot.credits.as_ref())
.map(|credits| credits.has_credits)
{
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
.to_string(),
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
.to_string(),
}
}
Some(PlanType::Unknown(_)) | None => format!(
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
+28
View File
@@ -243,6 +243,34 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
);
}
#[test]
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError {
+46
View File
@@ -458,6 +458,52 @@ fn plan_type_maps_known_plan() {
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
}
#[test]
fn plan_type_maps_self_serve_business_usage_based_plan() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(
auth.account_plan_type(),
Some(AccountPlanType::SelfServeBusinessUsageBased)
);
}
#[test]
fn plan_type_maps_enterprise_cbp_usage_based_plan() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(
auth.account_plan_type(),
Some(AccountPlanType::EnterpriseCbpUsageBased)
);
}
#[test]
fn plan_type_maps_unknown_to_unknown() {
let codex_home = tempdir().unwrap();
+4
View File
@@ -266,7 +266,11 @@ impl CodexAuth {
InternalKnownPlan::Plus => AccountPlanType::Plus,
InternalKnownPlan::Pro => AccountPlanType::Pro,
InternalKnownPlan::Team => AccountPlanType::Team,
InternalKnownPlan::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
}
InternalKnownPlan::Business => AccountPlanType::Business,
InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased,
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
InternalKnownPlan::Edu => AccountPlanType::Edu,
};
+62 -5
View File
@@ -41,7 +41,14 @@ pub struct IdTokenInfo {
impl IdTokenInfo {
pub fn get_chatgpt_plan_type(&self) -> Option<String> {
self.chatgpt_plan_type.as_ref().map(|t| match t {
PlanType::Known(plan) => format!("{plan:?}"),
PlanType::Known(plan) => plan.display_name().to_string(),
PlanType::Unknown(s) => s.clone(),
})
}
pub fn get_chatgpt_plan_type_raw(&self) -> Option<String> {
self.chatgpt_plan_type.as_ref().map(|t| match t {
PlanType::Known(plan) => plan.raw_value().to_string(),
PlanType::Unknown(s) => s.clone(),
})
}
@@ -49,9 +56,7 @@ impl IdTokenInfo {
pub fn is_workspace_account(&self) -> bool {
matches!(
self.chatgpt_plan_type,
Some(PlanType::Known(
KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu
))
Some(PlanType::Known(plan)) if plan.is_workspace_account()
)
}
}
@@ -71,7 +76,11 @@ impl PlanType {
"plus" => Self::Known(KnownPlan::Plus),
"pro" => Self::Known(KnownPlan::Pro),
"team" => Self::Known(KnownPlan::Team),
"self_serve_business_usage_based" => {
Self::Known(KnownPlan::SelfServeBusinessUsageBased)
}
"business" => Self::Known(KnownPlan::Business),
"enterprise_cbp_usage_based" => Self::Known(KnownPlan::EnterpriseCbpUsageBased),
"enterprise" | "hc" => Self::Known(KnownPlan::Enterprise),
"education" | "edu" => Self::Known(KnownPlan::Edu),
_ => Self::Unknown(raw.to_string()),
@@ -79,7 +88,7 @@ impl PlanType {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KnownPlan {
Free,
@@ -87,12 +96,60 @@ pub enum KnownPlan {
Plus,
Pro,
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
#[serde(alias = "hc")]
Enterprise,
Edu,
}
impl KnownPlan {
pub fn display_name(self) -> &'static str {
match self {
Self::Free => "Free",
Self::Go => "Go",
Self::Plus => "Plus",
Self::Pro => "Pro",
Self::Team => "Team",
Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based",
Self::Business => "Business",
Self::EnterpriseCbpUsageBased => "Enterprise CBP Usage Based",
Self::Enterprise => "Enterprise",
Self::Edu => "Edu",
}
}
pub fn raw_value(self) -> &'static str {
match self {
Self::Free => "free",
Self::Go => "go",
Self::Plus => "plus",
Self::Pro => "pro",
Self::Team => "team",
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
Self::Business => "business",
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
Self::Enterprise => "enterprise",
Self::Edu => "edu",
}
}
pub fn is_workspace_account(self) -> bool {
matches!(
self,
Self::Team
| Self::SelfServeBusinessUsageBased
| Self::Business
| Self::EnterpriseCbpUsageBased
| Self::Enterprise
| Self::Edu
)
}
}
#[derive(Deserialize)]
struct IdClaims {
#[serde(default)]
+38
View File
@@ -68,6 +68,44 @@ fn id_token_info_parses_hc_plan_as_enterprise() {
assert_eq!(info.is_workspace_account(), true);
}
#[test]
fn id_token_info_parses_usage_based_business_plans() {
let self_serve_business_jwt = fake_jwt(serde_json::json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "self_serve_business_usage_based"
}
}));
let self_serve_business =
parse_chatgpt_jwt_claims(&self_serve_business_jwt).expect("should parse");
assert_eq!(
self_serve_business.get_chatgpt_plan_type().as_deref(),
Some("Self Serve Business Usage Based")
);
assert_eq!(
self_serve_business.get_chatgpt_plan_type_raw().as_deref(),
Some("self_serve_business_usage_based")
);
assert_eq!(self_serve_business.is_workspace_account(), true);
let enterprise_cbp_jwt = fake_jwt(serde_json::json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "enterprise_cbp_usage_based"
}
}));
let enterprise_cbp = parse_chatgpt_jwt_claims(&enterprise_cbp_jwt).expect("should parse");
assert_eq!(
enterprise_cbp.get_chatgpt_plan_type().as_deref(),
Some("Enterprise CBP Usage Based")
);
assert_eq!(
enterprise_cbp.get_chatgpt_plan_type_raw().as_deref(),
Some("enterprise_cbp_usage_based")
);
assert_eq!(enterprise_cbp.is_workspace_account(), true);
}
#[test]
fn id_token_info_handles_missing_fields() {
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
+57
View File
@@ -13,9 +13,66 @@ pub enum PlanType {
Plus,
Pro,
Team,
#[serde(rename = "self_serve_business_usage_based")]
#[ts(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
#[serde(rename = "enterprise_cbp_usage_based")]
#[ts(rename = "enterprise_cbp_usage_based")]
EnterpriseCbpUsageBased,
Enterprise,
Edu,
#[serde(other)]
Unknown,
}
impl PlanType {
pub fn is_team_like(self) -> bool {
matches!(self, Self::Team | Self::SelfServeBusinessUsageBased)
}
pub fn is_business_like(self) -> bool {
matches!(self, Self::Business | Self::EnterpriseCbpUsageBased)
}
}
#[cfg(test)]
mod tests {
use super::PlanType;
use pretty_assertions::assert_eq;
#[test]
fn usage_based_plan_types_use_expected_wire_names() {
assert_eq!(
serde_json::to_string(&PlanType::SelfServeBusinessUsageBased)
.expect("self-serve business usage based should serialize"),
"\"self_serve_business_usage_based\""
);
assert_eq!(
serde_json::to_string(&PlanType::EnterpriseCbpUsageBased)
.expect("enterprise cbp usage based should serialize"),
"\"enterprise_cbp_usage_based\""
);
assert_eq!(
serde_json::from_str::<PlanType>("\"self_serve_business_usage_based\"")
.expect("self-serve business usage based should deserialize"),
PlanType::SelfServeBusinessUsageBased
);
assert_eq!(
serde_json::from_str::<PlanType>("\"enterprise_cbp_usage_based\"")
.expect("enterprise cbp usage based should deserialize"),
PlanType::EnterpriseCbpUsageBased
);
}
#[test]
fn plan_family_helpers_group_usage_based_variants_with_existing_plans() {
assert_eq!(PlanType::Team.is_team_like(), true);
assert_eq!(PlanType::SelfServeBusinessUsageBased.is_team_like(), true);
assert_eq!(PlanType::Business.is_team_like(), false);
assert_eq!(PlanType::Business.is_business_like(), true);
assert_eq!(PlanType::EnterpriseCbpUsageBased.is_business_like(), true);
assert_eq!(PlanType::Team.is_business_like(), false);
}
}
+58 -3
View File
@@ -94,14 +94,23 @@ pub(crate) fn compose_account_display(
CoreAuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
let email = auth.get_account_email();
let plan = plan
.map(|plan_type| title_case(format!("{plan_type:?}").as_str()))
.or_else(|| Some("Unknown".to_string()));
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 {
@@ -187,3 +196,49 @@ pub(crate) fn title_case(s: &str) -> String {
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"
));
}
}
+7 -5
View File
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
// Leave small chance for a random tooltip to be shown.
if rng.random_ratio(8, 10) {
match plan {
Some(PlanType::Plus)
| Some(PlanType::Business)
| Some(PlanType::Team)
| Some(PlanType::Enterprise)
| Some(PlanType::Pro) => {
Some(plan_type)
if matches!(
plan_type,
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
) || plan_type.is_team_like()
|| plan_type.is_business_like() =>
{
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
}
Some(PlanType::Go) | Some(PlanType::Free) => {
@@ -1,5 +1,6 @@
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
use crate::status::plan_type_display_name;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
@@ -228,7 +229,7 @@ impl AppServerSession {
Some(TelemetryAuthMode::Chatgpt),
Some(StatusAccountDisplay::ChatGpt {
email: Some(email),
plan: Some(title_case(format!("{plan_type:?}").as_str())),
plan: Some(plan_type_display_name(plan_type)),
}),
Some(plan_type),
feedback_audience,
@@ -733,19 +734,6 @@ impl AppServerSession {
}
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return String::new();
};
let rest = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
pub(crate) fn status_account_display_from_auth_mode(
auth_mode: Option<AuthMode>,
plan_type: Option<codex_protocol::account::PlanType>,
@@ -755,7 +743,7 @@ pub(crate) fn status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => {
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())),
plan: plan_type.map(plan_type_display_name),
})
}
None => None,
@@ -1264,4 +1252,31 @@ mod tests {
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
}
#[test]
fn status_account_display_from_auth_mode_uses_remapped_plan_labels() {
let business = status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt),
Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased),
);
assert!(matches!(
business,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Enterprise"
));
let team = status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt),
Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased),
);
assert!(matches!(
team,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Business"
));
}
}
@@ -41,7 +41,7 @@ pub(crate) fn load_local_chatgpt_auth(
let chatgpt_plan_type = tokens
.id_token
.get_chatgpt_plan_type()
.get_chatgpt_plan_type_raw()
.map(|plan_type| plan_type.to_ascii_lowercase());
Ok(LocalChatgptAuth {
@@ -92,9 +92,9 @@ mod tests {
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &Path) {
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
fn write_chatgpt_auth(codex_home: &Path, plan_type: &str) {
let id_token = fake_jwt("user@example.com", "workspace-1", plan_type);
let access_token = fake_jwt("user@example.com", "workspace-1", plan_type);
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
@@ -114,7 +114,7 @@ mod tests {
#[test]
fn loads_local_chatgpt_auth_from_managed_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
write_chatgpt_auth(codex_home.path(), "business");
let auth = load_local_chatgpt_auth(
codex_home.path(),
@@ -162,7 +162,7 @@ mod tests {
#[test]
fn prefers_managed_auth_over_external_ephemeral_tokens() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
write_chatgpt_auth(codex_home.path(), "business");
login_with_chatgpt_auth_tokens(
codex_home.path(),
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
@@ -181,4 +181,22 @@ mod tests {
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
}
#[test]
fn preserves_usage_based_plan_type_wire_name() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based");
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("chatgpt auth should load");
assert_eq!(
auth.chatgpt_plan_type.as_deref(),
Some("self_serve_business_usage_based")
);
}
}
@@ -5,6 +5,7 @@ use chrono::DateTime;
use chrono::Local;
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;
@@ -86,6 +87,16 @@ pub(crate) fn compose_account_display(
account_display.cloned()
}
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 {
@@ -158,3 +169,42 @@ pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<
format!("{time} on {}", dt.format("%-d %b"))
}
}
fn title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return String::new();
};
let rest = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
#[cfg(test)]
mod tests {
use super::*;
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);
}
}
}
@@ -18,6 +18,7 @@ pub(crate) use card::new_status_output;
pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact;
pub(crate) use helpers::plan_type_display_name;
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
pub(crate) use rate_limits::RateLimitWindowDisplay;
#[cfg(test)]
+7 -5
View File
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
// Leave small chance for a random tooltip to be shown.
if rng.random_ratio(8, 10) {
match plan {
Some(PlanType::Plus)
| Some(PlanType::Business)
| Some(PlanType::Team)
| Some(PlanType::Enterprise)
| Some(PlanType::Pro) => {
Some(plan_type)
if matches!(
plan_type,
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
) || plan_type.is_team_like()
|| plan_type.is_business_like() =>
{
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
}
Some(PlanType::Go) | Some(PlanType::Free) => {
@@ -17,6 +17,7 @@ from .v2_all import ContextCompactedNotification
from .v2_all import DeprecationNoticeNotification
from .v2_all import ErrorNotification
from .v2_all import FileChangeOutputDeltaNotification
from .v2_all import FsChangedNotification
from .v2_all import FuzzyFileSearchSessionCompletedNotification
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
from .v2_all import HookCompletedNotification
@@ -26,6 +27,7 @@ from .v2_all import ItemGuardianApprovalReviewCompletedNotification
from .v2_all import ItemGuardianApprovalReviewStartedNotification
from .v2_all import ItemStartedNotification
from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpServerStatusUpdatedNotification
from .v2_all import McpToolCallProgressNotification
from .v2_all import ModelReroutedNotification
from .v2_all import PlanDeltaNotification
@@ -43,6 +45,7 @@ from .v2_all import ThreadRealtimeErrorNotification
from .v2_all import ThreadRealtimeItemAddedNotification
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
from .v2_all import ThreadRealtimeStartedNotification
from .v2_all import ThreadRealtimeTranscriptUpdatedNotification
from .v2_all import ThreadStartedNotification
from .v2_all import ThreadStatusChangedNotification
from .v2_all import ThreadTokenUsageUpdatedNotification
@@ -63,6 +66,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"configWarning": ConfigWarningNotification,
"deprecationNotice": DeprecationNoticeNotification,
"error": ErrorNotification,
"fs/changed": FsChangedNotification,
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
"hook/completed": HookCompletedNotification,
@@ -81,6 +85,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
"item/started": ItemStartedNotification,
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
@@ -93,6 +98,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
"thread/realtime/started": ThreadRealtimeStartedNotification,
"thread/realtime/transcriptUpdated": ThreadRealtimeTranscriptUpdatedNotification,
"thread/started": ThreadStartedNotification,
"thread/status/changed": ThreadStatusChangedNotification,
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,
File diff suppressed because it is too large Load Diff