[App-server] Implement account/read endpoint (#6336)
This PR does two things: 1. add a new function in core that maps the core-internal plan type to the external plan type; 2. implement account/read that get account status (v2 of `getAuthStatus`).
This commit is contained in:
@@ -191,7 +191,7 @@ client_request_definitions! {
|
|||||||
#[serde(rename = "account/read")]
|
#[serde(rename = "account/read")]
|
||||||
#[ts(rename = "account/read")]
|
#[ts(rename = "account/read")]
|
||||||
GetAccount {
|
GetAccount {
|
||||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
params: v2::GetAccountParams,
|
||||||
response: v2::GetAccountResponse,
|
response: v2::GetAccountResponse,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -263,6 +263,7 @@ client_request_definitions! {
|
|||||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||||
response: v1::LogoutChatGptResponse,
|
response: v1::LogoutChatGptResponse,
|
||||||
},
|
},
|
||||||
|
/// DEPRECATED in favor of GetAccount
|
||||||
GetAuthStatus {
|
GetAuthStatus {
|
||||||
params: v1::GetAuthStatusParams,
|
params: v1::GetAuthStatusParams,
|
||||||
response: v1::GetAuthStatusResponse,
|
response: v1::GetAuthStatusResponse,
|
||||||
@@ -758,12 +759,17 @@ mod tests {
|
|||||||
fn serialize_get_account() -> Result<()> {
|
fn serialize_get_account() -> Result<()> {
|
||||||
let request = ClientRequest::GetAccount {
|
let request = ClientRequest::GetAccount {
|
||||||
request_id: RequestId::Integer(5),
|
request_id: RequestId::Integer(5),
|
||||||
params: None,
|
params: v2::GetAccountParams {
|
||||||
|
refresh_token: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json!({
|
json!({
|
||||||
"method": "account/read",
|
"method": "account/read",
|
||||||
"id": 5,
|
"id": 5,
|
||||||
|
"params": {
|
||||||
|
"refreshToken": false
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
serde_json::to_value(&request)?,
|
serde_json::to_value(&request)?,
|
||||||
);
|
);
|
||||||
@@ -772,19 +778,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn account_serializes_fields_in_camel_case() -> Result<()> {
|
fn account_serializes_fields_in_camel_case() -> Result<()> {
|
||||||
let api_key = v2::Account::ApiKey {
|
let api_key = v2::Account::ApiKey {};
|
||||||
api_key: "secret".to_string(),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json!({
|
json!({
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"apiKey": "secret",
|
|
||||||
}),
|
}),
|
||||||
serde_json::to_value(&api_key)?,
|
serde_json::to_value(&api_key)?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let chatgpt = v2::Account::Chatgpt {
|
let chatgpt = v2::Account::Chatgpt {
|
||||||
email: Some("user@example.com".to_string()),
|
email: "user@example.com".to_string(),
|
||||||
plan_type: PlanType::Plus,
|
plan_type: PlanType::Plus,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -123,14 +123,11 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
|
|||||||
pub enum Account {
|
pub enum Account {
|
||||||
#[serde(rename = "apiKey", rename_all = "camelCase")]
|
#[serde(rename = "apiKey", rename_all = "camelCase")]
|
||||||
#[ts(rename = "apiKey", rename_all = "camelCase")]
|
#[ts(rename = "apiKey", rename_all = "camelCase")]
|
||||||
ApiKey { api_key: String },
|
ApiKey {},
|
||||||
|
|
||||||
#[serde(rename = "chatgpt", rename_all = "camelCase")]
|
#[serde(rename = "chatgpt", rename_all = "camelCase")]
|
||||||
#[ts(rename = "chatgpt", rename_all = "camelCase")]
|
#[ts(rename = "chatgpt", rename_all = "camelCase")]
|
||||||
Chatgpt {
|
Chatgpt { email: String, plan_type: PlanType },
|
||||||
email: Option<String>,
|
|
||||||
plan_type: PlanType,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
@@ -193,11 +190,20 @@ pub struct GetAccountRateLimitsResponse {
|
|||||||
pub rate_limits: RateLimitSnapshot,
|
pub rate_limits: RateLimitSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export_to = "v2/")]
|
||||||
|
pub struct GetAccountParams {
|
||||||
|
#[serde(default)]
|
||||||
|
pub refresh_token: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
pub struct GetAccountResponse {
|
pub struct GetAccountResponse {
|
||||||
pub account: Account,
|
pub account: Option<Account>,
|
||||||
|
pub requires_openai_auth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search;
|
|||||||
use crate::models::supported_models;
|
use crate::models::supported_models;
|
||||||
use crate::outgoing_message::OutgoingMessageSender;
|
use crate::outgoing_message::OutgoingMessageSender;
|
||||||
use crate::outgoing_message::OutgoingNotification;
|
use crate::outgoing_message::OutgoingNotification;
|
||||||
|
use codex_app_server_protocol::Account;
|
||||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||||
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||||
@@ -30,7 +31,9 @@ use codex_app_server_protocol::FeedbackUploadParams;
|
|||||||
use codex_app_server_protocol::FeedbackUploadResponse;
|
use codex_app_server_protocol::FeedbackUploadResponse;
|
||||||
use codex_app_server_protocol::FuzzyFileSearchParams;
|
use codex_app_server_protocol::FuzzyFileSearchParams;
|
||||||
use codex_app_server_protocol::FuzzyFileSearchResponse;
|
use codex_app_server_protocol::FuzzyFileSearchResponse;
|
||||||
|
use codex_app_server_protocol::GetAccountParams;
|
||||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||||
|
use codex_app_server_protocol::GetAccountResponse;
|
||||||
use codex_app_server_protocol::GetAuthStatusParams;
|
use codex_app_server_protocol::GetAuthStatusParams;
|
||||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||||
use codex_app_server_protocol::GetConversationSummaryParams;
|
use codex_app_server_protocol::GetConversationSummaryParams;
|
||||||
@@ -273,12 +276,8 @@ impl CodexMessageProcessor {
|
|||||||
ClientRequest::CancelLoginAccount { request_id, params } => {
|
ClientRequest::CancelLoginAccount { request_id, params } => {
|
||||||
self.cancel_login_v2(request_id, params).await;
|
self.cancel_login_v2(request_id, params).await;
|
||||||
}
|
}
|
||||||
ClientRequest::GetAccount {
|
ClientRequest::GetAccount { request_id, params } => {
|
||||||
request_id,
|
self.get_account(request_id, params).await;
|
||||||
params: _,
|
|
||||||
} => {
|
|
||||||
self.send_unimplemented_error(request_id, "account/read")
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
ClientRequest::ResumeConversation { request_id, params } => {
|
ClientRequest::ResumeConversation { request_id, params } => {
|
||||||
self.handle_resume_conversation(request_id, params).await;
|
self.handle_resume_conversation(request_id, params).await;
|
||||||
@@ -801,13 +800,17 @@ impl CodexMessageProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn refresh_token_if_requested(&self, do_refresh: bool) {
|
||||||
|
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
|
||||||
|
tracing::warn!("failed to refresh token whilte getting account: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) {
|
async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) {
|
||||||
let include_token = params.include_token.unwrap_or(false);
|
let include_token = params.include_token.unwrap_or(false);
|
||||||
let do_refresh = params.refresh_token.unwrap_or(false);
|
let do_refresh = params.refresh_token.unwrap_or(false);
|
||||||
|
|
||||||
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
|
self.refresh_token_if_requested(do_refresh).await;
|
||||||
tracing::warn!("failed to refresh token while getting auth status: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine whether auth is required based on the active model provider.
|
// Determine whether auth is required based on the active model provider.
|
||||||
// If a custom provider is configured with `requires_openai_auth == false`,
|
// If a custom provider is configured with `requires_openai_auth == false`,
|
||||||
@@ -852,6 +855,56 @@ impl CodexMessageProcessor {
|
|||||||
self.outgoing.send_response(request_id, response).await;
|
self.outgoing.send_response(request_id, response).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_account(&self, request_id: RequestId, params: GetAccountParams) {
|
||||||
|
let do_refresh = params.refresh_token;
|
||||||
|
|
||||||
|
self.refresh_token_if_requested(do_refresh).await;
|
||||||
|
|
||||||
|
// Whether auth is required for the active model provider.
|
||||||
|
let requires_openai_auth = self.config.model_provider.requires_openai_auth;
|
||||||
|
|
||||||
|
if !requires_openai_auth {
|
||||||
|
let response = GetAccountResponse {
|
||||||
|
account: None,
|
||||||
|
requires_openai_auth,
|
||||||
|
};
|
||||||
|
self.outgoing.send_response(request_id, response).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = match self.auth_manager.auth() {
|
||||||
|
Some(auth) => Some(match auth.mode {
|
||||||
|
AuthMode::ApiKey => Account::ApiKey {},
|
||||||
|
AuthMode::ChatGPT => {
|
||||||
|
let email = auth.get_account_email();
|
||||||
|
let plan_type = auth.account_plan_type();
|
||||||
|
|
||||||
|
match (email, plan_type) {
|
||||||
|
(Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type },
|
||||||
|
_ => {
|
||||||
|
let error = JSONRPCErrorError {
|
||||||
|
code: INVALID_REQUEST_ERROR_CODE,
|
||||||
|
message:
|
||||||
|
"email and plan type are required for chatgpt authentication"
|
||||||
|
.to_string(),
|
||||||
|
data: None,
|
||||||
|
};
|
||||||
|
self.outgoing.send_error(request_id, error).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = GetAccountResponse {
|
||||||
|
account,
|
||||||
|
requires_openai_auth,
|
||||||
|
};
|
||||||
|
self.outgoing.send_response(request_id, response).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_user_agent(&self, request_id: RequestId) {
|
async fn get_user_agent(&self, request_id: RequestId) {
|
||||||
let user_agent = get_codex_user_agent();
|
let user_agent = get_codex_user_agent();
|
||||||
let response = GetUserAgentResponse { user_agent };
|
let response = GetUserAgentResponse { user_agent };
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use codex_app_server_protocol::CancelLoginChatGptParams;
|
|||||||
use codex_app_server_protocol::ClientInfo;
|
use codex_app_server_protocol::ClientInfo;
|
||||||
use codex_app_server_protocol::ClientNotification;
|
use codex_app_server_protocol::ClientNotification;
|
||||||
use codex_app_server_protocol::FeedbackUploadParams;
|
use codex_app_server_protocol::FeedbackUploadParams;
|
||||||
|
use codex_app_server_protocol::GetAccountParams;
|
||||||
use codex_app_server_protocol::GetAuthStatusParams;
|
use codex_app_server_protocol::GetAuthStatusParams;
|
||||||
use codex_app_server_protocol::InitializeParams;
|
use codex_app_server_protocol::InitializeParams;
|
||||||
use codex_app_server_protocol::InterruptConversationParams;
|
use codex_app_server_protocol::InterruptConversationParams;
|
||||||
@@ -249,6 +250,15 @@ impl McpProcess {
|
|||||||
self.send_request("account/rateLimits/read", None).await
|
self.send_request("account/rateLimits/read", None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send an `account/read` JSON-RPC request.
|
||||||
|
pub async fn send_get_account_request(
|
||||||
|
&mut self,
|
||||||
|
params: GetAccountParams,
|
||||||
|
) -> anyhow::Result<i64> {
|
||||||
|
let params = Some(serde_json::to_value(params)?);
|
||||||
|
self.send_request("account/read", params).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a `feedback/upload` JSON-RPC request.
|
/// Send a `feedback/upload` JSON-RPC request.
|
||||||
pub async fn send_feedback_upload_request(
|
pub async fn send_feedback_upload_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ use anyhow::Result;
|
|||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use app_test_support::McpProcess;
|
use app_test_support::McpProcess;
|
||||||
use app_test_support::to_response;
|
use app_test_support::to_response;
|
||||||
|
|
||||||
|
use app_test_support::ChatGptAuthFixture;
|
||||||
|
use app_test_support::write_chatgpt_auth;
|
||||||
|
use codex_app_server_protocol::Account;
|
||||||
use codex_app_server_protocol::AuthMode;
|
use codex_app_server_protocol::AuthMode;
|
||||||
use codex_app_server_protocol::CancelLoginAccountParams;
|
use codex_app_server_protocol::CancelLoginAccountParams;
|
||||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||||
use codex_app_server_protocol::GetAuthStatusParams;
|
use codex_app_server_protocol::GetAccountParams;
|
||||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
use codex_app_server_protocol::GetAccountResponse;
|
||||||
use codex_app_server_protocol::JSONRPCError;
|
use codex_app_server_protocol::JSONRPCError;
|
||||||
use codex_app_server_protocol::JSONRPCResponse;
|
use codex_app_server_protocol::JSONRPCResponse;
|
||||||
use codex_app_server_protocol::LoginAccountResponse;
|
use codex_app_server_protocol::LoginAccountResponse;
|
||||||
@@ -15,6 +19,7 @@ use codex_app_server_protocol::RequestId;
|
|||||||
use codex_app_server_protocol::ServerNotification;
|
use codex_app_server_protocol::ServerNotification;
|
||||||
use codex_core::auth::AuthCredentialsStoreMode;
|
use codex_core::auth::AuthCredentialsStoreMode;
|
||||||
use codex_login::login_with_api_key;
|
use codex_login::login_with_api_key;
|
||||||
|
use codex_protocol::account::PlanType as AccountPlanType;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -25,22 +30,30 @@ use tokio::time::timeout;
|
|||||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
// Helper to create a minimal config.toml for the app server
|
// Helper to create a minimal config.toml for the app server
|
||||||
fn create_config_toml(
|
#[derive(Default)]
|
||||||
codex_home: &Path,
|
struct CreateConfigTomlParams {
|
||||||
forced_method: Option<&str>,
|
forced_method: Option<String>,
|
||||||
forced_workspace_id: Option<&str>,
|
forced_workspace_id: Option<String>,
|
||||||
) -> std::io::Result<()> {
|
requires_openai_auth: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {
|
||||||
let config_toml = codex_home.join("config.toml");
|
let config_toml = codex_home.join("config.toml");
|
||||||
let forced_line = if let Some(method) = forced_method {
|
let forced_line = if let Some(method) = params.forced_method {
|
||||||
format!("forced_login_method = \"{method}\"\n")
|
format!("forced_login_method = \"{method}\"\n")
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let forced_workspace_line = if let Some(ws) = forced_workspace_id {
|
let forced_workspace_line = if let Some(ws) = params.forced_workspace_id {
|
||||||
format!("forced_chatgpt_workspace_id = \"{ws}\"\n")
|
format!("forced_chatgpt_workspace_id = \"{ws}\"\n")
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
let requires_line = match params.requires_openai_auth {
|
||||||
|
Some(true) => "requires_openai_auth = true\n".to_string(),
|
||||||
|
Some(false) => String::new(),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
let contents = format!(
|
let contents = format!(
|
||||||
r#"
|
r#"
|
||||||
model = "mock-model"
|
model = "mock-model"
|
||||||
@@ -57,6 +70,7 @@ base_url = "http://127.0.0.1:0/v1"
|
|||||||
wire_api = "chat"
|
wire_api = "chat"
|
||||||
request_max_retries = 0
|
request_max_retries = 0
|
||||||
stream_max_retries = 0
|
stream_max_retries = 0
|
||||||
|
{requires_line}
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
std::fs::write(config_toml, contents)
|
std::fs::write(config_toml, contents)
|
||||||
@@ -65,7 +79,7 @@ stream_max_retries = 0
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), None, None)?;
|
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||||
|
|
||||||
login_with_api_key(
|
login_with_api_key(
|
||||||
codex_home.path(),
|
codex_home.path(),
|
||||||
@@ -104,27 +118,25 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
|||||||
"auth.json should be deleted"
|
"auth.json should be deleted"
|
||||||
);
|
);
|
||||||
|
|
||||||
let status_id = mcp
|
let get_id = mcp
|
||||||
.send_get_auth_status_request(GetAuthStatusParams {
|
.send_get_account_request(GetAccountParams {
|
||||||
include_token: Some(true),
|
refresh_token: false,
|
||||||
refresh_token: Some(false),
|
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let status_resp: JSONRPCResponse = timeout(
|
let get_resp: JSONRPCResponse = timeout(
|
||||||
DEFAULT_READ_TIMEOUT,
|
DEFAULT_READ_TIMEOUT,
|
||||||
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
|
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
|
||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
let status: GetAuthStatusResponse = to_response(status_resp)?;
|
let account: GetAccountResponse = to_response(get_resp)?;
|
||||||
assert_eq!(status.auth_method, None);
|
assert_eq!(account.account, None);
|
||||||
assert_eq!(status.auth_token, None);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
|
async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), None, None)?;
|
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||||
|
|
||||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
@@ -171,7 +183,13 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
|
async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), Some("chatgpt"), None)?;
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
forced_method: Some("chatgpt".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
@@ -195,7 +213,13 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
|
async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), Some("api"), None)?;
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
forced_method: Some("api".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
@@ -219,7 +243,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
|
|||||||
#[serial(login_port)]
|
#[serial(login_port)]
|
||||||
async fn login_account_chatgpt_start() -> Result<()> {
|
async fn login_account_chatgpt_start() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), None, None)?;
|
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||||
|
|
||||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
@@ -285,7 +309,13 @@ async fn login_account_chatgpt_start() -> Result<()> {
|
|||||||
#[serial(login_port)]
|
#[serial(login_port)]
|
||||||
async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> {
|
async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
create_config_toml(codex_home.path(), None, Some("ws-forced"))?;
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
forced_workspace_id: Some("ws-forced".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
@@ -307,3 +337,156 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_account_no_auth() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
requires_openai_auth: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||||
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let params = GetAccountParams {
|
||||||
|
refresh_token: false,
|
||||||
|
};
|
||||||
|
let request_id = mcp.send_get_account_request(params).await?;
|
||||||
|
|
||||||
|
let resp: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let account: GetAccountResponse = to_response(resp)?;
|
||||||
|
|
||||||
|
assert_eq!(account.account, None, "expected no account");
|
||||||
|
assert_eq!(account.requires_openai_auth, true);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_account_with_api_key() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
requires_openai_auth: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let req_id = mcp
|
||||||
|
.send_login_account_api_key_request("sk-test-key")
|
||||||
|
.await?;
|
||||||
|
let resp: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let _login_ok = to_response::<LoginAccountResponse>(resp)?;
|
||||||
|
|
||||||
|
let params = GetAccountParams {
|
||||||
|
refresh_token: false,
|
||||||
|
};
|
||||||
|
let request_id = mcp.send_get_account_request(params).await?;
|
||||||
|
|
||||||
|
let resp: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let received: GetAccountResponse = to_response(resp)?;
|
||||||
|
|
||||||
|
let expected = GetAccountResponse {
|
||||||
|
account: Some(Account::ApiKey {}),
|
||||||
|
requires_openai_auth: true,
|
||||||
|
};
|
||||||
|
assert_eq!(received, expected);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_account_when_auth_not_required() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
requires_openai_auth: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||||
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let params = GetAccountParams {
|
||||||
|
refresh_token: false,
|
||||||
|
};
|
||||||
|
let request_id = mcp.send_get_account_request(params).await?;
|
||||||
|
|
||||||
|
let resp: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let received: GetAccountResponse = to_response(resp)?;
|
||||||
|
|
||||||
|
let expected = GetAccountResponse {
|
||||||
|
account: None,
|
||||||
|
requires_openai_auth: false,
|
||||||
|
};
|
||||||
|
assert_eq!(received, expected);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_account_with_chatgpt() -> Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
create_config_toml(
|
||||||
|
codex_home.path(),
|
||||||
|
CreateConfigTomlParams {
|
||||||
|
requires_openai_auth: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
write_chatgpt_auth(
|
||||||
|
codex_home.path(),
|
||||||
|
ChatGptAuthFixture::new("access-chatgpt")
|
||||||
|
.email("user@example.com")
|
||||||
|
.plan_type("pro"),
|
||||||
|
AuthCredentialsStoreMode::File,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||||
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||||
|
|
||||||
|
let params = GetAccountParams {
|
||||||
|
refresh_token: false,
|
||||||
|
};
|
||||||
|
let request_id = mcp.send_get_account_request(params).await?;
|
||||||
|
|
||||||
|
let resp: JSONRPCResponse = timeout(
|
||||||
|
DEFAULT_READ_TIMEOUT,
|
||||||
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
let received: GetAccountResponse = to_response(resp)?;
|
||||||
|
|
||||||
|
let expected = GetAccountResponse {
|
||||||
|
account: Some(Account::Chatgpt {
|
||||||
|
email: "user@example.com".to_string(),
|
||||||
|
plan_type: AccountPlanType::Pro,
|
||||||
|
}),
|
||||||
|
requires_openai_auth: true,
|
||||||
|
};
|
||||||
|
assert_eq!(received, expected);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ use crate::config::Config;
|
|||||||
use crate::default_client::CodexHttpClient;
|
use crate::default_client::CodexHttpClient;
|
||||||
use crate::error::RefreshTokenFailedError;
|
use crate::error::RefreshTokenFailedError;
|
||||||
use crate::error::RefreshTokenFailedReason;
|
use crate::error::RefreshTokenFailedReason;
|
||||||
use crate::token_data::PlanType;
|
use crate::token_data::KnownPlan as InternalKnownPlan;
|
||||||
|
use crate::token_data::PlanType as InternalPlanType;
|
||||||
use crate::token_data::TokenData;
|
use crate::token_data::TokenData;
|
||||||
use crate::token_data::parse_id_token;
|
use crate::token_data::parse_id_token;
|
||||||
use crate::util::try_parse_error_message;
|
use crate::util::try_parse_error_message;
|
||||||
|
use codex_protocol::account::PlanType as AccountPlanType;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -202,7 +204,34 @@ impl CodexAuth {
|
|||||||
self.get_current_token_data().and_then(|t| t.id_token.email)
|
self.get_current_token_data().and_then(|t| t.id_token.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_plan_type(&self) -> Option<PlanType> {
|
/// Account-facing plan classification derived from the current token.
|
||||||
|
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
|
||||||
|
/// mapped from the ID token's internal plan value. Prefer this when you
|
||||||
|
/// need to make UI or product decisions based on the user's subscription.
|
||||||
|
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
|
||||||
|
let map_known = |kp: &InternalKnownPlan| match kp {
|
||||||
|
InternalKnownPlan::Free => AccountPlanType::Free,
|
||||||
|
InternalKnownPlan::Plus => AccountPlanType::Plus,
|
||||||
|
InternalKnownPlan::Pro => AccountPlanType::Pro,
|
||||||
|
InternalKnownPlan::Team => AccountPlanType::Team,
|
||||||
|
InternalKnownPlan::Business => AccountPlanType::Business,
|
||||||
|
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
|
||||||
|
InternalKnownPlan::Edu => AccountPlanType::Edu,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.get_current_token_data()
|
||||||
|
.and_then(|t| t.id_token.chatgpt_plan_type)
|
||||||
|
.map(|pt| match pt {
|
||||||
|
InternalPlanType::Known(k) => map_known(&k),
|
||||||
|
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw internal plan value from the ID token.
|
||||||
|
/// Exposes the underlying `token_data::PlanType` without mapping it to the
|
||||||
|
/// public `AccountPlanType`. Use this when downstream code needs to inspect
|
||||||
|
/// internal/unknown plan strings exactly as issued in the token.
|
||||||
|
pub(crate) fn get_plan_type(&self) -> Option<InternalPlanType> {
|
||||||
self.get_current_token_data()
|
self.get_current_token_data()
|
||||||
.and_then(|t| t.id_token.chatgpt_plan_type)
|
.and_then(|t| t.id_token.chatgpt_plan_type)
|
||||||
}
|
}
|
||||||
@@ -609,8 +638,9 @@ mod tests {
|
|||||||
use crate::config::ConfigOverrides;
|
use crate::config::ConfigOverrides;
|
||||||
use crate::config::ConfigToml;
|
use crate::config::ConfigToml;
|
||||||
use crate::token_data::IdTokenInfo;
|
use crate::token_data::IdTokenInfo;
|
||||||
use crate::token_data::KnownPlan;
|
use crate::token_data::KnownPlan as InternalKnownPlan;
|
||||||
use crate::token_data::PlanType;
|
use crate::token_data::PlanType as InternalPlanType;
|
||||||
|
use codex_protocol::account::PlanType as AccountPlanType;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use codex_protocol::config_types::ForcedLoginMethod;
|
use codex_protocol::config_types::ForcedLoginMethod;
|
||||||
@@ -727,7 +757,7 @@ mod tests {
|
|||||||
tokens: Some(TokenData {
|
tokens: Some(TokenData {
|
||||||
id_token: IdTokenInfo {
|
id_token: IdTokenInfo {
|
||||||
email: Some("user@example.com".to_string()),
|
email: Some("user@example.com".to_string()),
|
||||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
|
||||||
chatgpt_account_id: None,
|
chatgpt_account_id: None,
|
||||||
raw_jwt: fake_jwt,
|
raw_jwt: fake_jwt,
|
||||||
},
|
},
|
||||||
@@ -981,6 +1011,54 @@ mod tests {
|
|||||||
.contains("ChatGPT login is required, but an API key is currently being used.")
|
.contains("ChatGPT login is required, but an API key is currently being used.")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_type_maps_known_plan() {
|
||||||
|
let codex_home = tempdir().unwrap();
|
||||||
|
let _jwt = write_auth_file(
|
||||||
|
AuthFileParams {
|
||||||
|
openai_api_key: None,
|
||||||
|
chatgpt_plan_type: "pro".to_string(),
|
||||||
|
chatgpt_account_id: None,
|
||||||
|
},
|
||||||
|
codex_home.path(),
|
||||||
|
)
|
||||||
|
.expect("failed to write auth file");
|
||||||
|
|
||||||
|
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||||
|
.expect("load auth")
|
||||||
|
.expect("auth available");
|
||||||
|
|
||||||
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
|
||||||
|
pretty_assertions::assert_eq!(
|
||||||
|
auth.get_plan_type(),
|
||||||
|
Some(InternalPlanType::Known(InternalKnownPlan::Pro))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_type_maps_unknown_to_unknown() {
|
||||||
|
let codex_home = tempdir().unwrap();
|
||||||
|
let _jwt = write_auth_file(
|
||||||
|
AuthFileParams {
|
||||||
|
openai_api_key: None,
|
||||||
|
chatgpt_plan_type: "mystery-tier".to_string(),
|
||||||
|
chatgpt_account_id: None,
|
||||||
|
},
|
||||||
|
codex_home.path(),
|
||||||
|
)
|
||||||
|
.expect("failed to write auth file");
|
||||||
|
|
||||||
|
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||||
|
.expect("load auth")
|
||||||
|
.expect("auth available");
|
||||||
|
|
||||||
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
|
||||||
|
pretty_assertions::assert_eq!(
|
||||||
|
auth.get_plan_type(),
|
||||||
|
Some(InternalPlanType::Unknown("mystery-tier".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Central manager providing a single source of truth for auth.json derived
|
/// Central manager providing a single source of truth for auth.json derived
|
||||||
|
|||||||
Reference in New Issue
Block a user