[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.
This commit is contained in:
Thibault Sottiaux 2026-03-01 22:43:37 +01:00 committed by GitHub
parent 4ae60cf03c
commit c9cef6ba9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 76 additions and 24 deletions

View file

@ -46,6 +46,16 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/PlanType"
},
{
"type": "null"
}
]
}
},
"type": "object"

View file

@ -7319,6 +7319,16 @@
"type": "null"
}
]
},
"planType": {
"anyOf": [
{
"$ref": "#/definitions/v2/PlanType"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",

View file

@ -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",

View file

@ -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, };

View file

@ -2557,6 +2557,7 @@ pub struct Thread {
#[ts(export_to = "v2/")]
pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
pub plan_type: Option<PlanType>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View file

@ -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": "<uuid>", "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)

View file

@ -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))

View file

@ -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)

View file

@ -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(())