From 119d8dacf8e99440137bc32fed3f0a4f55718092 Mon Sep 17 00:00:00 2001 From: Chaitanya Eranki Date: Tue, 6 Jan 2026 19:06:13 -0600 Subject: [PATCH] initial changes for custom model provider --- .gitignore | 2 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 4 + .../app-server/src/codex_message_processor.rs | 1 + codex-rs/app-server/src/message_processor.rs | 1 + .../app-server/tests/common/auth_fixtures.rs | 2 + codex-rs/chatgpt/src/chatgpt_token.rs | 2 +- codex-rs/cli/src/login.rs | 10 +- codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/codex-client/src/default_client.rs | 7 + codex-rs/core/src/auth.rs | 172 ++++++++++-- codex-rs/core/src/auth/storage.rs | 10 + codex-rs/core/src/config/mod.rs | 9 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/model_provider_info.rs | 103 +++++++ codex-rs/core/src/models_manager/manager.rs | 11 + .../core/tests/chat_completions_payload.rs | 9 + codex-rs/core/tests/chat_completions_sse.rs | 9 + codex-rs/core/tests/responses_headers.rs | 27 ++ codex-rs/core/tests/suite/auth_refresh.rs | 7 +- codex-rs/core/tests/suite/client.rs | 42 ++- .../suite/stream_error_allows_next_turn.rs | 9 + .../core/tests/suite/stream_no_completed.rs | 9 + codex-rs/exec/src/lib.rs | 1 + codex-rs/login/src/device_code_auth.rs | 2 + codex-rs/login/src/lib.rs | 2 + codex-rs/login/src/server.rs | 55 +++- .../login/tests/suite/login_server_e2e.rs | 17 ++ codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/config_types.rs | 1 + codex-rs/tui/src/lib.rs | 41 ++- codex-rs/tui/src/onboarding/auth.rs | 258 ++++++++++++++++++ .../tui/src/onboarding/onboarding_screen.rs | 23 ++ codex-rs/tui/src/status/helpers.rs | 1 + codex-rs/tui/src/status/tests.rs | 2 + codex-rs/tui2/src/lib.rs | 7 +- codex-rs/tui2/src/onboarding/auth.rs | 3 + codex-rs/tui2/src/status/helpers.rs | 1 + codex-rs/tui2/src/status/tests.rs | 1 + 39 files changed, 813 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index 07bc15ccdd12..3537a4a3d1a6 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ CHANGELOG.ignore.md __pycache__/ *.pyc +.cargo/ + diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 83fa53b99739..db0ab7d5455e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -28,6 +28,7 @@ impl GitSha { pub enum AuthMode { ApiKey, ChatGPT, + ProviderOAuth, } /// Generates an `enum ClientRequest` where each variant is a request that the diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7f09216eab3d..0165c0eeddfb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -731,6 +731,10 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "providerOauth", rename_all = "camelCase")] + #[ts(rename = "providerOauth", rename_all = "camelCase")] + ProviderOauth {}, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, 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 d1804801d589..1e6f21d15665 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1036,6 +1036,7 @@ impl CodexMessageProcessor { } } } + AuthMode::ProviderOAuth => Account::ProviderOauth {}, }), None => None, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6a6cf5edb25e..f133e6e113c3 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -48,6 +48,7 @@ impl MessageProcessor { config.codex_home.clone(), false, config.cli_auth_credentials_store_mode, + config.model_provider.clone(), ); let conversation_manager = Arc::new(ConversationManager::new( auth_manager.clone(), diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 071a920b8946..a0e993e7d394 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -6,6 +6,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; use chrono::Utc; +use codex_core::CHATGPT_AUTH_MODE; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; use codex_core::auth::save_auth; @@ -129,6 +130,7 @@ pub fn write_chatgpt_auth( openai_api_key: None, tokens: Some(tokens), last_refresh, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index e8879ad21c4c..b2efac92bea0 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -23,7 +23,7 @@ pub async fn init_chatgpt_token_from_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { - let auth = CodexAuth::from_auth_storage(codex_home, auth_credentials_store_mode)?; + let auth = CodexAuth::from_auth_storage(codex_home, auth_credentials_store_mode, None)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 8fbf7b04b6d8..55dd1bea8072 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -153,7 +153,11 @@ pub async fn run_login_with_device_code( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) { + match CodexAuth::from_auth_storage( + &config.codex_home, + config.cli_auth_credentials_store_mode, + Some(config.model_provider.clone()), + ) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { @@ -169,6 +173,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::ProviderOAuth => { + eprintln!("Logged in using ProviderOAuth"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9c4ae01cd65b..757729a0bddd 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -66,6 +66,7 @@ pub async fn load_auth_manager() -> Option { config.codex_home, false, config.cli_auth_credentials_store_mode, + config.model_provider, )) } diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs index efb4d5aec415..b4f17432b69d 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/codex-client/src/default_client.rs @@ -104,6 +104,13 @@ impl CodexRequestBuilder { self.map(|builder| builder.json(value)) } + pub fn form(self, form: &T) -> Self + where + T: Serialize, + { + self.map(|builder| builder.form(form)) + } + pub async fn send(self) -> Result { let headers = trace_headers(); diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 96714e3f74b1..23067b3e9e82 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -18,6 +18,7 @@ use std::time::Duration; use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; +use crate::ModelProviderInfo; pub use crate::auth::storage::AuthCredentialsStoreMode; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -42,11 +43,13 @@ use thiserror::Error; #[derive(Debug, Clone)] pub struct CodexAuth { pub mode: AuthMode, + pub auth_mode_name: Option, pub(crate) api_key: Option, pub(crate) auth_dot_json: Arc>>, storage: Arc, pub(crate) client: CodexHttpClient, + provider: Option, } impl PartialEq for CodexAuth { @@ -66,6 +69,8 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +pub const CHATGPT_AUTH_MODE: &str = "CHATGPT_AUTH_MODE"; + #[cfg(any(test, feature = "test-support"))] static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); @@ -108,7 +113,8 @@ impl CodexAuth { })?; let token = token_data.refresh_token; - let refresh_response = try_refresh_token(token, &self.client).await?; + let refresh_response = + try_refresh_token(token, &self.client, self.mode, self.provider.clone()).await?; let updated = update_tokens( &self.storage, @@ -138,8 +144,9 @@ impl CodexAuth { pub fn from_auth_storage( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider: Option, ) -> std::io::Result> { - load_auth(codex_home, false, auth_credentials_store_mode) + load_auth(codex_home, false, auth_credentials_store_mode, provider) } pub async fn get_token_data(&self) -> Result { @@ -150,10 +157,28 @@ impl CodexAuth { last_refresh: Some(last_refresh), .. }) => { - if last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { + if (matches!(self.mode, AuthMode::ProviderOAuth) + && last_refresh + < Utc::now() + - chrono::Duration::minutes( + self.provider + .as_ref() + .unwrap_or(&ModelProviderInfo::create_openai_provider()) + .refresh_token_interval_minutes + .unwrap_or_default(), + )) + || (!matches!(self.mode, AuthMode::ProviderOAuth) + && last_refresh + < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL)) + { let refresh_result = tokio::time::timeout( Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone(), &self.client), + try_refresh_token( + tokens.refresh_token.clone(), + &self.client, + self.mode, + self.provider.clone(), + ), ) .await; let refresh_response = match refresh_result { @@ -200,6 +225,10 @@ impl CodexAuth { let id_token = self.get_token_data().await?.access_token; Ok(id_token) } + AuthMode::ProviderOAuth => { + let id_token = self.get_token_data().await?.access_token; + Ok(id_token) + } } } @@ -254,30 +283,39 @@ impl CodexAuth { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); Self { api_key: None, mode: AuthMode::ChatGPT, + auth_mode_name: Some(CHATGPT_AUTH_MODE.to_string()), storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json, client: crate::default_client::create_client(), + provider: None, } } - fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self { + fn from_api_key_with_client( + api_key: &str, + client: CodexHttpClient, + provider: Option, + ) -> Self { Self { api_key: Some(api_key.to_owned()), mode: AuthMode::ApiKey, + auth_mode_name: Some(CHATGPT_AUTH_MODE.to_string()), storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json: Arc::new(Mutex::new(None)), client, + provider, } } pub fn from_api_key(api_key: &str) -> Self { - Self::from_api_key_with_client(api_key, crate::default_client::create_client()) + Self::from_api_key_with_client(api_key, crate::default_client::create_client(), None) } } @@ -318,6 +356,7 @@ pub fn login_with_api_key( openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -350,6 +389,7 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> &config.codex_home, true, config.cli_auth_credentials_store_mode, + None, )? else { return Ok(()); @@ -359,14 +399,31 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> let method_violation = match (required_method, auth.mode) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, + (ForcedLoginMethod::ProviderOAuth, AuthMode::ProviderOAuth) => None, (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), + (ForcedLoginMethod::Api, AuthMode::ProviderOAuth) => Some( + "API key login is required, but custom provider oauth is currently being used. Logging out." + .to_string(), + ), (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some( "ChatGPT login is required, but an API key is currently being used. Logging out." .to_string(), ), + (ForcedLoginMethod::Chatgpt, AuthMode::ProviderOAuth) => Some( + "ChatGPT login is required, but an custom provider oauth is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::ProviderOAuth, AuthMode::ChatGPT) => Some( + "Custom provider oauth login is required, but ChatGPT is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::ProviderOAuth, AuthMode::ApiKey) => Some( + "Custom provider oauth login is required, but API key is currently being used. Logging out." + .to_string(), + ), }; if let Some(message) = method_violation { @@ -435,12 +492,14 @@ fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider: Option, ) -> std::io::Result> { if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { let client = crate::default_client::create_client(); return Ok(Some(CodexAuth::from_api_key_with_client( api_key.as_str(), client, + provider, ))); } @@ -456,23 +515,33 @@ fn load_auth( openai_api_key: auth_json_api_key, tokens, last_refresh, + auth_mode, } = auth_dot_json; // Prefer AuthMode.ApiKey if it's set in the auth.json. if let Some(api_key) = &auth_json_api_key { - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); + return Ok(Some(CodexAuth::from_api_key_with_client( + api_key, client, provider, + ))); } Ok(Some(CodexAuth { api_key: None, - mode: AuthMode::ChatGPT, + mode: match auth_mode.as_deref() { + Some(s) if s == CHATGPT_AUTH_MODE => AuthMode::ChatGPT, + Some(_) => AuthMode::ProviderOAuth, + None => AuthMode::ChatGPT, + }, + auth_mode_name: auth_mode.clone(), storage: storage.clone(), auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { openai_api_key: None, tokens, last_refresh, + auth_mode, }))), client, + provider, })) } @@ -504,21 +573,52 @@ async fn update_tokens( async fn try_refresh_token( refresh_token: String, client: &CodexHttpClient, + auth_mode: AuthMode, + provider: Option, ) -> Result { let refresh_request = RefreshRequest { - client_id: CLIENT_ID, + client_id: if matches!(auth_mode, AuthMode::ProviderOAuth) { + if let Some(p) = &provider { + let client_id_str = p.client_id.clone().unwrap_or_default(); + Box::leak(client_id_str.into_boxed_str()) as &'static str + } else { + CLIENT_ID + } + } else { + CLIENT_ID + }, grant_type: "refresh_token", refresh_token, scope: "openid profile email", }; - let endpoint = refresh_token_endpoint(); + let mut endpoint = refresh_token_endpoint(); + if matches!(auth_mode, AuthMode::ProviderOAuth) + && let Some(p) = &provider + { + endpoint = format!( + "{}{}", + p.issuer.clone().unwrap_or_default(), + p.refresh_token_path.clone().unwrap_or_default() + ); + } + + let mut refresh_request_builder = client.post(endpoint.as_str()); + + if let Some(p) = &provider { + if p.refresh_token_form_data.unwrap_or_default() { + refresh_request_builder = refresh_request_builder.form(&refresh_request) + } else { + refresh_request_builder = refresh_request_builder + .header("Content-Type", "application/json") + .json(&refresh_request) + } + } else { + refresh_request_builder = refresh_request_builder.json(&refresh_request) + } // Use shared client factory to include standard headers - let response = client - .post(endpoint.as_str()) - .header("Content-Type", "application/json") - .json(&refresh_request) + let response = refresh_request_builder .send() .await .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; @@ -714,7 +814,7 @@ mod tests { #[test] fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); - let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File) + let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File, None) .expect("call should succeed"); assert_eq!(auth, None); } @@ -739,9 +839,14 @@ mod tests { auth_dot_json, storage: _, .. - } = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) - .unwrap() - .unwrap(); + } = super::load_auth( + codex_home.path(), + false, + AuthCredentialsStoreMode::File, + None, + ) + .unwrap() + .unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); @@ -766,6 +871,7 @@ mod tests { account_id: None, }), last_refresh: Some(last_refresh), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }, auth_dot_json ); @@ -782,7 +888,7 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File) + let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File, None) .unwrap() .unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); @@ -798,6 +904,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -1024,9 +1131,14 @@ mod tests { ) .expect("failed to write auth file"); - let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) - .expect("load auth") - .expect("auth available"); + let auth = super::load_auth( + codex_home.path(), + false, + AuthCredentialsStoreMode::File, + None, + ) + .expect("load auth") + .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); } @@ -1044,9 +1156,14 @@ mod tests { ) .expect("failed to write auth file"); - let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) - .expect("load auth") - .expect("auth available"); + let auth = super::load_auth( + codex_home.path(), + false, + AuthCredentialsStoreMode::File, + None, + ) + .expect("load auth") + .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); } @@ -1077,11 +1194,13 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider: ModelProviderInfo, ) -> Self { let auth = load_auth( &codex_home, enable_codex_api_key_env, auth_credentials_store_mode, + Some(provider), ) .ok() .flatten(); @@ -1140,6 +1259,7 @@ impl AuthManager { &self.codex_home, self.enable_codex_api_key_env, self.auth_credentials_store_mode, + None, ) .ok() .flatten(); @@ -1165,11 +1285,13 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider: ModelProviderInfo, ) -> Arc { Arc::new(Self::new( codex_home, enable_codex_api_key_env, auth_credentials_store_mode, + provider, )) } diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index a238eb9c38ed..bc37aa427e05 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -44,6 +44,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, } pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { @@ -280,6 +283,7 @@ fn create_auth_storage_with_keyring_store( #[cfg(test)] mod tests { use super::*; + use crate::CHATGPT_AUTH_MODE; use crate::token_data::IdTokenInfo; use anyhow::Context; use base64::Engine; @@ -298,6 +302,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; storage @@ -317,6 +322,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; let file = get_auth_file(codex_home.path()); @@ -338,6 +344,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -432,6 +439,7 @@ mod tests { account_id: Some(format!("{prefix}-account-id")), }), last_refresh: None, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), } } @@ -447,6 +455,7 @@ mod tests { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; seed_keyring_with_auth( &mock_keyring, @@ -488,6 +497,7 @@ mod tests { account_id: Some("account".to_string()), }), last_refresh: Some(Utc::now()), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; storage.save(&auth)?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 53864851ae03..47ac3bc5d384 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3097,6 +3097,15 @@ model_verbosity = "high" stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 879448408355..9ff82506e988 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -68,6 +68,7 @@ pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; // Re-export common auth types for workspace consumers pub use auth::AuthManager; +pub use auth::CHATGPT_AUTH_MODE; pub use auth::CodexAuth; pub mod default_client; pub mod project_doc; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 96173922372f..ce37a7769b7b 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -99,6 +99,37 @@ pub struct ModelProviderInfo { /// and API key (if needed) comes from the "env_key" environment variable. #[serde(default)] pub requires_openai_auth: bool, + + // Does the provider use it's own oauth mechanism, if so the following fields are to do + // with configuration of it + + // Flag for whether to use custom oauth + #[serde(default)] + pub requires_custom_oauth: bool, + + // client id for oauth + pub client_id: Option, + + // issuer for oauth + pub issuer: Option, + + // refresh_token_path + pub refresh_token_path: Option, + + // issuer_path_prefix + pub issuer_path_prefix: Option, + + // redirect callback path + pub redirect_callback_path: Option, + + // refresh token form data + pub refresh_token_form_data: Option, + + // port for auth server for oauth + pub auth_server_port: Option, + + // refresh token interval in minutes + pub refresh_token_interval_minutes: Option, } impl ModelProviderInfo { @@ -247,6 +278,15 @@ impl ModelProviderInfo { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: true, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, } } @@ -320,6 +360,15 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, } } @@ -348,6 +397,15 @@ base_url = "http://localhost:11434/v1" stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -378,6 +436,15 @@ query_params = { api-version = "2025-04-01-preview" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -411,6 +478,15 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -442,6 +518,15 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let api = provider.to_api_provider(None).expect("api provider"); assert!( @@ -464,6 +549,15 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let named_api = named_provider.to_api_provider(None).expect("api provider"); assert!(named_api.is_azure_responses_endpoint()); @@ -488,6 +582,15 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let api = provider.to_api_provider(None).expect("api provider"); assert!( diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 060f4a5c2788..63e28556ac5c 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -386,6 +386,15 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, } } @@ -471,6 +480,7 @@ mod tests { codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, + config.model_provider.clone(), )); let provider = provider_for(server.uri()); let manager = ModelsManager::with_provider(auth_manager, provider); @@ -525,6 +535,7 @@ mod tests { codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, + config.model_provider.clone(), )); let provider = provider_for(server.uri()); let manager = ModelsManager::with_provider(auth_manager, provider); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 8af5df216959..18b3dc637424 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -59,6 +59,15 @@ async fn run_request(input: Vec) -> Value { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 4f05838279ad..927e6f9bbe0b 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -58,6 +58,15 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = match TempDir::new() { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 3b0ffd2983cd..570c2ef45d71 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -53,6 +53,15 @@ async fn responses_stream_includes_subagent_header_on_review() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -147,6 +156,15 @@ async fn responses_stream_includes_subagent_header_on_other() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -237,6 +255,15 @@ async fn responses_respects_model_family_overrides_from_config() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 6daaf70b5e60..68901fc57465 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -3,6 +3,7 @@ use anyhow::Result; use base64::Engine; use chrono::Duration; use chrono::Utc; +use codex_core::CHATGPT_AUTH_MODE; use codex_core::CodexAuth; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; @@ -184,6 +185,7 @@ impl RefreshTokenTestContext { openai_api_key: None, tokens: Some(tokens), last_refresh: Some(initial_last_refresh), + auth_mode: Some(CHATGPT_AUTH_MODE.to_string()), }; save_auth( codex_home.path(), @@ -194,8 +196,9 @@ impl RefreshTokenTestContext { let endpoint = format!("{}/oauth/token", server.uri()); let env_guard = EnvGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, endpoint); - let auth = CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File)? - .context("auth should load from storage")?; + let auth = + CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File, None)? + .context("auth should load from storage")?; Ok(Self { codex_home, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a22027f99fb3..a72a02ea420d 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -562,12 +562,15 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider = model_provider; - let auth_manager = - match CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File) { - Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_auth_storage( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some(config.model_provider.clone()), + ) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec); let NewConversation { conversation: codex, @@ -1116,6 +1119,15 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let codex_home = TempDir::new().unwrap(); @@ -1612,6 +1624,15 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; // Init session @@ -1694,6 +1715,15 @@ async fn env_var_overrides_loaded_auth() { stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; // Init session diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index e6f8aa95605d..7f4fb4306f69 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -73,6 +73,15 @@ async fn continue_after_stream_error() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2_000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 17a19e7c3434..f8f6b229a03d 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -81,6 +81,15 @@ async fn retries_on_early_close() { stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2000), requires_openai_auth: false, + requires_custom_oauth: false, + auth_server_port: None, + client_id: None, + issuer: None, + issuer_path_prefix: None, + redirect_callback_path: None, + refresh_token_form_data: None, + refresh_token_path: None, + refresh_token_interval_minutes: None, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 93a481b630e2..5c42146940a3 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -285,6 +285,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.codex_home.clone(), true, config.cli_auth_credentials_store_mode, + config.model_provider.clone(), ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); let default_model = conversation_manager diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index d9e7d90ce281..d5c27f48afa3 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -175,6 +175,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { let tokens = crate::server::exchange_code_for_tokens( base_url, + &opts.issuer_path_prefix, &opts.client_id, &redirect_uri, &pkce, @@ -197,6 +198,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + None, ) .await } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ac2cd28bea53..9a05dac3b734 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -3,6 +3,8 @@ mod pkce; mod server; pub use device_code_auth::run_device_code_login; +pub use server::DEFAULT_ISSUER_PATH_PREFIX; +pub use server::DEFAULT_REDIRECT_CALLBACK_PATH; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 999c19072e50..47bd05e8fa96 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -14,6 +14,8 @@ use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; +use codex_core::CHATGPT_AUTH_MODE; +use codex_core::ModelProviderInfo; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; use codex_core::auth::save_auth; @@ -30,17 +32,22 @@ use tiny_http::StatusCode; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +pub const DEFAULT_REDIRECT_CALLBACK_PATH: &str = "/auth/callback"; +pub const DEFAULT_ISSUER_PATH_PREFIX: &str = "/oauth"; #[derive(Debug, Clone)] pub struct ServerOptions { pub codex_home: PathBuf, pub client_id: String, pub issuer: String, + pub issuer_path_prefix: String, + pub redirect_callback_path: String, pub port: u16, pub open_browser: bool, pub force_state: Option, pub forced_chatgpt_workspace_id: Option, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub provider: Option, } impl ServerOptions { @@ -55,10 +62,13 @@ impl ServerOptions { client_id, issuer: DEFAULT_ISSUER.to_string(), port: DEFAULT_PORT, + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), open_browser: true, force_state: None, forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, + provider: None, } } } @@ -113,9 +123,13 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { }; let server = Arc::new(server); - let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); + let redirect_uri = format!( + "http://localhost:{actual_port}{0}", + opts.redirect_callback_path + ); let auth_url = build_authorize_url( &opts.issuer, + &opts.issuer_path_prefix, &opts.client_id, &redirect_uri, &pkce, @@ -236,7 +250,7 @@ async fn process_request( let path = parsed_url.path().to_string(); match path.as_str() { - "/auth/callback" => { + s if s == opts.redirect_callback_path.as_str() => { let params: std::collections::HashMap = parsed_url.query_pairs().into_owned().collect(); if params.get("state").map(String::as_str) != Some(state) { @@ -253,8 +267,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.issuer_path_prefix, + &opts.client_id, + redirect_uri, + pkce, + &code, + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -265,9 +286,14 @@ async fn process_request( return login_error_response(&message); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.issuer_path_prefix, + &opts.client_id, + &tokens.id_token, + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -275,6 +301,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.provider.clone(), ) .await { @@ -379,6 +406,7 @@ fn send_response_with_disconnect( fn build_authorize_url( issuer: &str, + issuer_path_prefix: &str, client_id: &str, redirect_uri: &str, pkce: &PkceCodes, @@ -414,7 +442,7 @@ fn build_authorize_url( .map(|(k, v)| format!("{k}={}", urlencoding::encode(&v))) .collect::>() .join("&"); - format!("{issuer}/oauth/authorize?{qs}") + format!("{issuer}{issuer_path_prefix}/authorize?{qs}") } fn generate_state() -> String { @@ -493,6 +521,7 @@ pub(crate) struct ExchangedTokens { pub(crate) async fn exchange_code_for_tokens( issuer: &str, + issues_path_prefix: &str, client_id: &str, redirect_uri: &str, pkce: &PkceCodes, @@ -507,7 +536,7 @@ pub(crate) async fn exchange_code_for_tokens( let client = reqwest::Client::new(); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(format!("{issuer}{issues_path_prefix}/token")) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}", @@ -542,6 +571,7 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + provider: Option, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -562,6 +592,10 @@ pub(crate) async fn persist_tokens_async( openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), + auth_mode: match provider { + None => Some(CHATGPT_AUTH_MODE.to_string()), + Some(provider) => Some(provider.name), + }, }; save_auth(&codex_home, &auth, auth_credentials_store_mode) }) @@ -687,6 +721,7 @@ fn login_error_response(message: &str) -> HandledRequest { pub(crate) async fn obtain_api_key( issuer: &str, + issuer_path_prefix: &str, client_id: &str, id_token: &str, ) -> io::Result { @@ -697,7 +732,7 @@ pub(crate) async fn obtain_api_key( } let client = reqwest::Client::new(); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(format!("{issuer}{issuer_path_prefix}/token")) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}", diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 73cb8bd420fa..dda149d48481 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -8,6 +8,8 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; use codex_core::auth::AuthCredentialsStoreMode; +use codex_login::DEFAULT_ISSUER_PATH_PREFIX; +use codex_login::DEFAULT_REDIRECT_CALLBACK_PATH; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -114,10 +116,13 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: 0, open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + provider: None, }; let server = run_login_server(opts)?; assert!( @@ -175,10 +180,13 @@ async fn creates_missing_codex_home_dir() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: 0, open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: None, + provider: None, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -214,10 +222,13 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: 0, open_browser: false, force_state: Some(state.clone()), forced_chatgpt_workspace_id: Some("org-required".to_string()), + provider: None, }; let server = run_login_server(opts)?; assert!( @@ -270,10 +281,13 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: 0, open_browser: false, force_state: Some("cancel_state".to_string()), forced_chatgpt_workspace_id: None, + provider: None, }; let first_server = run_login_server(first_opts)?; @@ -290,10 +304,13 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, + issuer_path_prefix: DEFAULT_ISSUER_PATH_PREFIX.to_string(), + redirect_callback_path: DEFAULT_REDIRECT_CALLBACK_PATH.to_string(), port: login_port, open_browser: false, force_state: Some("cancel_state_2".to_string()), forced_chatgpt_workspace_id: None, + provider: None, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 81eb80764be5..603b0ad16991 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -57,6 +57,7 @@ impl MessageProcessor { config.codex_home.clone(), false, config.cli_auth_credentials_store_mode, + config.model_provider.clone(), ); let conversation_manager = Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp)); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index a98ec4e2b2ff..e41d94b8a926 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -69,6 +69,7 @@ pub enum SandboxMode { pub enum ForcedLoginMethod { Chatgpt, Api, + ProviderOAuth, } /// Represents the trust level for a project directory. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6b784affce65..5790ed66cd87 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -385,6 +385,7 @@ async fn run_ratatui_app( initial_config.codex_home.clone(), false, initial_config.cli_auth_credentials_store_mode, + initial_config.model_provider.clone(), ); let login_status = get_login_status(&initial_config); let should_show_trust_screen = should_show_trust_screen(&initial_config); @@ -394,7 +395,11 @@ async fn run_ratatui_app( let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { - show_login_screen: should_show_login_screen(login_status, &initial_config), + show_login_screen: should_show_openai_login_screen(login_status, &initial_config), + show_custom_login_screen: should_show_custom_provider_login_screen( + login_status, + &initial_config, + ), show_trust_screen: should_show_trust_screen, login_status, auth_manager: auth_manager.clone(), @@ -533,12 +538,25 @@ pub enum LoginStatus { } fn get_login_status(config: &Config) -> LoginStatus { - if config.model_provider.requires_openai_auth { + if config.model_provider.requires_openai_auth || config.model_provider.requires_custom_oauth { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) { - Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), + match CodexAuth::from_auth_storage( + &codex_home, + config.cli_auth_credentials_store_mode, + Some(config.model_provider.clone()), + ) { + Ok(Some(auth)) => match auth.mode { + AuthMode::ChatGPT | AuthMode::ApiKey => LoginStatus::AuthMode(auth.mode), + AuthMode::ProviderOAuth => { + if auth.auth_mode_name == Some(config.model_provider.name.clone()) { + LoginStatus::AuthMode(auth.mode) + } else { + LoginStatus::NotAuthenticated + } + } + }, Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { error!("Failed to read auth.json: {err}"); @@ -589,10 +607,11 @@ fn should_show_onboarding( return true; } - should_show_login_screen(login_status, config) + should_show_openai_login_screen(login_status, config) + || should_show_custom_provider_login_screen(login_status, config) } -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { +fn should_show_openai_login_screen(login_status: LoginStatus, config: &Config) -> bool { // Only show the login screen for providers that actually require OpenAI auth // (OpenAI or equivalents). For OSS/other providers, skip login entirely. if !config.model_provider.requires_openai_auth { @@ -602,6 +621,16 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool login_status == LoginStatus::NotAuthenticated } +fn should_show_custom_provider_login_screen(login_status: LoginStatus, config: &Config) -> bool { + // Only show the login screen for providers that actually require OpenAI auth + // (OpenAI or equivalents). For OSS/other providers, skip login entirely. + if !config.model_provider.requires_custom_oauth { + return false; + } + + login_status != LoginStatus::AuthMode(AuthMode::ProviderOAuth) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 6307e6e7dc91..df98471d04f9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_core::AuthManager; +use codex_core::ModelProviderInfo; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; @@ -119,6 +120,7 @@ impl KeyboardHandler for AuthModeWidget { AuthMode::ApiKey => { self.disallow_api_login(); } + AuthMode::ProviderOAuth => {} }, SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; @@ -649,6 +651,261 @@ impl WidgetRef for AuthModeWidget { } } +#[derive(Clone)] +pub(crate) enum CustomSignInState { + PickMode, + ContinueInBrowser(ContinueInBrowserState), + SuccessMessage, + Success, +} + +#[derive(Clone)] +pub(crate) struct CustomAuthModeWidget { + pub request_frame: FrameRequester, + pub sign_in_state: Arc>, + pub codex_home: PathBuf, + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub login_status: LoginStatus, + pub auth_manager: Arc, + pub animations_enabled: bool, + pub error: Option, + pub provider: ModelProviderInfo, +} + +impl CustomAuthModeWidget { + fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { + let lines = vec![ + Line::from(vec![ + "> ".into(), + format!("Sign in with {}", self.provider.name).cyan(), + ]), + "".into(), + " Press Enter to continue".dim().into(), + ]; + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { + let mut spans = vec![" ".into()]; + if self.animations_enabled { + // Schedule a follow-up frame to keep the shimmer animation going. + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); + spans.extend(shimmer_spans("Finish signing in via your browser")); + } else { + spans.push("Finish signing in via your browser".into()); + } + let mut lines = vec![spans.into(), "".into()]; + + let sign_in_state = self.sign_in_state.read().unwrap(); + if let CustomSignInState::ContinueInBrowser(state) = &*sign_in_state + && !state.auth_url.is_empty() + { + lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); + lines.push("".into()); + lines.push(Line::from(state.auth_url.as_str().cyan().underlined())); + lines.push("".into()); + } + + lines.push(" Press Esc to cancel".dim().into()); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn render_success_message(&self, area: Rect, buf: &mut Buffer) { + let lines = vec![ + format!("✓ Signed in with your {} account", self.provider.name) + .fg(Color::Green) + .into(), + "".into(), + " Before you start:".into(), + "".into(), + " Decide how much autonomy you want to grant Codex".into(), + Line::from(vec![ + " For more details see the ".into(), + "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}" + .underlined(), + ]) + .dim(), + "".into(), + " Codex can make mistakes".into(), + " Review the code it writes and commands it runs" + .dim() + .into(), + "".into(), + format!(" Powered by your {} account", self.provider.name).into(), + Line::from(vec![" Uses your plan's rate limits".into()]).dim(), + "".into(), + " Press Enter to continue".fg(Color::Cyan).into(), + ]; + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn render_success(&self, area: Rect, buf: &mut Buffer) { + let lines = vec![ + "✓ Signed in with your custom model provider account" + .fg(Color::Green) + .into(), + ]; + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn start_custom_login(&mut self) { + // If we're already authenticated with custom provider, don't start a new login – + // just proceed to the success message flow. + if matches!( + self.login_status, + LoginStatus::AuthMode(AuthMode::ProviderOAuth) + ) && match self.auth_manager.auth() { + Some(auth) => auth.auth_mode_name == Some(self.provider.name.clone()), + None => false, + } { + *self.sign_in_state.write().unwrap() = CustomSignInState::Success; + self.request_frame.schedule_frame(); + return; + } + + self.error = None; + let opts = ServerOptions { + client_id: self.provider.client_id.clone().unwrap_or_default(), + codex_home: self.codex_home.clone(), + issuer: self.provider.issuer.clone().unwrap_or_default(), + issuer_path_prefix: self.provider.issuer_path_prefix.clone().unwrap_or_default(), + provider: Some(self.provider.clone()), + open_browser: true, + port: self.provider.auth_server_port.unwrap_or_default(), + redirect_callback_path: self + .provider + .redirect_callback_path + .clone() + .unwrap_or_default(), + cli_auth_credentials_store_mode: self.cli_auth_credentials_store_mode, + forced_chatgpt_workspace_id: None, + force_state: None, + }; + match run_login_server(opts) { + Ok(child) => { + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + let auth_manager = self.auth_manager.clone(); + tokio::spawn(async move { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + CustomSignInState::ContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + // Force the auth manager to reload the new auth information. + auth_manager.reload(); + + *sign_in_state.write().unwrap() = CustomSignInState::SuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = + CustomSignInState::ContinueInBrowser(ContinueInBrowserState { + auth_url: "".to_string(), + shutdown_flag: None, + }); + // self.error = Some(e.to_string()); + request_frame.schedule_frame(); + } + } + }); + } + Err(e) => { + *self.sign_in_state.write().unwrap() = + CustomSignInState::ContinueInBrowser(ContinueInBrowserState { + auth_url: "".to_string(), + shutdown_flag: None, + }); + self.error = Some(e.to_string()); + self.request_frame.schedule_frame(); + } + } + } +} + +impl KeyboardHandler for CustomAuthModeWidget { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Enter => { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + CustomSignInState::PickMode => { + self.start_custom_login(); + } + CustomSignInState::SuccessMessage => { + *self.sign_in_state.write().unwrap() = CustomSignInState::Success; + } + _ => {} + } + } + KeyCode::Esc => { + tracing::info!("Esc pressed"); + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + if matches!(sign_in_state, CustomSignInState::ContinueInBrowser(_)) { + *self.sign_in_state.write().unwrap() = CustomSignInState::PickMode; + self.request_frame.schedule_frame(); + } + } + _ => {} + } + } + + fn handle_paste(&mut self, _pasted: String) { + // No paste handling for custom auth + } +} + +impl StepStateProvider for CustomAuthModeWidget { + fn get_step_state(&self) -> StepState { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { + CustomSignInState::PickMode + | CustomSignInState::ContinueInBrowser(_) + | CustomSignInState::SuccessMessage => StepState::InProgress, + CustomSignInState::Success => StepState::Complete, + } + } +} + +impl WidgetRef for CustomAuthModeWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { + CustomSignInState::PickMode => { + self.render_pick_mode(area, buf); + } + CustomSignInState::ContinueInBrowser(_) => { + self.render_continue_in_browser(area, buf); + } + CustomSignInState::SuccessMessage => { + self.render_success_message(area, buf); + } + CustomSignInState::Success => { + self.render_success(area, buf); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -672,6 +929,7 @@ mod tests { codex_home_path, false, AuthCredentialsStoreMode::File, + ModelProviderInfo::create_openai_provider(), ), forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 14999b2229fc..389d78891b84 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -16,6 +16,8 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::CustomAuthModeWidget; +use crate::onboarding::auth::CustomSignInState; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -31,6 +33,7 @@ use std::sync::RwLock; enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), + CustomProviderAuth(CustomAuthModeWidget), TrustDirectory(TrustDirectoryWidget), } @@ -60,6 +63,7 @@ pub(crate) struct OnboardingScreen { pub(crate) struct OnboardingScreenArgs { pub show_trust_screen: bool, pub show_login_screen: bool, + pub show_custom_login_screen: bool, pub login_status: LoginStatus, pub auth_manager: Arc, pub config: Config, @@ -75,6 +79,7 @@ impl OnboardingScreen { let OnboardingScreenArgs { show_trust_screen, show_login_screen, + show_custom_login_screen, login_status, auth_manager, config, @@ -108,6 +113,18 @@ impl OnboardingScreen { forced_login_method, animations_enabled: config.animations, })) + } else if show_custom_login_screen { + steps.push(Step::CustomProviderAuth(CustomAuthModeWidget { + request_frame: tui.frame_requester(), + auth_manager, + cli_auth_credentials_store_mode, + codex_home: codex_home.clone(), + error: None, + login_status, + sign_in_state: Arc::new(RwLock::new(CustomSignInState::PickMode)), + animations_enabled: config.animations, + provider: config.model_provider, + })); } let is_git_repo = get_git_repo_root(&cwd).is_some(); let highlighted = if is_git_repo { @@ -334,6 +351,7 @@ impl KeyboardHandler for Step { match self { Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), + Step::CustomProviderAuth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } } @@ -342,6 +360,7 @@ impl KeyboardHandler for Step { match self { Step::Welcome(_) => {} Step::Auth(widget) => widget.handle_paste(pasted), + Step::CustomProviderAuth(widget) => widget.handle_paste(pasted), Step::TrustDirectory(widget) => widget.handle_paste(pasted), } } @@ -352,6 +371,7 @@ impl StepStateProvider for Step { match self { Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), + Step::CustomProviderAuth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), } } @@ -366,6 +386,9 @@ impl WidgetRef for Step { Step::Auth(widget) => { widget.render_ref(area, buf); } + Step::CustomProviderAuth(widget) => { + widget.render_ref(area, buf); + } Step::TrustDirectory(widget) => { widget.render_ref(area, buf); } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 8ba7ec37751d..53dc446c84e1 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -99,6 +99,7 @@ pub(crate) fn compose_account_display( Some(StatusAccountDisplay::ChatGpt { email, plan }) } AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey), + AuthMode::ProviderOAuth => Some(StatusAccountDisplay::ApiKey), // TODO: fix this } } diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index c6f6c7359953..8c7d8e4b1ed4 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -5,6 +5,7 @@ use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; use codex_core::AuthManager; +use codex_core::ModelProviderInfo; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::models_manager::manager::ModelsManager; @@ -34,6 +35,7 @@ fn test_auth_manager(config: &Config) -> AuthManager { config.codex_home.clone(), false, config.cli_auth_credentials_store_mode, + ModelProviderInfo::create_openai_provider(), ) } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 3c5ac92f6c35..b797fbd69479 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -401,6 +401,7 @@ async fn run_ratatui_app( initial_config.codex_home.clone(), false, initial_config.cli_auth_credentials_store_mode, + initial_config.model_provider.clone(), ); let login_status = get_login_status(&initial_config); let should_show_trust_screen = should_show_trust_screen(&initial_config); @@ -572,7 +573,11 @@ fn get_login_status(config: &Config) -> LoginStatus { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) { + match CodexAuth::from_auth_storage( + &codex_home, + config.cli_auth_credentials_store_mode, + Some(config.model_provider.clone()), + ) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 6307e6e7dc91..1d7f014e8c02 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -119,6 +119,7 @@ impl KeyboardHandler for AuthModeWidget { AuthMode::ApiKey => { self.disallow_api_login(); } + AuthMode::ProviderOAuth => {} }, SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; @@ -655,6 +656,7 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + use codex_core::ModelProviderInfo; use codex_core::auth::AuthCredentialsStoreMode; fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { @@ -672,6 +674,7 @@ mod tests { codex_home_path, false, AuthCredentialsStoreMode::File, + ModelProviderInfo::create_openai_provider(), ), forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), diff --git a/codex-rs/tui2/src/status/helpers.rs b/codex-rs/tui2/src/status/helpers.rs index 8ba7ec37751d..bcbc56630d0f 100644 --- a/codex-rs/tui2/src/status/helpers.rs +++ b/codex-rs/tui2/src/status/helpers.rs @@ -99,6 +99,7 @@ pub(crate) fn compose_account_display( Some(StatusAccountDisplay::ChatGpt { email, plan }) } AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey), + AuthMode::ProviderOAuth => Some(StatusAccountDisplay::ApiKey), } } diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 7eb18dd48bb1..3478b239598d 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -34,6 +34,7 @@ fn test_auth_manager(config: &Config) -> AuthManager { config.codex_home.clone(), false, config.cli_auth_credentials_store_mode, + config.model_provider.clone(), ) }