Add backend client support for Codex user settings (#34559)

## What changed

- Add `Client::get_user_settings` for the active Codex API or ChatGPT backend route.
- Send authenticated workspace headers and bypass cached responses when fetching settings.
- Expose the effective `commit_attribution_enabled` policy, defaulting it to `false` when older responses omit the field.

## Testing

- Cover both backend route styles, cache-control behavior, response decoding, the compatibility default, and authenticated workspace headers.

GitOrigin-RevId: 11db485e89293f9fa9c56ac7997922deed8b9427
This commit is contained in:
jay
2026-07-21 15:00:18 +00:00
committed by copyberry
parent 268288ad6d
commit 98c3bbdc79
5 changed files with 131 additions and 0 deletions
+1
View File
@@ -2263,6 +2263,7 @@ dependencies = [
"serde_json",
"tokio",
"url",
"wiremock",
]
[[package]]
+1
View File
@@ -28,3 +28,4 @@ codex-protocol = { workspace = true }
[dev-dependencies]
pretty_assertions = "1"
tokio = { workspace = true, features = ["macros", "rt"] }
wiremock = { workspace = true }
+118
View File
@@ -1,5 +1,6 @@
use crate::types::AccountsCheckResponse;
use crate::types::CodeTaskDetailsResponse;
use crate::types::CodexUserSettingsResponse;
use crate::types::CodexWorkspaceMessagesResponse;
use crate::types::ConfigBundleResponse;
use crate::types::PaginatedListTaskListItem;
@@ -454,6 +455,26 @@ impl Client {
.map_err(RequestError::from)
}
/// Fetch authenticated Codex user settings from the active backend route.
///
/// Uses `GET /api/codex/settings/user` for Codex API hosts and
/// `GET /wham/settings/user` for ChatGPT `backend-api` hosts.
pub async fn get_user_settings(
&self,
) -> std::result::Result<CodexUserSettingsResponse, RequestError> {
let url = self.user_settings_url();
let req = self
.request(Method::GET, &url)
.headers(self.headers())
.header(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store"),
);
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<CodexUserSettingsResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
pub async fn list_workspace_messages(
&self,
) -> std::result::Result<CodexWorkspaceMessagesResponse, RequestError> {
@@ -613,6 +634,13 @@ impl Client {
}
}
fn user_settings_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url),
}
}
fn map_rate_limit_window(
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
) -> Option<RateLimitWindow> {
@@ -695,6 +723,12 @@ mod tests {
use codex_backend_openapi_models::models::RateLimitReachedKind;
use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[test]
fn map_plan_type_supports_usage_based_business_variants() {
@@ -1025,6 +1059,90 @@ mod tests {
);
}
#[tokio::test]
async fn user_settings_request_uses_expected_paths_and_revalidates_cached_responses() {
let server = MockServer::start().await;
for (request_path, commit_attribution_enabled) in [
("/api/codex/settings/user", true),
("/backend-api/wham/settings/user", false),
] {
Mock::given(method("GET"))
.and(path(request_path))
.and(header_regex("cache-control", "^no-cache, no-store$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"commit_attribution_enabled": commit_attribution_enabled,
})))
.expect(1)
.mount(&server)
.await;
}
let codex_response = Client::new(
server.uri(),
HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
)
.get_user_settings()
.await
.unwrap();
let chatgpt_response = Client::new(
format!("{}/backend-api", server.uri()),
HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
)
.get_user_settings()
.await
.unwrap();
assert_eq!(
[codex_response, chatgpt_response],
[
CodexUserSettingsResponse {
commit_attribution_enabled: true,
},
CodexUserSettingsResponse {
commit_attribution_enabled: false,
},
]
);
}
#[test]
fn user_settings_missing_attribution_policy_defaults_to_disabled() {
assert_eq!(
serde_json::from_value::<CodexUserSettingsResponse>(serde_json::json!({})).unwrap(),
CodexUserSettingsResponse {
commit_attribution_enabled: false,
}
);
}
#[test]
fn authenticated_user_settings_client_uses_active_workspace_headers() {
let auth = CodexAuth::from_external_chatgpt_tokens(
"e30.e30.c2ln",
"workspace-123",
Some("enterprise"),
)
.unwrap();
let client = Client::from_auth(
"https://chatgpt.com/backend-api",
&auth,
HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
);
let headers = client.headers();
assert_eq!(
[
headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok()),
],
[Some("Bearer e30.e30.c2ln"), Some("workspace-123")]
);
}
fn test_client(base_url: &str, path_style: PathStyle) -> Client {
Client {
base_url: base_url.to_string(),
+1
View File
@@ -8,6 +8,7 @@ pub use types::AccountEntry;
pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::CodexUserSettingsResponse;
pub use types::CodexWorkspaceMessage;
pub use types::CodexWorkspaceMessageType;
pub use types::CodexWorkspaceMessagesResponse;
+10
View File
@@ -60,6 +60,16 @@ pub struct CodexWorkspaceMessagesResponse {
pub messages: Vec<CodexWorkspaceMessage>,
}
/// Authenticated Codex user settings used by CLI runtime policy.
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
pub struct CodexUserSettingsResponse {
/// Server-computed effective commit-attribution policy.
///
/// Older backend responses omit this field, which safely defaults to disabled.
#[serde(default)]
pub commit_attribution_enabled: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessage {
pub message_id: String,