Support self-serve Business ProLite accounts (#35785)

## What changed

- Recognize `self_serve_business_prolite` across authentication, account and rate-limit APIs, generated schemas, workspace classification, status display, cloud configuration gating, and usage-limit messaging.
- Keep the Python SDK's `PlanType` enum compatible with non-empty string values introduced by newer Codex runtimes while preserving its known constants.

## Testing

- Cover token parsing, account reads and notifications, backend rate-limit mapping, workspace behavior, error formatting, and Python SDK response coercion for the new plan value.

GitOrigin-RevId: 70bc17a7c4ba4028cb10e4333cc1f2ac64da361f
This commit is contained in:
Arun Eswara
2026-07-28 15:50:45 +00:00
committed by copyberry
parent bb1af235ea
commit cf7e9cfe6a
25 changed files with 258 additions and 29 deletions
@@ -2913,6 +2913,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -14021,6 +14021,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -10363,6 +10363,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -30,6 +30,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -63,6 +63,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -30,6 +30,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
@@ -76,6 +76,7 @@
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
+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_usage_based" | "business" | "ent26" | "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_usage_based" | "enterprise" | "edu" | "unknown";
+1 -1
View File
@@ -2076,7 +2076,7 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### Authentication modes
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`.
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`. Self-serve Business ProLite accounts use the `self_serve_business_prolite` plan type.
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
+6 -6
View File
@@ -839,7 +839,7 @@ mod tests {
credits: None,
individual_limit: None,
spend_control_reached: None,
plan_type: Some(PlanType::Plus),
plan_type: Some(PlanType::SelfServeBusinessProLite),
rate_limit_reached_type: None,
},
});
@@ -860,7 +860,7 @@ mod tests {
"credits": null,
"individualLimit": null,
"spendControlReached": null,
"planType": "plus",
"planType": "self_serve_business_prolite",
"rateLimitReachedType": null
}
},
@@ -874,16 +874,16 @@ mod tests {
#[test]
fn verify_account_updated_notification_serialization() {
let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification {
auth_mode: Some(AuthMode::ApiKey),
plan_type: None,
auth_mode: Some(AuthMode::Chatgpt),
plan_type: Some(PlanType::SelfServeBusinessProLite),
});
assert_eq!(
json!({
"method": "account/updated",
"params": {
"authMode": "apikey",
"planType": null
"authMode": "chatgpt",
"planType": "self_serve_business_prolite"
},
}),
serde_json::to_value(notification)
@@ -2595,6 +2595,52 @@ async fn get_account_with_chatgpt() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn get_account_with_business_prolite_returns_plan_type() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("self_serve_business_prolite"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let received: GetAccountResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
received,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: Some("user@example.com".to_string()),
plan_type: AccountPlanType::SelfServeBusinessProLite,
}),
requires_openai_auth: true,
}
);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_without_email() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -127,7 +127,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "pro",
"plan_type": "self_serve_business_prolite",
"rate_limit": {
"allowed": true,
"limit_reached": false,
@@ -251,7 +251,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: secondary_reset_timestamp,
}),
spend_control_reached: Some(false),
plan_type: Some(AccountPlanType::Pro),
plan_type: Some(AccountPlanType::SelfServeBusinessProLite),
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
},
rate_limits_by_limit_id: Some(
@@ -279,7 +279,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: secondary_reset_timestamp,
}),
spend_control_reached: Some(false),
plan_type: Some(AccountPlanType::Pro),
plan_type: Some(AccountPlanType::SelfServeBusinessProLite),
rate_limit_reached_type: Some(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
),
@@ -299,7 +299,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
credits: None,
individual_limit: None,
spend_control_reached: None,
plan_type: Some(AccountPlanType::Pro),
plan_type: Some(AccountPlanType::SelfServeBusinessProLite),
rate_limit_reached_type: None,
},
),
+11 -1
View File
@@ -685,6 +685,9 @@ impl Client {
crate::types::PlanType::Pro => AccountPlanType::Pro,
crate::types::PlanType::ProLite => AccountPlanType::ProLite,
crate::types::PlanType::Team => AccountPlanType::Team,
crate::types::PlanType::SelfServeBusinessProLite => {
AccountPlanType::SelfServeBusinessProLite
}
crate::types::PlanType::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
}
@@ -732,7 +735,14 @@ mod tests {
use wiremock::matchers::path;
#[test]
fn map_plan_type_supports_usage_based_business_variants() {
fn map_plan_type_supports_business_variants() {
let business_prolite =
serde_json::from_str::<crate::types::PlanType>("\"self_serve_business_prolite\"")
.expect("business ProLite should deserialize");
assert_eq!(
Client::map_plan_type(business_prolite),
AccountPlanType::SelfServeBusinessProLite
);
assert_eq!(
Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased),
AccountPlanType::SelfServeBusinessUsageBased
+16 -11
View File
@@ -476,18 +476,23 @@ async fn get_bundle_allows_agent_identity_business_plan() {
}
#[tokio::test]
async fn get_bundle_skips_team_like_usage_based_plan() {
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
let codex_home = tempdir().expect("tempdir");
let service = CloudConfigBundleService::new(
auth_manager_with_plan("self_serve_business_usage_based").await,
fetcher.clone(),
codex_home.path().to_path_buf(),
CLOUD_CONFIG_BUNDLE_TIMEOUT,
);
async fn get_bundle_skips_team_like_business_plans() {
for plan_type in [
"self_serve_business_prolite",
"self_serve_business_usage_based",
] {
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
let codex_home = tempdir().expect("tempdir");
let service = CloudConfigBundleService::new(
auth_manager_with_plan(plan_type).await,
fetcher.clone(),
codex_home.path().to_path_buf(),
CLOUD_CONFIG_BUNDLE_TIMEOUT,
);
assert_eq!(service.load_startup_bundle().await, Ok(None));
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
assert_eq!(service.load_startup_bundle().await, Ok(None));
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
@@ -112,6 +112,8 @@ pub enum PlanType {
FreeWorkspace,
#[serde(rename = "team")]
Team,
#[serde(rename = "self_serve_business_prolite")]
SelfServeBusinessProLite,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
#[serde(rename = "business")]
+21
View File
@@ -122,6 +122,27 @@ fn id_token_info_parses_usage_based_business_plans() {
assert_eq!(enterprise_cbp.is_workspace_account(), true);
}
#[test]
fn id_token_info_parses_self_serve_business_prolite_plan() {
let jwt = fake_jwt(serde_json::json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "self_serve_business_prolite"
}
}));
let info = parse_chatgpt_jwt_claims(&jwt).expect("should parse");
assert_eq!(
info.get_chatgpt_plan_type().as_deref(),
Some("Self Serve Business ProLite")
);
assert_eq!(
info.get_chatgpt_plan_type_raw().as_deref(),
Some("self_serve_business_prolite")
);
assert_eq!(info.is_workspace_account(), true);
}
#[test]
fn id_token_info_handles_missing_fields() {
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
+31 -4
View File
@@ -17,6 +17,9 @@ pub enum PlanType {
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_prolite")]
#[ts(rename = "self_serve_business_prolite")]
SelfServeBusinessProLite,
#[serde(rename = "self_serve_business_usage_based")]
#[ts(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
@@ -46,7 +49,10 @@ pub enum ProviderAccount {
impl PlanType {
pub fn is_team_like(self) -> bool {
matches!(self, Self::Team | Self::SelfServeBusinessUsageBased)
matches!(
self,
Self::Team | Self::SelfServeBusinessProLite | Self::SelfServeBusinessUsageBased
)
}
pub fn is_business_like(self) -> bool {
@@ -60,6 +66,7 @@ impl PlanType {
matches!(
self,
Self::Team
| Self::SelfServeBusinessProLite
| Self::SelfServeBusinessUsageBased
| Self::Business
| Self::Ent26
@@ -88,6 +95,7 @@ impl From<KnownPlan> for PlanType {
KnownPlan::Pro => Self::Pro,
KnownPlan::ProLite => Self::ProLite,
KnownPlan::Team => Self::Team,
KnownPlan::SelfServeBusinessProLite => Self::SelfServeBusinessProLite,
KnownPlan::SelfServeBusinessUsageBased => Self::SelfServeBusinessUsageBased,
KnownPlan::Business => Self::Business,
KnownPlan::Ent26 => Self::Ent26,
@@ -106,7 +114,12 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn usage_based_plan_types_use_expected_wire_names() {
fn business_plan_types_use_expected_wire_names() {
assert_eq!(
serde_json::to_string(&PlanType::SelfServeBusinessProLite)
.expect("self-serve business ProLite should serialize"),
"\"self_serve_business_prolite\""
);
assert_eq!(
serde_json::to_string(&PlanType::SelfServeBusinessUsageBased)
.expect("self-serve business usage based should serialize"),
@@ -125,6 +138,11 @@ mod tests {
serde_json::to_string(&PlanType::ProLite).expect("prolite should serialize"),
"\"prolite\""
);
assert_eq!(
serde_json::from_str::<PlanType>("\"self_serve_business_prolite\"")
.expect("self-serve business ProLite should deserialize"),
PlanType::SelfServeBusinessProLite
);
assert_eq!(
serde_json::from_str::<PlanType>("\"self_serve_business_usage_based\"")
.expect("self-serve business usage based should deserialize"),
@@ -146,8 +164,9 @@ mod tests {
}
#[test]
fn plan_family_helpers_group_usage_based_variants_with_existing_plans() {
fn plan_family_helpers_group_business_variants_with_existing_plans() {
assert_eq!(PlanType::Team.is_team_like(), true);
assert_eq!(PlanType::SelfServeBusinessProLite.is_team_like(), true);
assert_eq!(PlanType::SelfServeBusinessUsageBased.is_team_like(), true);
assert_eq!(PlanType::Business.is_team_like(), false);
assert_eq!(PlanType::Ent26.is_team_like(), false);
@@ -159,8 +178,12 @@ mod tests {
}
#[test]
fn workspace_account_helper_includes_usage_based_workspace_plans() {
fn workspace_account_helper_includes_business_workspace_plans() {
assert_eq!(PlanType::Team.is_workspace_account(), true);
assert_eq!(
PlanType::SelfServeBusinessProLite.is_workspace_account(),
true
);
assert_eq!(
PlanType::SelfServeBusinessUsageBased.is_workspace_account(),
true
@@ -178,6 +201,10 @@ mod tests {
#[test]
fn auth_plan_type_converts_to_account_plan_type() {
assert_eq!(
PlanType::from(AuthPlanType::Known(KnownPlan::SelfServeBusinessProLite)),
PlanType::SelfServeBusinessProLite
);
assert_eq!(
PlanType::from(AuthPlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
PlanType::EnterpriseCbpUsageBased
+6
View File
@@ -71,6 +71,7 @@ impl PlanType {
"pro" => Self::Known(KnownPlan::Pro),
"prolite" => Self::Known(KnownPlan::ProLite),
"team" => Self::Known(KnownPlan::Team),
"self_serve_business_prolite" => Self::Known(KnownPlan::SelfServeBusinessProLite),
"self_serve_business_usage_based" => {
Self::Known(KnownPlan::SelfServeBusinessUsageBased)
}
@@ -93,6 +94,8 @@ pub enum KnownPlan {
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_prolite")]
SelfServeBusinessProLite,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
Business,
@@ -114,6 +117,7 @@ impl KnownPlan {
Self::Pro => "Pro",
Self::ProLite => "Pro Lite",
Self::Team => "Team",
Self::SelfServeBusinessProLite => "Self Serve Business ProLite",
Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based",
Self::Business => "Business",
Self::Ent26 => "Enterprise",
@@ -131,6 +135,7 @@ impl KnownPlan {
Self::Pro => "pro",
Self::ProLite => "prolite",
Self::Team => "team",
Self::SelfServeBusinessProLite => "self_serve_business_prolite",
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
Self::Business => "business",
Self::Ent26 => "ent26",
@@ -144,6 +149,7 @@ impl KnownPlan {
matches!(
self,
Self::Team
| Self::SelfServeBusinessProLite
| Self::SelfServeBusinessUsageBased
| Self::Business
| Self::Ent26
+1
View File
@@ -686,6 +686,7 @@ impl std::fmt::Display for UsageLimitReachedError {
),
Some(PlanType::Known(
KnownPlan::Team
| KnownPlan::SelfServeBusinessProLite
| KnownPlan::SelfServeBusinessUsageBased
| KnownPlan::Business
| KnownPlan::Ent26
+15
View File
@@ -351,6 +351,21 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
}
}
#[test]
fn usage_limit_reached_error_formats_self_serve_business_prolite_plan() {
let err = UsageLimitReachedError {
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessProLite)),
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. To get more access now, send a request to your admin or try again later."
);
}
#[test]
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
let err = UsageLimitReachedError {
+12
View File
@@ -2944,5 +2944,17 @@ mod tests {
plan: Some(ref plan),
}) if plan == "Business"
));
let business_prolite = status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt),
Some(codex_protocol::account::PlanType::SelfServeBusinessProLite),
);
assert!(matches!(
business_prolite,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Business"
));
}
}
+1
View File
@@ -219,6 +219,7 @@ mod tests {
(PlanType::Pro, "Pro"),
(PlanType::ProLite, "Pro Lite"),
(PlanType::Team, "Business"),
(PlanType::SelfServeBusinessProLite, "Business"),
(PlanType::SelfServeBusinessUsageBased, "Business"),
(PlanType::Business, "Enterprise"),
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
@@ -608,6 +608,7 @@ def generate_v2_all(schema_dir: Path) -> None:
_require_nullable_chatgpt_account_email(out_path)
_preserve_reasoning_effort_enum(out_path)
_preserve_thread_source_enum(out_path)
_preserve_plan_type_enum(out_path)
_normalize_generated_timestamps(out_path)
@@ -695,6 +696,36 @@ def _preserve_thread_source_enum(out_path: Path) -> None:
out_path.write_text(source[:class_start] + open_enum + source[class_end:])
def _preserve_plan_type_enum(out_path: Path) -> None:
"""Keep the public plan constants while accepting values from newer runtimes."""
source = out_path.read_text()
class_start = source.find("class PlanType(Enum):")
if class_start == -1:
raise RuntimeError("Generated SDK is missing PlanType")
class_end = source.find("\n\nclass ", class_start)
if class_end == -1:
class_end = len(source)
class_source = source[class_start:class_end]
class_source = class_source.replace(
"class PlanType(Enum):",
"class PlanType(str, Enum):",
1,
).rstrip()
class_source += """
@classmethod
def _missing_(cls, value: object) -> PlanType | None:
if not isinstance(value, str) or not value:
return None
member = str.__new__(cls, value)
member._name_ = value
member._value_ = value
return member
"""
out_path.write_text(source[:class_start] + class_source + source[class_end:])
def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]:
"""Map each server notification method to its generated payload model class."""
server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text())
@@ -2505,7 +2505,7 @@ class PlanDeltaNotification(BaseModel):
turn_id: Annotated[str, Field(alias="turnId")]
class PlanType(Enum):
class PlanType(str, Enum):
free = "free"
go = "go"
plus = "plus"
@@ -2519,6 +2519,15 @@ class PlanType(Enum):
edu = "edu"
unknown = "unknown"
@classmethod
def _missing_(cls, value: object) -> PlanType | None:
if not isinstance(value, str) or not value:
return None
member = str.__new__(cls, value)
member._name_ = value
member._value_ = value
return member
class PluginAuthPolicy(Enum):
on_install = "ON_INSTALL"
@@ -5,8 +5,12 @@ from pathlib import Path
from openai_codex.client import CodexClient, _params_dict
from openai_codex.generated.notification_registry import notification_turn_id
from openai_codex.generated.v2_all import (
AccountRateLimitsUpdatedNotification,
AccountUpdatedNotification,
AgentMessageDeltaNotification,
ApprovalsReviewer,
GetAccountResponse,
PlanType,
ReasoningEffort,
ReasoningEffortOption,
ThreadForkParams,
@@ -37,6 +41,38 @@ def test_generated_v2_bundle_has_single_shared_plan_type_definition() -> None:
assert source.count("class PlanType(") == 1
def test_plan_type_accepts_business_prolite_from_newer_runtime() -> None:
"""New runtime plan values should remain typed when using a codex_bin override."""
plan_type = "self_serve_business_prolite"
response = GetAccountResponse.model_validate(
{
"account": {
"type": "chatgpt",
"email": "user@example.com",
"planType": plan_type,
},
"requiresOpenaiAuth": True,
}
)
assert response.account is not None
assert response.account.root.plan_type.value == plan_type
client = CodexClient()
account_updated = client._coerce_notification(
"account/updated",
{"authMode": "chatgpt", "planType": plan_type},
)
assert isinstance(account_updated.payload, AccountUpdatedNotification)
assert account_updated.payload.plan_type == PlanType(plan_type)
rate_limits_updated = client._coerce_notification(
"account/rateLimits/updated",
{"rateLimits": {"planType": plan_type}},
)
assert isinstance(rate_limits_updated.payload, AccountRateLimitsUpdatedNotification)
assert rate_limits_updated.payload.rate_limits.plan_type == PlanType(plan_type)
def test_reasoning_effort_preserves_enum_constants_and_accepts_future_values() -> None:
"""Known effort members and new runtime values should share the enum-style API."""
known_option = ReasoningEffortOption.model_validate(