diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a7ae035d..2c52067b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -191,7 +191,7 @@ client_request_definitions! { #[serde(rename = "account/read")] #[ts(rename = "account/read")] GetAccount { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + params: v2::GetAccountParams, response: v2::GetAccountResponse, }, @@ -263,6 +263,7 @@ client_request_definitions! { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v1::LogoutChatGptResponse, }, + /// DEPRECATED in favor of GetAccount GetAuthStatus { params: v1::GetAuthStatusParams, response: v1::GetAuthStatusResponse, @@ -758,12 +759,17 @@ mod tests { fn serialize_get_account() -> Result<()> { let request = ClientRequest::GetAccount { request_id: RequestId::Integer(5), - params: None, + params: v2::GetAccountParams { + refresh_token: false, + }, }; assert_eq!( json!({ "method": "account/read", "id": 5, + "params": { + "refreshToken": false + } }), serde_json::to_value(&request)?, ); @@ -772,19 +778,16 @@ mod tests { #[test] fn account_serializes_fields_in_camel_case() -> Result<()> { - let api_key = v2::Account::ApiKey { - api_key: "secret".to_string(), - }; + let api_key = v2::Account::ApiKey {}; assert_eq!( json!({ "type": "apiKey", - "apiKey": "secret", }), serde_json::to_value(&api_key)?, ); let chatgpt = v2::Account::Chatgpt { - email: Some("user@example.com".to_string()), + email: "user@example.com".to_string(), plan_type: PlanType::Plus, }; assert_eq!( diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 436585ba..6d2a681f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -123,14 +123,11 @@ impl From for SandboxPolicy { pub enum Account { #[serde(rename = "apiKey", rename_all = "camelCase")] #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { api_key: String }, + ApiKey {}, #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - email: Option, - plan_type: PlanType, - }, + Chatgpt { email: String, plan_type: PlanType }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -193,11 +190,20 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits: RateLimitSnapshot, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + #[serde(default)] + pub refresh_token: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GetAccountResponse { - pub account: Account, + pub account: Option, + pub requires_openai_auth: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ebfaed58..b6549cc1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -30,7 +31,9 @@ use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; @@ -273,12 +276,8 @@ impl CodexMessageProcessor { ClientRequest::CancelLoginAccount { request_id, params } => { self.cancel_login_v2(request_id, params).await; } - ClientRequest::GetAccount { - request_id, - params: _, - } => { - self.send_unimplemented_error(request_id, "account/read") - .await; + ClientRequest::GetAccount { request_id, params } => { + self.get_account(request_id, params).await; } ClientRequest::ResumeConversation { request_id, params } => { self.handle_resume_conversation(request_id, params).await; @@ -801,13 +800,17 @@ impl CodexMessageProcessor { } } + async fn refresh_token_if_requested(&self, do_refresh: bool) { + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + tracing::warn!("failed to refresh token whilte getting account: {err}"); + } + } + async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token while getting auth status: {err}"); - } + self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -852,6 +855,56 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn get_account(&self, request_id: RequestId, params: GetAccountParams) { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + // Whether auth is required for the active model provider. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + if !requires_openai_auth { + let response = GetAccountResponse { + account: None, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + return; + } + + let account = match self.auth_manager.auth() { + Some(auth) => Some(match auth.mode { + AuthMode::ApiKey => Account::ApiKey {}, + AuthMode::ChatGPT => { + let email = auth.get_account_email(); + let plan_type = auth.account_plan_type(); + + match (email, plan_type) { + (Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type }, + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: + "email and plan type are required for chatgpt authentication" + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + }), + None => None, + }; + + let response = GetAccountResponse { + account, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + } + async fn get_user_agent(&self, request_id: RequestId) { let user_agent = get_codex_user_agent(); let response = GetUserAgentResponse { user_agent }; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index b4404b47..75851eda 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; @@ -249,6 +250,15 @@ impl McpProcess { self.send_request("account/rateLimits/read", None).await } + /// Send an `account/read` JSON-RPC request. + pub async fn send_get_account_request( + &mut self, + params: GetAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/read", params).await + } + /// Send a `feedback/upload` JSON-RPC request. pub async fn send_feedback_upload_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index a00c97b1..dd592707 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -2,11 +2,15 @@ use anyhow::Result; use anyhow::bail; use app_test_support::McpProcess; use app_test_support::to_response; + +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; -use codex_app_server_protocol::GetAuthStatusParams; -use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; @@ -15,6 +19,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use serial_test::serial; use std::path::Path; @@ -25,22 +30,30 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // Helper to create a minimal config.toml for the app server -fn create_config_toml( - codex_home: &Path, - forced_method: Option<&str>, - forced_workspace_id: Option<&str>, -) -> std::io::Result<()> { +#[derive(Default)] +struct CreateConfigTomlParams { + forced_method: Option, + forced_workspace_id: Option, + requires_openai_auth: Option, +} + +fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); - let forced_line = if let Some(method) = forced_method { + let forced_line = if let Some(method) = params.forced_method { format!("forced_login_method = \"{method}\"\n") } else { String::new() }; - let forced_workspace_line = if let Some(ws) = forced_workspace_id { + let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { format!("forced_chatgpt_workspace_id = \"{ws}\"\n") } else { String::new() }; + let requires_line = match params.requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) => String::new(), + None => String::new(), + }; let contents = format!( r#" model = "mock-model" @@ -57,6 +70,7 @@ base_url = "http://127.0.0.1:0/v1" wire_api = "chat" request_max_retries = 0 stream_max_retries = 0 +{requires_line} "# ); std::fs::write(config_toml, contents) @@ -65,7 +79,7 @@ stream_max_retries = 0 #[tokio::test] async fn logout_account_removes_auth_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; login_with_api_key( codex_home.path(), @@ -104,27 +118,25 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { "auth.json should be deleted" ); - let status_id = mcp - .send_get_auth_status_request(GetAuthStatusParams { - include_token: Some(true), - refresh_token: Some(false), + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, }) .await?; - let status_resp: JSONRPCResponse = timeout( + let get_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(status_id)), + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), ) .await??; - let status: GetAuthStatusResponse = to_response(status_resp)?; - assert_eq!(status.auth_method, None); - assert_eq!(status.auth_token, None); + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!(account.account, None); Ok(()) } #[tokio::test] async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -171,7 +183,13 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { #[tokio::test] async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), Some("chatgpt"), None)?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("chatgpt".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -195,7 +213,13 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { #[tokio::test] async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), Some("api"), None)?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("api".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -219,7 +243,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { #[serial(login_port)] async fn login_account_chatgpt_start() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -285,7 +309,13 @@ async fn login_account_chatgpt_start() -> Result<()> { #[serial(login_port)] async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, Some("ws-forced"))?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("ws-forced".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -307,3 +337,156 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result ); Ok(()) } + +#[tokio::test] +async fn get_account_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let account: GetAccountResponse = to_response(resp)?; + + assert_eq!(account.account, None, "expected no account"); + assert_eq!(account.requires_openai_auth, true); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let _login_ok = to_response::(resp)?; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::ApiKey {}), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(false), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: None, + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_chatgpt() -> 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("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 655d8230..a9d64060 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -26,10 +26,12 @@ use crate::config::Config; use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; -use crate::token_data::PlanType; +use crate::token_data::KnownPlan as InternalKnownPlan; +use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_protocol::account::PlanType as AccountPlanType; use serde_json::Value; use thiserror::Error; @@ -202,7 +204,34 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.id_token.email) } - pub(crate) fn get_plan_type(&self) -> Option { + /// Account-facing plan classification derived from the current token. + /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) + /// mapped from the ID token's internal plan value. Prefer this when you + /// need to make UI or product decisions based on the user's subscription. + pub fn account_plan_type(&self) -> Option { + let map_known = |kp: &InternalKnownPlan| match kp { + InternalKnownPlan::Free => AccountPlanType::Free, + InternalKnownPlan::Plus => AccountPlanType::Plus, + InternalKnownPlan::Pro => AccountPlanType::Pro, + InternalKnownPlan::Team => AccountPlanType::Team, + InternalKnownPlan::Business => AccountPlanType::Business, + InternalKnownPlan::Enterprise => AccountPlanType::Enterprise, + InternalKnownPlan::Edu => AccountPlanType::Edu, + }; + + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type) + .map(|pt| match pt { + InternalPlanType::Known(k) => map_known(&k), + InternalPlanType::Unknown(_) => AccountPlanType::Unknown, + }) + } + + /// Raw internal plan value from the ID token. + /// Exposes the underlying `token_data::PlanType` without mapping it to the + /// public `AccountPlanType`. Use this when downstream code needs to inspect + /// internal/unknown plan strings exactly as issued in the token. + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() .and_then(|t| t.id_token.chatgpt_plan_type) } @@ -609,8 +638,9 @@ mod tests { use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; + use crate::token_data::KnownPlan as InternalKnownPlan; + use crate::token_data::PlanType as InternalPlanType; + use codex_protocol::account::PlanType as AccountPlanType; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; @@ -727,7 +757,7 @@ mod tests { tokens: Some(TokenData { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), chatgpt_account_id: None, raw_jwt: fake_jwt, }, @@ -981,6 +1011,54 @@ mod tests { .contains("ChatGPT login is required, but an API key is currently being used.") ); } + + #[test] + fn plan_type_maps_known_plan() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Known(InternalKnownPlan::Pro)) + ); + } + + #[test] + fn plan_type_maps_unknown_to_unknown() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "mystery-tier".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Unknown("mystery-tier".to_string())) + ); + } } /// Central manager providing a single source of truth for auth.json derived