diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f92cce4896..551d1709da 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2913,6 +2913,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 99f80ada8b..058fb96814 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14021,6 +14021,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0c6850f595..2a5e2bf6cc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10363,6 +10363,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index d66af3fbe4..b86fa1307b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -30,6 +30,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index c10c49132f..ff573e82b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -63,6 +63,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index a7d91a6281..bd33d63b1e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -30,6 +30,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index a65e3066d3..68842c6723 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -76,6 +76,7 @@ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", "ent26", diff --git a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts index 9685328149..116b8dfba8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -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"; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cfd15f90a1..a79aff37b1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 1351868d47..ae5ea7e538 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 12137c5b61..8ed1791ae5 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -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()?; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 3194f2cf40..9f25f389f0 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -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, }, ), diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 1073a3d50b..5912d8a1e0 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -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::("\"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 diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index dd1469ad7e..0274e10ef7 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -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] diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 252b9b1d17..13cfe3f73f 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -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")] diff --git a/codex-rs/login/src/token_data_tests.rs b/codex-rs/login/src/token_data_tests.rs index d84a040133..78ccd3aea9 100644 --- a/codex-rs/login/src/token_data_tests.rs +++ b/codex-rs/login/src/token_data_tests.rs @@ -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" })); diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 2a7b842a1e..544cd8a51d 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -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 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::("\"self_serve_business_prolite\"") + .expect("self-serve business ProLite should deserialize"), + PlanType::SelfServeBusinessProLite + ); assert_eq!( serde_json::from_str::("\"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 diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index af5eb8a793..90289facc4 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -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 diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 68e0f7fc94..eb29127aa6 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -686,6 +686,7 @@ impl std::fmt::Display for UsageLimitReachedError { ), Some(PlanType::Known( KnownPlan::Team + | KnownPlan::SelfServeBusinessProLite | KnownPlan::SelfServeBusinessUsageBased | KnownPlan::Business | KnownPlan::Ent26 diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 146aa41b91..4ed8780544 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -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 { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 01121ce439..354a3f6646 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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" + )); } } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index f03336a4b0..e779883b18 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -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"), diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index e0c32bc647..33dfae57c2 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -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()) diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index e31c9115d7..43f32cad08 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -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" diff --git a/sdk/python/tests/test_client_rpc_methods.py b/sdk/python/tests/test_client_rpc_methods.py index 1441d2df38..075951213f 100644 --- a/sdk/python/tests/test_client_rpc_methods.py +++ b/sdk/python/tests/test_client_rpc_methods.py @@ -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(