Add support for Academic Researchers plans (#55387)

## What changed

- Recognize `sci` and `sci_plus` in authentication, account responses, rate limits, and generated client schemas.
- Treat both plans as workspace accounts eligible for cloud configuration, and use business analytics routes.
- Display “Academic Researchers” and “Academic Researchers Plus” in the TUI and direct users to their admin when usage limits are reached.

## Testing

Add coverage for plan parsing, workspace classification, account and rate-limit responses, cloud configuration eligibility, analytics routing, TUI labels, and usage-limit guidance. Exercise installed-app refresh and listing with `sci_plus` authentication.

GitOrigin-RevId: b51c5df03029b8bc249d44f11b353a7755e7dd04
This commit is contained in:
Eric Traut
2026-10-19 21:43:19 +00:00
committed by copyberry
parent b8f880bc38
commit fea1d7f7d1
26 changed files with 126 additions and 4 deletions
@@ -3386,6 +3386,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -16447,6 +16447,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -12439,6 +12439,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -41,6 +41,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -81,6 +81,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -41,6 +41,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"unknown"
],
"type": "string"
@@ -96,6 +96,8 @@
"edu",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
"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" | "promax" | "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";
export type PlanType = "free" | "go" | "plus" | "pro" | "prolite" | "promax" | "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" | "sci" | "sci_plus" | "unknown";
@@ -3424,6 +3424,8 @@ async fn get_account_with_chatgpt() -> Result<()> {
#[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")]
#[test_case("sci", AccountPlanType::Sci; "sci")]
#[test_case("sci_plus", AccountPlanType::SciPlus; "sci_plus")]
#[tokio::test]
async fn get_account_with_chatgpt_plan_variants_returns_plan_type(
plan_type: &str,
@@ -18,6 +18,8 @@ use axum::extract::State;
use axum::routing::get;
use codex_app_server_protocol::AppsInstalledParams;
use codex_app_server_protocol::AppsInstalledResponse;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::InstalledApp;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
@@ -109,6 +111,15 @@ async fn installed_apps_threadless_refresh_updates_existing_thread_tools() -> Re
fixture.set_tools(vec![connector_tool("alpha", "Alpha")?]);
let responses_server = responses::start_mock_server().await;
let codex_home = configured_codex_home(fixture.base_url())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("sci_plus"),
AuthCredentialsStoreMode::File,
)?;
MockResponsesConfig::new(&responses_server.uri())
.with_root_config(&format!(
"chatgpt_base_url = {:?}\nmcp_oauth_credentials_store = \"file\"",
@@ -168,6 +179,17 @@ async fn installed_apps_threadless_refresh_updates_existing_thread_tools() -> Re
}
assert_eq!(fixture.list_tools_calls(), index + 1);
}
let request_id = app_server
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let _: AppsListResponse =
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(fixture.directory_calls(), 2);
Ok(())
}
@@ -559,6 +581,10 @@ impl InstalledAppsFixture {
StreamableHttpServerConfig::default(),
);
let router = Router::new()
.route(
"/api/codex/config/bundle",
get(|| async { Json(json!({})) }),
)
.route("/connectors/directory/list", get(list_directory_apps))
.route(
"/connectors/directory/list_workspace",
@@ -101,6 +101,8 @@ async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> {
#[test_case("promax", AccountPlanType::ProMax; "pro_max")]
#[test_case("edu_plus", AccountPlanType::EduPlus; "edu_plus")]
#[test_case("edu_pro", AccountPlanType::EduPro; "edu_pro")]
#[test_case("sci", AccountPlanType::Sci; "sci")]
#[test_case("sci_plus", AccountPlanType::SciPlus; "sci_plus")]
#[tokio::test]
async fn get_account_rate_limits_returns_snapshot(
plan_type: &str,
+2
View File
@@ -803,6 +803,8 @@ impl Client {
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::Sci => AccountPlanType::Sci,
crate::types::PlanType::SciPlus => AccountPlanType::SciPlus,
crate::types::PlanType::Guest
| crate::types::PlanType::FreeWorkspace
| crate::types::PlanType::Quorum
+4 -1
View File
@@ -56,7 +56,10 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
auth.uses_codex_backend()
&& (plan_type.is_business_like()
|| plan_type.is_education_like()
|| plan_type == PlanType::Enterprise)
|| matches!(
plan_type,
PlanType::Enterprise | PlanType::Sci | PlanType::SciPlus
))
}
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
@@ -585,6 +585,8 @@ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() {
"education",
"edu_plus",
"edu_pro",
"sci",
"sci_plus",
] {
let bundle = test_bundle();
let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
@@ -140,6 +140,10 @@ pub enum PlanType {
EduPlus,
#[serde(rename = "edu_pro")]
EduPro,
#[serde(rename = "sci")]
Sci,
#[serde(rename = "sci_plus")]
SciPlus,
#[serde(rename = "unknown", other)]
Unknown,
}
+11 -1
View File
@@ -40,6 +40,10 @@ pub enum PlanType {
#[serde(rename = "edu_pro")]
#[ts(rename = "edu_pro")]
EduPro,
Sci,
#[serde(rename = "sci_plus")]
#[ts(rename = "sci_plus")]
SciPlus,
#[serde(other)]
Unknown,
}
@@ -84,7 +88,7 @@ impl PlanType {
self.is_team_like()
|| self.is_business_like()
|| self.is_education_like()
|| self == Self::Enterprise
|| matches!(self, Self::Enterprise | Self::Sci | Self::SciPlus)
}
}
@@ -117,6 +121,8 @@ impl From<KnownPlan> for PlanType {
KnownPlan::Edu => Self::Edu,
KnownPlan::EduPlus => Self::EduPlus,
KnownPlan::EduPro => Self::EduPro,
KnownPlan::Sci => Self::Sci,
KnownPlan::SciPlus => Self::SciPlus,
}
}
}
@@ -228,6 +234,10 @@ mod tests {
assert_eq!(PlanType::Edu.is_workspace_account(), true);
assert_eq!(PlanType::EduPlus.is_workspace_account(), true);
assert_eq!(PlanType::EduPro.is_workspace_account(), true);
for plan in [KnownPlan::Sci, KnownPlan::SciPlus] {
assert!(plan.is_workspace_account());
assert!(PlanType::from(plan).is_workspace_account());
}
assert_eq!(PlanType::Pro.is_workspace_account(), false);
}
+13
View File
@@ -92,6 +92,8 @@ impl PlanType {
"education" | "edu" => Self::Known(KnownPlan::Edu),
"edu_plus" => Self::Known(KnownPlan::EduPlus),
"edu_pro" => Self::Known(KnownPlan::EduPro),
"sci" => Self::Known(KnownPlan::Sci),
"sci_plus" => Self::Known(KnownPlan::SciPlus),
_ => Self::Unknown(raw.to_string()),
}
}
@@ -125,6 +127,9 @@ pub enum KnownPlan {
EduPlus,
#[serde(rename = "edu_pro")]
EduPro,
Sci,
#[serde(rename = "sci_plus")]
SciPlus,
}
impl KnownPlan {
@@ -147,6 +152,8 @@ impl KnownPlan {
Self::Edu => "Edu",
Self::EduPlus => "Edu Plus",
Self::EduPro => "Edu Pro",
Self::Sci => "Academic Researchers",
Self::SciPlus => "Academic Researchers Plus",
}
}
@@ -169,6 +176,8 @@ impl KnownPlan {
Self::Edu => "edu",
Self::EduPlus => "edu_plus",
Self::EduPro => "edu_pro",
Self::Sci => "sci",
Self::SciPlus => "sci_plus",
}
}
@@ -186,6 +195,8 @@ impl KnownPlan {
| Self::Edu
| Self::EduPlus
| Self::EduPro
| Self::Sci
| Self::SciPlus
)
}
}
@@ -240,6 +251,8 @@ mod tests {
("promax", KnownPlan::ProMax),
("edu_plus", KnownPlan::EduPlus),
("edu_pro", KnownPlan::EduPro),
("sci", KnownPlan::Sci),
("sci_plus", KnownPlan::SciPlus),
] {
let expected = PlanType::Known(known);
assert_eq!(PlanType::from_raw_value(raw), expected);
+3 -1
View File
@@ -784,7 +784,9 @@ impl std::fmt::Display for UsageLimitReachedError {
| KnownPlan::Business
| KnownPlan::Ent26
| KnownPlan::EnterpriseCbpAutomation
| KnownPlan::EnterpriseCbpUsageBased,
| KnownPlan::EnterpriseCbpUsageBased
| KnownPlan::Sci
| KnownPlan::SciPlus,
)) => {
format!(
"You’ve hit your usage limit. To get more access now, send a request to your admin{}",
@@ -332,6 +332,13 @@ async fn analytics_matches_app_account_routes_and_parameters() {
"usage/daily-workspace-user-token-usage-breakdown",
vec![("group_by", "day")],
),
(
"sci_plus",
Report::Usage,
Grouping::Model,
"usage/daily-workspace-user-token-usage-breakdown",
vec![("group_by", "day")],
),
(
"team",
Report::Credits,
+1
View File
@@ -15,6 +15,7 @@ impl From<Option<PlanType>> for AccountKind {
fn from(plan: Option<PlanType>) -> Self {
match plan {
None | Some(PlanType::Unknown) => Self::Unknown,
Some(PlanType::Sci | PlanType::SciPlus) => Self::Business,
Some(plan) if plan.is_team_like() => Self::Business,
Some(plan) if plan.is_workspace_account() => Self::Enterprise,
Some(_) => Self::Consumer,
@@ -2008,6 +2008,29 @@ async fn workspace_owner_limit_states_render_state_specific_messages() {
);
}
#[tokio::test]
async fn academic_researchers_usage_limit_renders_admin_guidance() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let error = codex_protocol::error::UsageLimitReachedError {
plan_type: Some(codex_protocol::auth::PlanType::Known(
codex_protocol::auth::KnownPlan::Sci,
)),
resets_at: None,
limit_window_minutes: None,
rate_limits: None,
promo_message: None,
rate_limit_reached_type: None,
};
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, error.to_string());
insta::assert_snapshot!(
drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>(),
@"■ You’ve hit your usage limit. To get more access now, send a request to your admin or try again later."
);
}
#[tokio::test]
async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+6
View File
@@ -229,6 +229,12 @@ mod tests {
.map(plan_type_display_name)
.join("\n")
);
insta::assert_snapshot!(
[PlanType::Sci, PlanType::SciPlus]
.map(plan_type_display_name)
.join("\n"),
@"Academic Researchers\nAcademic Researchers Plus"
);
}
#[test]
+3
View File
@@ -1,6 +1,7 @@
//! Subscription labels for TUI surfaces, preserving status-specific plan detail.
use codex_protocol::account::PlanType;
use codex_protocol::auth::KnownPlan;
pub(crate) enum SubscriptionDisplay {
Status,
@@ -31,6 +32,8 @@ impl SubscriptionDisplay {
(PlanType::EduPlus, Self::Status) => "Edu Plus",
(PlanType::EduPro, Self::Status) => "Edu Pro",
(PlanType::Edu | PlanType::EduPlus | PlanType::EduPro, Self::Analytics) => "Education",
(PlanType::Sci, _) => KnownPlan::Sci.display_name(),
(PlanType::SciPlus, _) => KnownPlan::SciPlus.display_name(),
(PlanType::Unknown, Self::Status) => "Unknown",
(PlanType::Unknown, Self::Analytics) => "Account",
}
@@ -3147,6 +3147,8 @@ class PlanType(str, Enum):
edu = "edu"
edu_plus = "edu_plus"
edu_pro = "edu_pro"
sci = "sci"
sci_plus = "sci_plus"
unknown = "unknown"
@classmethod