Support Edu Plus and Edu Pro account plans (#39316)

## What changed

- Recognize `edu_plus` and `edu_pro` as distinct education workspace plans across authentication, backend rate-limit mapping, and app-server account schemas.
- Include both plans in cloud configuration eligibility and use the education usage-limit behavior.
- Display the plans as `Edu Plus` and `Edu Pro` in the TUI status view.

## Testing

- Cover parsing, workspace classification, account and rate-limit responses, cloud configuration eligibility, usage-limit messages, and TUI display names for the new variants.

GitOrigin-RevId: bf8add780ae03a3c6d10c4f4afa0da262c37928e
This commit is contained in:
joeflorencio-openai
2026-08-18 23:47:00 +00:00
committed by copyberry
parent 8ae72a9314
commit 657bd889ae
22 changed files with 129 additions and 35 deletions
@@ -2984,6 +2984,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -14729,6 +14729,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -10959,6 +10959,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -38,6 +38,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -71,6 +71,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -38,6 +38,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
@@ -84,6 +84,8 @@
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown"
],
"type": "string"
+1 -1
View File
@@ -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" | "prolite" | "team" | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" | "ent26" | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
export type PlanType = "free" | "go" | "plus" | "pro" | "prolite" | "team" | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" | "ent26" | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "edu_plus" | "edu_pro" | "unknown";
+10 -3
View File
@@ -58,6 +58,7 @@ use serial_test::serial;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use url::Url;
use wiremock::Mock;
@@ -2677,8 +2678,14 @@ async fn get_account_with_chatgpt() -> Result<()> {
Ok(())
}
#[test_case("self_serve_business_prolite", AccountPlanType::SelfServeBusinessProLite; "business_prolite")]
#[test_case("edu_plus", AccountPlanType::EduPlus; "edu_plus")]
#[test_case("edu_pro", AccountPlanType::EduPro; "edu_pro")]
#[tokio::test]
async fn get_account_with_business_prolite_returns_plan_type() -> Result<()> {
async fn get_account_with_chatgpt_plan_variants_returns_plan_type(
plan_type: &str,
expected_plan: AccountPlanType,
) -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
@@ -2691,7 +2698,7 @@ async fn get_account_with_business_prolite_returns_plan_type() -> Result<()> {
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("self_serve_business_prolite"),
.plan_type(plan_type),
AuthCredentialsStoreMode::File,
)?;
@@ -2715,7 +2722,7 @@ async fn get_account_with_business_prolite_returns_plan_type() -> Result<()> {
GetAccountResponse {
account: Some(Account::Chatgpt {
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::SelfServeBusinessProLite,
plan_type: expected_plan,
}),
requires_openai_auth: true,
}
@@ -24,6 +24,7 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
@@ -95,8 +96,14 @@ async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> {
Ok(())
}
#[test_case("enterprise_cbp_automation", AccountPlanType::EnterpriseCbpAutomation; "enterprise_automation")]
#[test_case("edu_plus", AccountPlanType::EduPlus; "edu_plus")]
#[test_case("edu_pro", AccountPlanType::EduPro; "edu_pro")]
#[tokio::test]
async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
async fn get_account_rate_limits_returns_snapshot(
plan_type: &str,
expected_plan: AccountPlanType,
) -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(
codex_home.path(),
@@ -127,7 +134,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "enterprise_cbp_automation",
"plan_type": plan_type,
"rate_limit": {
"allowed": true,
"limit_reached": false,
@@ -251,7 +258,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: secondary_reset_timestamp,
}),
spend_control_reached: Some(false),
plan_type: Some(AccountPlanType::EnterpriseCbpAutomation),
plan_type: Some(expected_plan),
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
},
rate_limits_by_limit_id: Some(
@@ -279,7 +286,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: secondary_reset_timestamp,
}),
spend_control_reached: Some(false),
plan_type: Some(AccountPlanType::EnterpriseCbpAutomation),
plan_type: Some(expected_plan),
rate_limit_reached_type: Some(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
),
@@ -299,7 +306,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
credits: None,
individual_limit: None,
spend_control_reached: None,
plan_type: Some(AccountPlanType::EnterpriseCbpAutomation),
plan_type: Some(expected_plan),
rate_limit_reached_type: None,
},
),
+2
View File
@@ -706,6 +706,8 @@ impl Client {
}
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
crate::types::PlanType::EduPlus => AccountPlanType::EduPlus,
crate::types::PlanType::EduPro => AccountPlanType::EduPro,
crate::types::PlanType::Guest
| crate::types::PlanType::FreeWorkspace
| crate::types::PlanType::Quorum
+2 -1
View File
@@ -51,7 +51,8 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
};
auth.uses_codex_backend()
&& (plan_type.is_business_like()
|| matches!(plan_type, PlanType::Enterprise | PlanType::Edu))
|| plan_type.is_education_like()
|| plan_type == PlanType::Enterprise)
}
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
@@ -443,6 +443,8 @@ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() {
"hc",
"edu",
"education",
"edu_plus",
"edu_pro",
] {
let bundle = test_bundle();
let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
@@ -134,6 +134,10 @@ pub enum PlanType {
Enterprise,
#[serde(rename = "edu")]
Edu,
#[serde(rename = "edu_plus")]
EduPlus,
#[serde(rename = "edu_pro")]
EduPro,
#[serde(rename = "unknown", other)]
Unknown,
}
+19 -12
View File
@@ -33,6 +33,12 @@ pub enum PlanType {
EnterpriseCbpUsageBased,
Enterprise,
Edu,
#[serde(rename = "edu_plus")]
#[ts(rename = "edu_plus")]
EduPlus,
#[serde(rename = "edu_pro")]
#[ts(rename = "edu_pro")]
EduPro,
#[serde(other)]
Unknown,
}
@@ -68,19 +74,16 @@ impl PlanType {
)
}
/// Groups education plans by workspace capabilities without losing their SKU identity.
pub fn is_education_like(self) -> bool {
matches!(self, Self::Edu | Self::EduPlus | Self::EduPro)
}
pub fn is_workspace_account(self) -> bool {
matches!(
self,
Self::Team
| Self::SelfServeBusinessProLite
| Self::SelfServeBusinessUsageBased
| Self::Business
| Self::Ent26
| Self::EnterpriseCbpAutomation
| Self::EnterpriseCbpUsageBased
| Self::Enterprise
| Self::Edu
)
self.is_team_like()
|| self.is_business_like()
|| self.is_education_like()
|| self == Self::Enterprise
}
}
@@ -110,6 +113,8 @@ impl From<KnownPlan> for PlanType {
KnownPlan::EnterpriseCbpUsageBased => Self::EnterpriseCbpUsageBased,
KnownPlan::Enterprise => Self::Enterprise,
KnownPlan::Edu => Self::Edu,
KnownPlan::EduPlus => Self::EduPlus,
KnownPlan::EduPro => Self::EduPro,
}
}
}
@@ -219,6 +224,8 @@ mod tests {
);
assert_eq!(PlanType::Enterprise.is_workspace_account(), true);
assert_eq!(PlanType::Edu.is_workspace_account(), true);
assert_eq!(PlanType::EduPlus.is_workspace_account(), true);
assert_eq!(PlanType::EduPro.is_workspace_account(), true);
assert_eq!(PlanType::Pro.is_workspace_account(), false);
}
+25
View File
@@ -81,6 +81,8 @@ impl PlanType {
"enterprise_cbp_usage_based" => Self::Known(KnownPlan::EnterpriseCbpUsageBased),
"enterprise" | "hc" => Self::Known(KnownPlan::Enterprise),
"education" | "edu" => Self::Known(KnownPlan::Edu),
"edu_plus" => Self::Known(KnownPlan::EduPlus),
"edu_pro" => Self::Known(KnownPlan::EduPro),
_ => Self::Unknown(raw.to_string()),
}
}
@@ -109,6 +111,10 @@ pub enum KnownPlan {
Enterprise,
#[serde(alias = "education")]
Edu,
#[serde(rename = "edu_plus")]
EduPlus,
#[serde(rename = "edu_pro")]
EduPro,
}
impl KnownPlan {
@@ -128,6 +134,8 @@ impl KnownPlan {
Self::EnterpriseCbpUsageBased => "Enterprise CBP Usage Based",
Self::Enterprise => "Enterprise",
Self::Edu => "Edu",
Self::EduPlus => "Edu Plus",
Self::EduPro => "Edu Pro",
}
}
@@ -147,6 +155,8 @@ impl KnownPlan {
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
Self::Enterprise => "enterprise",
Self::Edu => "edu",
Self::EduPlus => "edu_plus",
Self::EduPro => "edu_pro",
}
}
@@ -162,6 +172,8 @@ impl KnownPlan {
| Self::EnterpriseCbpUsageBased
| Self::Enterprise
| Self::Edu
| Self::EduPlus
| Self::EduPro
)
}
}
@@ -212,5 +224,18 @@ mod tests {
.expect("enterprise cbp automation should deserialize"),
PlanType::Known(KnownPlan::EnterpriseCbpAutomation)
);
for (raw, known) in [
("edu_plus", KnownPlan::EduPlus),
("edu_pro", KnownPlan::EduPro),
] {
let expected = PlanType::Known(known);
assert_eq!(PlanType::from_raw_value(raw), expected);
assert_eq!(
serde_json::from_value::<PlanType>(serde_json::json!(raw))
.expect("plan should deserialize"),
expected
);
assert_eq!(known.raw_value(), raw);
}
}
}
+3 -2
View File
@@ -719,8 +719,9 @@ impl std::fmt::Display for UsageLimitReachedError {
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
retry_suffix_after_or(self.resets_at.as_ref())
),
Some(PlanType::Known(KnownPlan::Enterprise))
| Some(PlanType::Known(KnownPlan::Edu)) => format!(
Some(PlanType::Known(
KnownPlan::Enterprise | KnownPlan::Edu | KnownPlan::EduPlus | KnownPlan::EduPro,
)) => format!(
"You've hit your usage limit.{}",
retry_suffix(self.resets_at.as_ref())
),
+18 -11
View File
@@ -406,17 +406,24 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
#[test]
fn usage_limit_reached_error_formats_default_for_other_plans() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::Enterprise)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. Try again later."
);
for plan in [
KnownPlan::Enterprise,
KnownPlan::Edu,
KnownPlan::EduPlus,
KnownPlan::EduPro,
] {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(plan)),
resets_at: None,
rate_limits: Some(Box::new(rate_limit_snapshot())),
promo_message: None,
rate_limit_reached_type: None,
};
assert_eq!(
err.to_string(),
"You've hit your usage limit. Try again later."
);
}
}
#[test]
+10
View File
@@ -105,6 +105,10 @@ pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
"Enterprise".to_string()
} else if plan_type == PlanType::ProLite {
"Pro Lite".to_string()
} else if plan_type == PlanType::EduPlus {
"Edu Plus".to_string()
} else if plan_type == PlanType::EduPro {
"Edu Pro".to_string()
} else {
title_case(format!("{plan_type:?}").as_str())
}
@@ -234,6 +238,12 @@ mod tests {
for (plan_type, expected) in cases {
assert_eq!(plan_type_display_name(plan_type), expected);
}
insta::assert_snapshot!(
"education_plan_display_names",
[PlanType::Edu, PlanType::EduPlus, PlanType::EduPro]
.map(plan_type_display_name)
.join("\n")
);
}
#[test]
@@ -0,0 +1,7 @@
---
source: tui/src/status/helpers.rs
expression: "[PlanType::Edu, PlanType::EduPlus,\nPlanType::EduPro].map(plan_type_display_name).join(\"\\n\")"
---
Edu
Edu Plus
Edu Pro