Files
codex/codex-rs/app-server/src/request_processors/account_processor.rs
aaronl-openai 9f06d171e2 Preserve session MCP config on refresh (#21055)
# Overview
MCP refreshes were rebuilding active threads from fresh disk-backed
config only, which dropped thread-start session overlays such as
app-injected MCP servers. This keeps refreshes current with disk config
while preserving the thread-local config that only the active thread
knows about.

# Changes
- Rebuild refreshed config per active thread using that thread's current
`cwd`, rather than fanning out one app-server config to every thread.
- Preserve each thread's `SessionFlags` layer while replacing reloadable
config layers with freshly loaded config, then derive the MCP refresh
payload from the rebuilt result.
- Move MCP refresh orchestration into app-server so manual refreshes
fail loudly while background refreshes remain best-effort, and route
plugin-triggered refreshes through the same per-thread reload path.
- Add regression coverage for session overlays, fresh project config,
plugin-derived MCP config, current requirements, and strict vs
best-effort refresh behavior.

# Verification
- Passed focused Rust coverage for the thread-config rebuild behavior
and deferred MCP refresh flow, plus `cargo test -p codex-app-server
--lib`.
- Verified end to end in the Codex dev app against the locally built
CLI: registered an MCP via thread config, verified that it could be used
successfully before refresh, manually triggered MCP refresh, and
verified that it continued to be available afterward.
2026-05-05 21:09:28 -07:00

953 lines
32 KiB
Rust

use super::*;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
enum ActiveLogin {
Browser {
shutdown_handle: ShutdownHandle,
login_id: Uuid,
},
DeviceCode {
cancel: CancellationToken,
login_id: Uuid,
},
}
impl ActiveLogin {
fn login_id(&self) -> Uuid {
match self {
ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => {
*login_id
}
}
}
fn cancel(&self) {
match self {
ActiveLogin::Browser {
shutdown_handle, ..
} => shutdown_handle.shutdown(),
ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(),
}
}
}
#[derive(Clone, Copy, Debug)]
enum CancelLoginError {
NotFound,
}
enum RefreshTokenRequestOutcome {
NotAttemptedOrSucceeded,
FailedTransiently,
FailedPermanently,
}
impl Drop for ActiveLogin {
fn drop(&mut self) {
self.cancel();
}
}
#[derive(Clone)]
pub(crate) struct AccountRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
config_manager: ConfigManager,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
}
impl AccountRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
config_manager: ConfigManager,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
config,
config_manager,
active_login: Arc::new(Mutex::new(None)),
}
}
pub(crate) async fn login_account(
&self,
request_id: ConnectionRequestId,
params: LoginAccountParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.login_v2(request_id, params).await.map(|()| None)
}
pub(crate) async fn logout_account(
&self,
request_id: ConnectionRequestId,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.logout_v2(request_id).await.map(|()| None)
}
pub(crate) async fn cancel_login_account(
&self,
params: CancelLoginAccountParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.cancel_login_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn get_account(
&self,
params: GetAccountParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn get_auth_status(
&self,
params: GetAuthStatusParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_auth_status_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn get_account_rate_limits(
&self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_rate_limits_response()
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn send_add_credits_nudge_email(
&self,
params: SendAddCreditsNudgeEmailParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.send_add_credits_nudge_email_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn cancel_active_login(&self) {
let mut guard = self.active_login.lock().await;
if let Some(active_login) = guard.take() {
drop(active_login);
}
}
pub(crate) fn clear_external_auth(&self) {
self.auth_manager.clear_external_auth();
}
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
}
}
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
config_manager: &ConfigManager,
thread_manager: &Arc<ThreadManager>,
auth: Option<CodexAuth>,
) {
match config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
{
Ok(config) => {
let refresh_thread_manager = Arc::clone(thread_manager);
let refresh_config_manager = config_manager.clone();
thread_manager
.plugins_manager()
.maybe_start_remote_installed_plugins_cache_refresh(
&config.plugins_config_input(),
auth,
Some(Arc::new(move || {
Self::spawn_effective_plugins_changed_task(
Arc::clone(&refresh_thread_manager),
refresh_config_manager.clone(),
);
})),
);
}
Err(err) => {
warn!(
"failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}"
);
}
}
}
fn spawn_effective_plugins_changed_task(
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
) {
tokio::spawn(async move {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
if thread_manager.list_thread_ids().await.is_empty() {
return;
}
crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await;
});
}
async fn login_v2(
&self,
request_id: ConnectionRequestId,
params: LoginAccountParams,
) -> Result<(), JSONRPCErrorError> {
match params {
LoginAccountParams::ApiKey { api_key } => {
self.login_api_key_v2(request_id, LoginApiKeyParams { api_key })
.await;
}
LoginAccountParams::Chatgpt {
codex_streamlined_login,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
self.login_chatgpt_device_code_v2(request_id).await;
}
LoginAccountParams::ChatgptAuthTokens {
access_token,
chatgpt_account_id,
chatgpt_plan_type,
} => {
self.login_chatgpt_auth_tokens(
request_id,
access_token,
chatgpt_account_id,
chatgpt_plan_type,
)
.await;
}
}
Ok(())
}
fn external_auth_active_error(&self) -> JSONRPCErrorError {
invalid_request(
"External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.",
)
}
async fn login_api_key_common(
&self,
params: &LoginApiKeyParams,
) -> std::result::Result<(), JSONRPCErrorError> {
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Chatgpt)
) {
return Err(invalid_request(
"API key login is disabled. Use ChatGPT login instead.",
));
}
// Cancel any active login attempt.
{
let mut guard = self.active_login.lock().await;
if let Some(active) = guard.take() {
drop(active);
}
}
match login_with_api_key(
&self.config.codex_home,
&params.api_key,
self.config.cli_auth_credentials_store_mode,
) {
Ok(()) => {
self.auth_manager.reload().await;
Ok(())
}
Err(err) => Err(internal_error(format!("failed to save api key: {err}"))),
}
}
async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) {
let result = self
.login_api_key_common(&params)
.await
.map(|()| LoginAccountResponse::ApiKey {});
let logged_in = result.is_ok();
self.outgoing.send_result(request_id, result).await;
if logged_in {
self.send_login_success_notifications(/*login_id*/ None)
.await;
}
}
// Build options for a ChatGPT login attempt; performs validation.
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
return Err(invalid_request(
"ChatGPT login is disabled. Use API key login instead.",
));
}
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
CLIENT_ID.to_string(),
config.forced_chatgpt_workspace_id.clone(),
config.cli_auth_credentials_store_mode,
)
};
#[cfg(debug_assertions)]
let opts = {
let mut opts = opts;
if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR)
&& !issuer.trim().is_empty()
{
opts.issuer = issuer;
}
opts
};
Ok(opts)
}
fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError {
let is_not_found = err.kind() == std::io::ErrorKind::NotFound;
if is_not_found {
invalid_request(err.to_string())
} else {
internal_error(format!("failed to request device code: {err}"))
}
}
async fn login_chatgpt_v2(
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
let shutdown_handle = server.cancel_handle();
// Replace active login if present.
{
let mut guard = self.active_login.lock().await;
if let Some(existing) = guard.take() {
drop(existing);
}
*guard = Some(ActiveLogin::Browser {
shutdown_handle: shutdown_handle.clone(),
login_id,
});
}
let outgoing_clone = self.outgoing.clone();
let config_manager = self.config_manager.clone();
let thread_manager = Arc::clone(&self.thread_manager);
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let active_login = self.active_login.clone();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
LOGIN_CHATGPT_TIMEOUT,
server.block_until_done(),
)
.await
{
Ok(Ok(())) => (true, None),
Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))),
Err(_elapsed) => {
shutdown_handle.shutdown();
(false, Some("Login timed out".to_string()))
}
};
Self::send_chatgpt_login_completion_notifications(
&outgoing_clone,
config_manager,
thread_manager,
chatgpt_base_url,
login_id,
success,
error_msg,
)
.await;
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
let mut guard = active_login.lock().await;
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
*guard = None;
}
});
Ok(LoginAccountResponse::Chatgpt {
login_id: login_id.to_string(),
auth_url,
})
}
async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) {
let result = self.login_chatgpt_device_code_response().await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_device_code_response(
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.await?;
let device_code = request_device_code(&opts)
.await
.map_err(Self::login_chatgpt_device_code_start_error)?;
let login_id = Uuid::new_v4();
let cancel = CancellationToken::new();
{
let mut guard = self.active_login.lock().await;
if let Some(existing) = guard.take() {
drop(existing);
}
*guard = Some(ActiveLogin::DeviceCode {
cancel: cancel.clone(),
login_id,
});
}
let verification_url = device_code.verification_url.clone();
let user_code = device_code.user_code.clone();
let outgoing_clone = self.outgoing.clone();
let config_manager = self.config_manager.clone();
let thread_manager = Arc::clone(&self.thread_manager);
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let active_login = self.active_login.clone();
tokio::spawn(async move {
let (success, error_msg) = tokio::select! {
_ = cancel.cancelled() => {
(false, Some("Login was not completed".to_string()))
}
r = complete_device_code_login(opts, device_code) => {
match r {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
}
}
};
Self::send_chatgpt_login_completion_notifications(
&outgoing_clone,
config_manager,
thread_manager,
chatgpt_base_url,
login_id,
success,
error_msg,
)
.await;
let mut guard = active_login.lock().await;
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
*guard = None;
}
});
Ok(LoginAccountResponse::ChatgptDeviceCode {
login_id: login_id.to_string(),
verification_url,
user_code,
})
}
async fn cancel_login_chatgpt_common(
&self,
login_id: Uuid,
) -> std::result::Result<(), CancelLoginError> {
let mut guard = self.active_login.lock().await;
if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) {
if let Some(active) = guard.take() {
drop(active);
}
Ok(())
} else {
Err(CancelLoginError::NotFound)
}
}
async fn cancel_login_response(
&self,
params: CancelLoginAccountParams,
) -> Result<CancelLoginAccountResponse, JSONRPCErrorError> {
let login_id = params.login_id;
let uuid = Uuid::parse_str(&login_id)
.map_err(|_| invalid_request(format!("invalid login id: {login_id}")))?;
let status = match self.cancel_login_chatgpt_common(uuid).await {
Ok(()) => CancelLoginAccountStatus::Canceled,
Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound,
};
Ok(CancelLoginAccountResponse { status })
}
async fn login_chatgpt_auth_tokens(
&self,
request_id: ConnectionRequestId,
access_token: String,
chatgpt_account_id: String,
chatgpt_plan_type: Option<String>,
) {
let result = self
.login_chatgpt_auth_tokens_response(access_token, chatgpt_account_id, chatgpt_plan_type)
.await;
let logged_in = result.is_ok();
self.outgoing.send_result(request_id, result).await;
if logged_in {
self.send_login_success_notifications(/*login_id*/ None)
.await;
}
}
async fn login_chatgpt_auth_tokens_response(
&self,
access_token: String,
chatgpt_account_id: String,
chatgpt_plan_type: Option<String>,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Api)
) {
return Err(invalid_request(
"External ChatGPT auth is disabled. Use API key login instead.",
));
}
// Cancel any active login attempt to avoid persisting managed auth state.
{
let mut guard = self.active_login.lock().await;
if let Some(active) = guard.take() {
drop(active);
}
}
if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref()
&& chatgpt_account_id != expected_workspace
{
return Err(invalid_request(format!(
"External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}."
)));
}
login_with_chatgpt_auth_tokens(
&self.config.codex_home,
&access_token,
&chatgpt_account_id,
chatgpt_plan_type.as_deref(),
)
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await;
self.config_manager.replace_cloud_requirements_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
);
self.config_manager
.sync_default_client_residency_requirement()
.await;
Ok(LoginAccountResponse::ChatgptAuthTokens {})
}
async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
&self.config_manager,
&self.thread_manager,
self.auth_manager.auth_cached(),
)
.await;
let payload_login_completed = AccountLoginCompletedNotification {
login_id: login_id.map(|id| id.to_string()),
success: true,
error: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted(
payload_login_completed,
))
.await;
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(
self.current_account_updated_notification(),
))
.await;
}
async fn send_chatgpt_login_completion_notifications(
outgoing: &OutgoingMessageSender,
config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>,
chatgpt_base_url: String,
login_id: Uuid,
success: bool,
error_msg: Option<String>,
) {
let payload_v2 = AccountLoginCompletedNotification {
login_id: Some(login_id.to_string()),
success,
error: error_msg,
};
outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2))
.await;
if success {
let auth_manager = thread_manager.auth_manager();
auth_manager.reload().await;
config_manager
.replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url);
config_manager
.sync_default_client_residency_requirement()
.await;
let auth = auth_manager.auth_cached();
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
&config_manager,
&thread_manager,
auth.clone(),
)
.await;
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
.await;
}
}
async fn logout_common(&self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
// Cancel any active login attempt.
{
let mut guard = self.active_login.lock().await;
if let Some(active) = guard.take() {
drop(active);
}
}
match self.auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {
return Err(internal_error(format!("logout failed: {err}")));
}
}
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
&self.config_manager,
&self.thread_manager,
self.auth_manager.auth_cached(),
)
.await;
// Reflect the current auth method after logout (likely None).
Ok(self
.auth_manager
.auth_cached()
.as_ref()
.map(CodexAuth::api_auth_mode))
}
async fn logout_v2(&self, request_id: ConnectionRequestId) -> Result<(), JSONRPCErrorError> {
let result = self.logout_common().await;
let account_updated =
result
.as_ref()
.ok()
.cloned()
.map(|auth_mode| AccountUpdatedNotification {
auth_mode,
plan_type: None,
});
self.outgoing
.send_result(request_id, result.map(|_| LogoutAccountResponse {}))
.await;
if let Some(payload) = account_updated {
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload))
.await;
}
Ok(())
}
async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome {
if self.auth_manager.is_external_chatgpt_auth_active() {
return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded;
}
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
let failed_reason = err.failed_reason();
if failed_reason.is_none() {
tracing::warn!("failed to refresh token while getting account: {err}");
return RefreshTokenRequestOutcome::FailedTransiently;
}
return RefreshTokenRequestOutcome::FailedPermanently;
}
RefreshTokenRequestOutcome::NotAttemptedOrSucceeded
}
async fn get_auth_status_response(
&self,
params: GetAuthStatusParams,
) -> Result<GetAuthStatusResponse, JSONRPCErrorError> {
let include_token = params.include_token.unwrap_or(false);
let do_refresh = params.refresh_token.unwrap_or(false);
self.refresh_token_if_requested(do_refresh).await;
// Determine whether auth is required based on the active model provider.
// If a custom provider is configured with `requires_openai_auth == false`,
// then no auth step is required; otherwise, default to requiring auth.
let requires_openai_auth = self.config.model_provider.requires_openai_auth;
let response = if !requires_openai_auth {
GetAuthStatusResponse {
auth_method: None,
auth_token: None,
requires_openai_auth: Some(false),
}
} else {
let auth = if do_refresh {
self.auth_manager.auth_cached()
} else {
self.auth_manager.auth().await
};
match auth {
Some(auth) => {
let permanent_refresh_failure =
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
let auth_mode = auth.api_auth_mode();
let (reported_auth_method, token_opt) =
if matches!(auth, CodexAuth::AgentIdentity(_))
|| include_token && permanent_refresh_failure
{
(Some(auth_mode), None)
} else {
match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
}
};
GetAuthStatusResponse {
auth_method: reported_auth_method,
auth_token: token_opt,
requires_openai_auth: Some(true),
}
}
None => GetAuthStatusResponse {
auth_method: None,
auth_token: None,
requires_openai_auth: Some(true),
},
}
};
Ok(response)
}
async fn get_account_response(
&self,
params: GetAccountParams,
) -> Result<GetAccountResponse, JSONRPCErrorError> {
let do_refresh = params.refresh_token;
self.refresh_token_if_requested(do_refresh).await;
let provider = create_model_provider(
self.config.model_provider.clone(),
Some(self.auth_manager.clone()),
);
let account_state = match provider.account_state() {
Ok(account_state) => account_state,
Err(ProviderAccountError::MissingChatgptAccountDetails) => {
return Err(invalid_request(
"email and plan type are required for chatgpt authentication",
));
}
};
let account = account_state.account.map(Account::from);
Ok(GetAccountResponse {
account,
requires_openai_auth: account_state.requires_openai_auth,
})
}
async fn get_account_rate_limits_response(
&self,
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
self.fetch_account_rate_limits()
.await
.map(
|(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
},
)
}
async fn send_add_credits_nudge_email_response(
&self,
params: SendAddCreditsNudgeEmailParams,
) -> Result<SendAddCreditsNudgeEmailResponse, JSONRPCErrorError> {
self.send_add_credits_nudge_email_inner(params)
.await
.map(|status| SendAddCreditsNudgeEmailResponse { status })
}
async fn send_add_credits_nudge_email_inner(
&self,
params: SendAddCreditsNudgeEmailParams,
) -> Result<AddCreditsNudgeEmailStatus, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to notify workspace owner",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to notify workspace owner",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
match client
.send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type))
.await
{
Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent),
Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
Err(err) => Err(internal_error(format!(
"failed to notify workspace owner: {err}"
))),
}
}
fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType {
match value {
AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits,
AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit,
}
}
async fn fetch_account_rate_limits(
&self,
) -> Result<
(
CoreRateLimitSnapshot,
HashMap<String, CoreRateLimitSnapshot>,
),
JSONRPCErrorError,
> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let snapshots = client
.get_rate_limits_many()
.await
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if snapshots.is_empty() {
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
.iter()
.cloned()
.map(|snapshot| {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
(limit_id, snapshot)
})
.collect();
let primary = snapshots
.iter()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
.cloned()
.unwrap_or_else(|| snapshots[0].clone());
Ok((primary, rate_limits_by_limit_id))
}
}