mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
8ae72a9314
commit
657bd889ae
@@ -2984,6 +2984,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+2
@@ -14729,6 +14729,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+2
@@ -10959,6 +10959,8 @@
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"edu_plus",
|
||||
"edu_pro",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
+7
@@ -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
|
||||
Reference in New Issue
Block a user