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 7db1da147f..18a9a1f7d5 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 @@ -6805,6 +6805,13 @@ ], "title": "ChatgptAccountType", "type": "string" + }, + "workspaceName": { + "description": "Display name of the selected ChatGPT workspace, when available.", + "type": [ + "string", + "null" + ] } }, "required": [ 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 e106849a05..385ca8d148 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 @@ -40,6 +40,13 @@ ], "title": "ChatgptAccountType", "type": "string" + }, + "workspaceName": { + "description": "Display name of the selected ChatGPT workspace, when available.", + "type": [ + "string", + "null" + ] } }, "required": [ 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 3a0b91064f..2c5d632ee3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -36,6 +36,13 @@ ], "title": "ChatgptAccountType", "type": "string" + }, + "workspaceName": { + "description": "Display name of the selected ChatGPT workspace, when available.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 458f58fc0f..5e3d6b67ce 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 5ed9ebcd40..d194c82851 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index 4897b1ff9a..6b6982479d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", usesCodexManagedCredentials: boolean, } | { "type": "gcp", }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, +/** + * Display name of the selected ChatGPT workspace, when available. + */ +workspaceName: string | null, } | { "type": "amazonBedrock", usesCodexManagedCredentials: boolean, } | { "type": "gcp", }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3a777cdc3d..8d5d2be81d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3654,12 +3654,14 @@ mod tests { let chatgpt = v2::Account::Chatgpt { email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, + workspace_name: None, }; assert_eq!( json!({ "type": "chatgpt", "email": "user@example.com", "planType": "plus", + "workspaceName": null, }), serde_json::to_value(&chatgpt)?, ); @@ -3667,12 +3669,14 @@ mod tests { let chatgpt_without_email = v2::Account::Chatgpt { email: None, plan_type: PlanType::Pro, + workspace_name: None, }; assert_eq!( json!({ "type": "chatgpt", "email": null, "planType": "pro", + "workspaceName": null, }), serde_json::to_value(&chatgpt_without_email)?, ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 2de06c9eda..f745f1f985 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -33,6 +33,8 @@ pub enum Account { )] email: Option, plan_type: PlanType, + /// Display name of the selected ChatGPT workspace, when available. + workspace_name: Option, }, #[serde(rename = "amazonBedrock", rename_all = "camelCase")] @@ -52,7 +54,11 @@ impl From for Account { fn from(account: ProviderAccount) -> Self { match account { ProviderAccount::ApiKey => Self::ApiKey {}, - ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { + email, + plan_type, + workspace_name: None, + }, ProviderAccount::AmazonBedrock { uses_codex_managed_credentials, } => Self::AmazonBedrock { diff --git a/codex-rs/app-server/src/request_processors/account_processor/workspace_routing.rs b/codex-rs/app-server/src/request_processors/account_processor/workspace_routing.rs index 9450922c95..aef1593c8e 100644 --- a/codex-rs/app-server/src/request_processors/account_processor/workspace_routing.rs +++ b/codex-rs/app-server/src/request_processors/account_processor/workspace_routing.rs @@ -37,17 +37,20 @@ pub(super) type WorkspaceRoutingFetches = HashMap>, + routing: Mutex>, } +#[derive(Clone)] pub(super) struct CachedWorkspaceRouting { key: WorkspaceRoutingKey, routing: WorkspaceRouting, + name: Option, } pub(super) struct AccountRead { account_state: ProviderAccountState, pub(super) workspace_routing: Option, + workspace_name: Option, } #[derive(Debug, PartialEq, Eq, thiserror::Error)] @@ -264,9 +267,13 @@ impl AccountRequestProcessor { AccountReadError::InvalidAccount(error) => invalid_request(error.to_string()), AccountReadError::Routing(error) => internal_error(error.to_string()), })?; + let mut account = read.account_state.account.map(Account::from); + if let Some(Account::Chatgpt { workspace_name, .. }) = &mut account { + *workspace_name = read.workspace_name; + } Ok(Some( GetAccountResponse { - account: read.account_state.account.map(Account::from), + account, requires_openai_auth: read.account_state.requires_openai_auth, workspace_routing: read.workspace_routing.map(|routing| { codex_app_server_protocol::WorkspaceRouting { @@ -387,7 +394,7 @@ impl AccountRequestProcessor { .await .as_ref() .filter(|cached| cached.key == key) - .map(|cached| cached.routing.clone()), + .cloned(), }; let routing = if let Some(cached) = cached { cached @@ -450,7 +457,7 @@ impl AccountRequestProcessor { .accounts .into_iter() .filter(|account| account.id == account_id); - let entry = accounts + let mut entry = accounts .next() .ok_or(WorkspaceRoutingError::MissingWorkspace)?; if accounts.next().is_some() { @@ -471,6 +478,7 @@ impl AccountRequestProcessor { { return Err(WorkspaceRoutingError::ConfigurationChanged.into()); } + let name = entry.name.take(); let routing = resolve_routing( entry, required_chatgpt_base_url.as_deref(), @@ -481,15 +489,17 @@ impl AccountRequestProcessor { { return Err(WorkspaceRoutingError::AccountChanged.into()); } + let routing = CachedWorkspaceRouting { + key: WorkspaceRoutingKey { + auth_generation: discovery_generation, + ..key + }, + routing, + name, + }; // Same-owner refresh can complete a newer discovery first. if current_auth_changes.borrow().generation == discovery_generation { - *cached = Some(CachedWorkspaceRouting { - key: WorkspaceRoutingKey { - auth_generation: discovery_generation, - ..key - }, - routing: routing.clone(), - }); + *cached = Some(routing.clone()); } routing }; @@ -509,9 +519,9 @@ impl AccountRequestProcessor { let session_bootstrap_origin = Url::parse(&request.chatgpt_base_url) .map_err(|_| WorkspaceRoutingError::InvalidSessionBootstrapUrl)? .origin(); - let selected_backend = workspace_routing - .as_ref() - .is_some_and(|routing| routing.backend_origin == origin.ascii_serialization()); + let selected_backend = workspace_routing.as_ref().is_some_and(|routing| { + routing.routing.backend_origin == origin.ascii_serialization() + }); let workspace_bound = request.previously_routed || request.provider_base_url == codex_model_provider::CHATGPT_CODEX_BASE_URL || origin == session_bootstrap_origin @@ -522,9 +532,13 @@ impl AccountRequestProcessor { return Err(WorkspaceRoutingError::SessionBackendChanged.into()); } } + let (workspace_routing, workspace_name) = workspace_routing + .map(|workspace| (Some(workspace.routing), workspace.name)) + .unwrap_or_default(); Ok(AccountRead { account_state, workspace_routing, + workspace_name, }) }); let result = tokio::select! { diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 79bd14f003..4910058cfe 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -229,6 +229,7 @@ async fn personal_access_token_without_email_supports_auth_status_and_account_re account: Some(Account::Chatgpt { email: None, plan_type: AccountPlanType::EnterpriseCbpAutomation, + workspace_name: None, }), requires_openai_auth: true, } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index aa09b38f94..3ffcf0d2f7 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -569,6 +569,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { account: Some(Account::Chatgpt { email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, + workspace_name: None, }), requires_openai_auth: true, } @@ -647,6 +648,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { account: Some(Account::Chatgpt { email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, + workspace_name: None, }), requires_openai_auth: true, } @@ -2444,6 +2446,7 @@ async fn login_survives_same_owner_token_refresh( account: Some(Account::Chatgpt { email: Some("device@example.com".into()), plan_type: AccountPlanType::Enterprise, + workspace_name: None, }), requires_openai_auth: true, workspace_routing: expected_workspace_routing(WORKSPACE_ID_DEVICE), @@ -3485,6 +3488,7 @@ async fn get_account_with_chatgpt() -> Result<()> { account: Some(Account::Chatgpt { email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, + workspace_name: None, }), requires_openai_auth: true, }; @@ -3542,6 +3546,7 @@ async fn get_account_with_chatgpt_plan_variants_returns_plan_type( account: Some(Account::Chatgpt { email: Some("user@example.com".to_string()), plan_type: expected_plan, + workspace_name: None, }), requires_openai_auth: true, } @@ -3589,6 +3594,7 @@ async fn get_account_with_chatgpt_without_email() -> Result<()> { account: Some(Account::Chatgpt { email: None, plan_type: AccountPlanType::Pro, + workspace_name: None, }), requires_openai_auth: true, } @@ -3714,6 +3720,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result account: Some(Account::Chatgpt { email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Unknown, + workspace_name: None, }), requires_openai_auth: true, }; diff --git a/codex-rs/app-server/tests/suite/v2/account_gcp.rs b/codex-rs/app-server/tests/suite/v2/account_gcp.rs index a7c94d2fba..1f10f8dd7a 100644 --- a/codex-rs/app-server/tests/suite/v2/account_gcp.rs +++ b/codex-rs/app-server/tests/suite/v2/account_gcp.rs @@ -80,7 +80,7 @@ async fn gcp_account_tracks_provider_changes_with_saved_chatgpt_credentials() -> ), ( "openai", - json!({"account": {"type": "chatgpt", "email": "user@example.com", "planType": "pro"}, "requiresOpenaiAuth": true, "workspaceRouting": null}), + json!({"account": {"type": "chatgpt", "email": "user@example.com", "planType": "pro", "workspaceName": null}, "requiresOpenaiAuth": true, "workspaceRouting": null}), ), ( "gcp", diff --git a/codex-rs/app-server/tests/suite/v2/workspace_routing.rs b/codex-rs/app-server/tests/suite/v2/workspace_routing.rs index 27593b711f..117a0b847c 100644 --- a/codex-rs/app-server/tests/suite/v2/workspace_routing.rs +++ b/codex-rs/app-server/tests/suite/v2/workspace_routing.rs @@ -108,7 +108,7 @@ async fn saved_workspace_is_discovered_once_and_not_the_default_account( .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "accounts": [ {"id": "other", "workspace_backend_origin": "https://other.example", "account_routing_override": "us"}, - {"id": "selected", "workspace_backend_origin": "https://gov.chatgpt.com", "account_routing_override": "NO_CONSTRAINT"} + {"id": "selected", "name": "Selected workspace", "workspace_backend_origin": "https://gov.chatgpt.com", "account_routing_override": "NO_CONSTRAINT"} ], "default_account_id": "other" })).set_delay(Duration::from_secs(accounts_delay_secs))) .expect(if accounts_delay_secs > 5 { 2 } else { 1 }) @@ -158,7 +158,7 @@ async fn saved_workspace_is_discovered_once_and_not_the_default_account( Some(json!({"authMode": "chatgpt", "planType": plan_type})) ); let expected = json!({ - "account": {"type": "chatgpt", "email": "user@example.com", "planType": plan_type}, + "account": {"type": "chatgpt", "email": "user@example.com", "planType": plan_type, "workspaceName": "Selected workspace"}, "requiresOpenaiAuth": true, "workspaceRouting": routing("selected", "https://gov.chatgpt.com", "NO_CONSTRAINT") }); assert_eq!(read(&mut server).await?, expected); @@ -185,7 +185,7 @@ async fn saved_chatgpt_login_without_selected_workspace_preserves_account() -> R let mut server = start(&home).await?; assert_eq!( read(&mut server).await?, - json!({"account": {"type": "chatgpt", "email": null, "planType": "pro"}, + json!({"account": {"type": "chatgpt", "email": null, "planType": "pro", "workspaceName": null}, "requiresOpenaiAuth": true, "workspaceRouting": null}) ); backend.verify().await; @@ -245,7 +245,7 @@ async fn stable_clients_do_not_treat_failed_workspace_discovery_as_unrestricted( .await; assert_eq!( read(&mut server).await?, - json!({"account": {"type": "chatgpt", "email": null, "planType": "pro"}, + json!({"account": {"type": "chatgpt", "email": null, "planType": "pro", "workspaceName": null}, "requiresOpenaiAuth": true, "workspaceRouting": routing("selected", "https://chatgpt.com", "NO_CONSTRAINT")}) ); Ok(()) diff --git a/codex-rs/tui/src/app/account_status.rs b/codex-rs/tui/src/app/account_status.rs index 4cccb0c9f2..4895b230df 100644 --- a/codex-rs/tui/src/app/account_status.rs +++ b/codex-rs/tui/src/app/account_status.rs @@ -1,4 +1,4 @@ -//! Refreshes the email omitted by account-update notifications without blocking terminal input. +//! Refreshes identity details omitted by account-update notifications without blocking terminal input. //! Results carry a request ID so account changes and reconnects reject obsolete identities. use super::App; @@ -31,8 +31,20 @@ impl App { .await; match result { Ok(Ok(response)) => { - if let Some(Account::Chatgpt { email, .. }) = response.account { - app_event_tx.send(AppEvent::AccountEmailLoaded { request_id, email }); + if let Some(Account::Chatgpt { + email, + workspace_name, + .. + }) = response.account + { + app_event_tx.send(AppEvent::AccountEmailLoaded { + request_id, + email, + workspace: crate::app_server_session::workspace_display( + workspace_name, + response.workspace_routing, + ), + }); } } Ok(Err(_)) | Err(_) => { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 6951cb51f1..3d31b745e9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1333,10 +1333,10 @@ impl App { elicitation_target: None, }); } - AppEvent::AccountEmailLoaded { request_id, email } => { + AppEvent::AccountEmailLoaded { request_id, email, workspace } => { if self.account_email_request_id == Some(request_id) { self.account_email_request_id = None; - self.chat_widget.on_account_email_loaded(email); + self.chat_widget.on_account_email_loaded(email, workspace); } } AppEvent::SecuritySetupLoaded { request_id, identity, notice } => { diff --git a/codex-rs/tui/src/app/tests/security_setup_tests.rs b/codex-rs/tui/src/app/tests/security_setup_tests.rs index afd05087a0..ba3f805acb 100644 --- a/codex-rs/tui/src/app/tests/security_setup_tests.rs +++ b/codex-rs/tui/src/app/tests/security_setup_tests.rs @@ -172,6 +172,7 @@ async fn security_setup_fetch_with_default_features_uses_authenticated_codex_end AppEvent::AccountEmailLoaded { request_id: uuid::Uuid::new_v4(), email: Some("stale@example.com".into()), + workspace: Some("stale-account".into()), }, ) .await?; @@ -180,6 +181,7 @@ async fn security_setup_fetch_with_default_features_uses_authenticated_codex_end Some(&StatusAccountDisplay::ChatGpt { email: Some("user@example.com".into()), plan: None, + workspace: Some("account".to_string()), }) ); app.handle_event(&mut tui, &mut server, event).await?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0d5c1cc1b3..2adc3036ae 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -298,6 +298,7 @@ pub(crate) enum AppEvent { AccountEmailLoaded { request_id: uuid::Uuid, email: Option, + workspace: Option, }, SecuritySetupLoaded { request_id: uuid::Uuid, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 02c5c29b24..683e7328e2 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -845,7 +845,11 @@ impl AppServerSession { FeedbackAudience::External, false, ), - Some(Account::Chatgpt { email, plan_type }) => { + Some(Account::Chatgpt { + email, + plan_type, + workspace_name, + }) => { let feedback_audience = if email .as_deref() .is_some_and(|email| email.ends_with("@openai.com")) @@ -860,6 +864,7 @@ impl AppServerSession { Some(StatusAccountDisplay::ChatGpt { email, plan: Some(plan_type_display_name(plan_type)), + workspace: workspace_display(workspace_name, account.workspace_routing), }), Some(plan_type), feedback_audience, @@ -1930,6 +1935,20 @@ pub(crate) async fn start_thread_with_request_handle( Ok(started) } +pub(crate) fn workspace_display( + name: Option, + routing: Option, +) -> Option { + let name = name + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty() && !name.chars().any(char::is_control)); + let id = routing.map(|routing| routing.chatgpt_account_id); + match (name, id) { + (Some(name), Some(id)) => Some(format!("{name} ({id})")), + (name, id) => name.or(id), + } +} + pub(crate) fn status_account_display_from_auth_mode( auth_mode: Option, plan_type: Option, @@ -1942,6 +1961,7 @@ pub(crate) fn status_account_display_from_auth_mode( | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), + workspace: None, }), Some(AuthMode::Headers) | Some(AuthMode::BedrockApiKey) @@ -2793,6 +2813,7 @@ mod tests { account: Some(Account::Chatgpt { email: Some("teammate@openai.com".to_string()), plan_type: codex_protocol::account::PlanType::Plus, + workspace_name: None, }), requires_openai_auth: true, }; @@ -2907,6 +2928,7 @@ mod tests { account: Some(Account::Chatgpt { email: Some("teammate@openai.com".to_string()), plan_type: codex_protocol::account::PlanType::Plus, + workspace_name: None, }), requires_openai_auth: true, workspace_routing: None, @@ -4811,6 +4833,7 @@ mod tests { Some(StatusAccountDisplay::ChatGpt { email: None, plan: Some(ref plan), + .. }) if plan == "Enterprise" )); @@ -4823,6 +4846,7 @@ mod tests { Some(StatusAccountDisplay::ChatGpt { email: None, plan: Some(ref plan), + .. }) if plan == "Business" )); @@ -4835,6 +4859,7 @@ mod tests { Some(StatusAccountDisplay::ChatGpt { email: None, plan: Some(ref plan), + .. }) if plan == "Business Premium" )); } diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index a41f125b4b..432fa653e8 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -213,10 +213,17 @@ impl ChatWidget { self.model_catalog.clone() } - pub(crate) fn on_account_email_loaded(&mut self, current_email: Option) { - if let Some(StatusAccountDisplay::ChatGpt { email, .. }) = &mut self.status_account_display + pub(crate) fn on_account_email_loaded( + &mut self, + current_email: Option, + current_workspace: Option, + ) { + if let Some(StatusAccountDisplay::ChatGpt { + email, workspace, .. + }) = &mut self.status_account_display { *email = current_email; + *workspace = current_workspace; } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 70f70c3cf3..b562a30147 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -123,10 +123,21 @@ async fn session_and_settings_sync_server_provider_id() { let first_id = ThreadId::new(); let mut first = configured_thread_session(first_id); first.model_provider_id = "server-ollama".to_string(); + chat.status_account_display = Some(StatusAccountDisplay::ChatGpt { + email: None, + plan: None, + workspace: Some("account-123".to_string()), + }); + let mut provider = chat.config.model_provider.clone(); + provider.env_key = Some("CUSTOM_API_KEY".to_string()); + chat.config + .model_providers + .insert(first.model_provider_id.clone(), provider); chat.handle_thread_session(first.clone()); let displayed = status(&mut chat); assert!(displayed.contains("Model provider:") && displayed.contains("server-ollama")); assert!(!displayed.contains("local-provider.example")); + assert!(!displayed.contains("ChatGPT workspace:")); let mut other = configured_thread_session(ThreadId::new()); other.model_provider_id = "server-bedrock".to_string(); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 2525b84058..9074c5ec03 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3917,6 +3917,7 @@ async fn account_update_discards_stale_workspace_headline_results() { Some(StatusAccountDisplay::ChatGpt { email: Some("first@example.com".to_string()), plan: None, + workspace: None, }), /*plan_type*/ None, /*has_chatgpt_account*/ true, @@ -3928,6 +3929,7 @@ async fn account_update_discards_stale_workspace_headline_results() { Some(StatusAccountDisplay::ChatGpt { email: Some("second@example.com".to_string()), plan: None, + workspace: None, }), /*plan_type*/ None, /*has_chatgpt_account*/ true, diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs index 7d814a27ac..733a6bfabe 100644 --- a/codex-rs/tui/src/status/account.rs +++ b/codex-rs/tui/src/status/account.rs @@ -3,6 +3,7 @@ pub(crate) enum StatusAccountDisplay { ChatGpt { email: Option, plan: Option, + workspace: Option, }, ApiKey, } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 44d45e4976..8145011fd1 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -11,6 +11,8 @@ use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; use codex_app_server_protocol::AskForApproval; +use codex_model_provider::provider_uses_first_party_auth_path; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; @@ -351,7 +353,27 @@ impl StatusHistoryCell { workspace_root_suffix.as_deref(), ); let show_chatgpt_usage_link = requires_openai_auth; - let account = compose_account_display(account_display); + let mut account = compose_account_display(account_display); + let workspace_matches_provider = if remote_connection.is_some() { + // A connected server owns the provider definitions; only the built-in ID is certain. + model_provider + .as_deref() + .is_none_or(|provider| provider == OPENAI_PROVIDER_ID) + } else { + config + .model_providers + .get( + model_provider + .as_deref() + .unwrap_or(&config.model_provider_id), + ) + .is_some_and(provider_uses_first_party_auth_path) + }; + if !workspace_matches_provider + && let Some(StatusAccountDisplay::ChatGpt { workspace, .. }) = &mut account + { + *workspace = None; + } let session_id = session_id.as_ref().map(std::string::ToString::to_string); let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); @@ -740,7 +762,7 @@ impl StatusHistoryCell { let mut lines = Vec::new(); let account_value = self.account.as_ref().map(|account| match account { - StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) { + StatusAccountDisplay::ChatGpt { email, plan, .. } => match (email, plan) { (Some(email), Some(plan)) => format!("{email} ({plan})"), (Some(email), None) => email.clone(), (None, Some(plan)) => plan.clone(), @@ -751,6 +773,11 @@ impl StatusHistoryCell { } }); + let workspace = match &self.account { + Some(StatusAccountDisplay::ChatGpt { workspace, .. }) => workspace.as_deref(), + _ => None, + }; + let mut labels: Vec = vec!["Model", "Directory", "Permissions", "Agents.md"] .into_iter() .map(str::to_string) @@ -775,6 +802,9 @@ impl StatusHistoryCell { if account_value.is_some() { push_label(&mut labels, &mut seen, "Account"); } + if workspace.is_some() { + push_label(&mut labels, &mut seen, "ChatGPT workspace"); + } if thread_name.is_some() { push_label(&mut labels, &mut seen, "Thread name"); } @@ -837,6 +867,9 @@ impl StatusHistoryCell { if let Some(account_value) = account_value { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(workspace) = workspace { + lines.push(formatter.line("ChatGPT workspace", vec![Span::from(workspace.to_owned())])); + } if let Some(thread_name) = thread_name { lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())])); diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap index 51b58ecdec..df0b8002fc 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -9,10 +9,11 @@ expression: sanitized Visit https://chatgpt.com/settings/usage for up-to-date information on rate limits and credits - Model: gpt-5.1-codex-max (reasoning none, summaries auto) - Directory: [[workspace]] - Permissions: Custom (workspace with network access, Ask for approval) - Agents.md: - Account: Enterprise (Automation) + Model: gpt-5.1-codex-max (reasoning none, summaries auto) + Directory: [[workspace]] + Permissions: Custom (workspace with network access, Ask for approval) + Agents.md: + Account: Enterprise (Automation) + ChatGPT workspace: Example workspace (account-123) - Limits: data not available yet + Limits: data not available yet diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bb76a2964e..c51f429dfa 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -358,6 +358,19 @@ async fn status_snapshot_shows_chatgpt_plan_without_email() { ..LoaderOverrides::without_managed_config_for_tests() }; let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::path("/api/codex/accounts/check")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "accounts": [{ + "id": "account-123", + "name": "Example workspace", + "workspace_backend_origin": "https://chatgpt.com", + "account_routing_override": "NO_CONSTRAINT" + }] + })), + ) + .mount(&server) + .await; wiremock::Mock::given(wiremock::matchers::path("/api/codex/config/bundle")) .respond_with(wiremock::ResponseTemplate::new(/*s*/ 200).set_body_string("{}")) .mount(&server) @@ -368,6 +381,7 @@ async fn status_snapshot_shows_chatgpt_plan_without_email() { ) .expect("configure local ChatGPT fixture"); let mut config = test_config(&temp_home).await; + config.chatgpt_base_url = server.uri(); config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; @@ -375,7 +389,9 @@ async fn status_snapshot_shows_chatgpt_plan_without_email() { write_chatgpt_auth( temp_home.path(), - ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise_cbp_automation"), + ChatGptAuthFixture::new("access-chatgpt") + .plan_type("enterprise_cbp_automation") + .account_id("account-123"), AuthCredentialsStoreMode::File, ) .expect("write email-less ChatGPT auth"); @@ -406,6 +422,7 @@ async fn status_snapshot_shows_chatgpt_plan_without_email() { StatusAccountDisplay::ChatGpt { email: None, plan: Some("Enterprise (Automation)".to_string()), + workspace: Some("Example workspace (account-123)".to_string()), } ); let usage = TokenUsage::default(); @@ -1618,6 +1635,7 @@ async fn status_snapshot_wraps_halfwidth_kana_in_narrow_terminal() { let account = StatusAccountDisplay::ChatGpt { email: Some("ガパガパガパ@example.com".to_string()), plan: Some("ガパ plan".to_string()), + workspace: None, }; let usage = TokenUsage::default(); let now = chrono::Local @@ -1928,6 +1946,7 @@ async fn status_snapshot_includes_credits_and_limits() { let account_display = Some(StatusAccountDisplay::ChatGpt { email: Some("user@example.com".into()), plan: Some("Pro 200".into()), + workspace: None, }); let usage = TokenUsage { input_tokens: 1_500, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 9533c59582..0fecdf7612 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -6866,6 +6866,13 @@ class ChatgptAccount(BaseModel): email: str | None plan_type: Annotated[PlanType, Field(alias="planType")] type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] + workspace_name: Annotated[ + str | None, + Field( + alias="workspaceName", + description="Display name of the selected ChatGPT workspace, when available.", + ), + ] = None class Account(RootModel[ApiKeyAccount | ChatgptAccount | AmazonBedrockAccount | GcpAccount]):