From c9cef6ba9eb4bb5d547523da6d2f0e7e8a927e4f Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 1 Mar 2026 22:43:37 +0100 Subject: [PATCH] [codex] include plan type in account updates (#13181) This change fixes a Codex app account-state sync bug where clients could know the user was signed in but still miss the ChatGPT subscription tier, which could lead to incorrect upgrade messaging for paid users. The root cause was that `account/updated` only carried `authMode` while plan information was available separately via `account/read` and rate-limit snapshots, so this update adds `planType` to `account/updated`, populates it consistently across login and refresh paths. --- .../schema/json/ServerNotification.json | 10 ++++++ .../codex_app_server_protocol.schemas.json | 10 ++++++ .../json/v2/AccountUpdatedNotification.json | 24 +++++++++++++ .../v2/AccountUpdatedNotification.ts | 3 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/README.md | 10 +++--- .../app-server/src/codex_message_processor.rs | 35 ++++++++++--------- codex-rs/app-server/src/outgoing_message.rs | 4 ++- codex-rs/app-server/tests/suite/v2/account.rs | 3 ++ 9 files changed, 76 insertions(+), 24 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7a29ef92b..28df2574b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -46,6 +46,16 @@ "type": "null" } ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] } }, "type": "object" 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 374d43de7..edee815e0 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 @@ -7319,6 +7319,16 @@ "type": "null" } ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PlanType" + }, + { + "type": "null" + } + ] } }, "title": "AccountUpdatedNotification", 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 d95d73706..8348b774c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -26,6 +26,20 @@ "type": "string" } ] + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "team", + "business", + "enterprise", + "edu", + "unknown" + ], + "type": "string" } }, "properties": { @@ -38,6 +52,16 @@ "type": "null" } ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] } }, "title": "AccountUpdatedNotification", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts index eacb81541..84bf626e0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthMode } from "../AuthMode"; +import type { PlanType } from "../PlanType"; -export type AccountUpdatedNotification = { authMode: AuthMode | null, }; +export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a26ca25bd..bde0e17fa 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2557,6 +2557,7 @@ pub struct Thread { #[ts(export_to = "v2/")] pub struct AccountUpdatedNotification { pub auth_mode: Option, + pub plan_type: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b43cd99c6..64d0625c8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -952,7 +952,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`) 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`. - **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"`; Codex persists tokens to disk and refreshes them automatically. @@ -964,7 +964,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`). +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. @@ -1008,7 +1008,7 @@ Field notes: 3. Notifications: ```json { "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "apikey" } } + { "method": "account/updated", "params": { "authMode": "apikey", "planType": null } } ``` ### 3) Log in with ChatGPT (browser flow) @@ -1022,7 +1022,7 @@ Field notes: 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "chatgpt" } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } ``` ### 4) Cancel a ChatGPT login @@ -1037,7 +1037,7 @@ Field notes: ```json { "method": "account/logout", "id": 5 } { "id": 5, "result": {} } -{ "method": "account/updated", "params": { "authMode": null } } +{ "method": "account/updated", "params": { "authMode": null, "planType": null } } ``` ### 6) Rate limits (ChatGPT) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index eef211ff8..e65f7e888 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -423,6 +423,14 @@ pub(crate) struct CodexMessageProcessorArgs { } impl CodexMessageProcessor { + fn current_account_updated_notification(&self) -> AccountUpdatedNotification { + let auth = self.auth_manager.auth_cached(); + AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + } + } + async fn load_thread( &self, thread_id: &str, @@ -1057,15 +1065,10 @@ impl CodexMessageProcessor { )) .await; - let payload_v2 = AccountUpdatedNotification { - auth_mode: self - .auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode), - }; self.outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) .await; } Err(error) => { @@ -1281,12 +1284,10 @@ impl CodexMessageProcessor { .await; // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode); + let auth = auth_manager.auth_cached(); let payload_v2 = AccountUpdatedNotification { - auth_mode: current_auth_method, + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), }; outgoing_clone .send_server_notification(ServerNotification::AccountUpdated( @@ -1467,11 +1468,10 @@ impl CodexMessageProcessor { )) .await; - let payload_v2 = AccountUpdatedNotification { - auth_mode: self.auth_manager.get_api_auth_mode(), - }; self.outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) .await; } @@ -1529,6 +1529,7 @@ impl CodexMessageProcessor { let payload_v2 = AccountUpdatedNotification { auth_mode: current_auth_method, + plan_type: None, }; self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d6605ebe5..5f56c81c5 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -612,6 +612,7 @@ mod tests { fn verify_account_updated_notification_serialization() { let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { auth_mode: Some(AuthMode::ApiKey), + plan_type: None, }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); @@ -619,7 +620,8 @@ mod tests { json!({ "method": "account/updated", "params": { - "authMode": "apikey" + "authMode": "apikey", + "planType": null }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index d4d702a0d..48ede78f4 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -131,6 +131,7 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { payload.auth_mode.is_none(), "auth_method should be None after logout" ); + assert_eq!(payload.plan_type, None); assert!( !codex_home.path().join("auth.json").exists(), @@ -201,6 +202,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { bail!("unexpected notification: {parsed:?}"); }; assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens)); + assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); let get_id = mcp .send_get_account_request(GetAccountParams { @@ -843,6 +845,7 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { bail!("unexpected notification: {parsed:?}"); }; pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey)); + pretty_assertions::assert_eq!(payload.plan_type, None); assert!(codex_home.path().join("auth.json").exists()); Ok(())