feat(analytics): generate an installation_id and pass it in responsesapi client_metadata (#16912)

## Summary

This adds a stable Codex installation ID and includes it on Responses
API requests via `x-codex-installation-id` passed in via the
`client_metadata` field for analytics/debugging.

The main pieces are:
- persist a UUID in `$CODEX_HOME/installation_id`
- thread the installation ID into `ModelClient`
- send it in `client_metadata` on Responses requests so it works
consistently across HTTP and WebSocket transports
This commit is contained in:
Owen Lin
2026-04-07 09:52:17 -07:00
committed by GitHub
Unverified
parent 2b9bf5d3d4
commit 5d1671ca70
12 changed files with 219 additions and 2 deletions
+3 -1
View File
@@ -169,6 +169,8 @@ pub struct ResponsesApiRequest {
pub prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<TextControls>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_metadata: Option<HashMap<String, String>>,
}
impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
@@ -189,7 +191,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
prompt_cache_key: request.prompt_cache_key.clone(),
text: request.text.clone(),
generate: None,
client_metadata: None,
client_metadata: request.client_metadata.clone(),
}
}
}
+2
View File
@@ -278,6 +278,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
service_tier: None,
prompt_cache_key: None,
text: None,
client_metadata: None,
};
let client = ResponsesClient::new(transport.clone(), provider, NoAuth);
@@ -320,6 +321,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
service_tier: None,
prompt_cache_key: None,
text: None,
client_metadata: None,
};
let mut extra_headers = HeaderMap::new();
+17 -1
View File
@@ -118,6 +118,7 @@ use codex_response_debug_context::telemetry_api_error_message;
use codex_response_debug_context::telemetry_transport_error_message;
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
@@ -142,6 +143,7 @@ struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
provider: ModelProviderInfo,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
@@ -263,6 +265,7 @@ impl ModelClient {
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
@@ -280,6 +283,7 @@ impl ModelClient {
auth_manager,
conversation_id,
window_generation: AtomicU64::new(0),
installation_id,
provider,
auth_env_telemetry,
session_source,
@@ -429,7 +433,11 @@ impl ModelClient {
text,
};
let mut extra_headers = self.build_responses_identity_headers();
let mut extra_headers = ApiHeaderMap::new();
if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) {
extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
}
extra_headers.extend(self.build_responses_identity_headers());
extra_headers.extend(build_conversation_headers(Some(
self.state.conversation_id.to_string(),
)));
@@ -515,6 +523,10 @@ impl ModelClient {
turn_metadata_header: Option<&str>,
) -> HashMap<String, String> {
let mut client_metadata = HashMap::new();
client_metadata.insert(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.state.installation_id.clone(),
);
client_metadata.insert(
X_CODEX_WINDOW_ID_HEADER.to_string(),
self.current_window_id(),
@@ -817,6 +829,10 @@ impl ModelClientSession {
},
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.client.state.installation_id.clone(),
)])),
};
Ok(request)
}
+4
View File
@@ -29,6 +29,7 @@ fn serializes_text_verbosity_when_set() {
verbosity: Some(OpenAiVerbosity::Low),
format: None,
}),
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -69,6 +70,7 @@ fn serializes_text_schema_with_strict_format() {
prompt_cache_key: None,
service_tier: None,
text: Some(text_controls),
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -106,6 +108,7 @@ fn omits_text_when_not_set() {
prompt_cache_key: None,
service_tier: None,
text: None,
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
@@ -128,6 +131,7 @@ fn serializes_flex_service_tier_when_set() {
prompt_cache_key: None,
service_tier: Some(ServiceTier::Flex.to_string()),
text: None,
client_metadata: None,
};
let v = serde_json::to_value(&req).expect("json");
+6
View File
@@ -2,6 +2,7 @@ use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
use super::UnauthorizedRecoveryExecution;
use super::X_CODEX_INSTALLATION_ID_HEADER;
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
@@ -23,6 +24,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
ModelClient::new(
/*auth_manager*/ None,
ThreadId::new(),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
session_source,
/*model_verbosity*/ None,
@@ -107,6 +109,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
assert_eq!(
client_metadata,
std::collections::HashMap::from([
(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
"11111111-1111-4111-8111-111111111111".to_string(),
),
(
X_CODEX_WINDOW_ID_HEADER.to_string(),
format!("{conversation_id}:1"),
+3
View File
@@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task;
use crate::config::ManagedFeatures;
use crate::connectors;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
@@ -1927,6 +1928,7 @@ impl Session {
});
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@@ -1970,6 +1972,7 @@ impl Session {
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
installation_id,
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
+3
View File
@@ -255,6 +255,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*auth_manager*/ None,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
codex_protocol::protocol::SessionSource::Exec,
/*model_verbosity*/ None,
@@ -2772,6 +2773,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_client: ModelClient::new(
Some(auth_manager.clone()),
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -3613,6 +3615,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
+145
View File
@@ -0,0 +1,145 @@
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Result;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use tokio::fs;
use uuid::Uuid;
pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id";
pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result<String> {
let path = codex_home.join(INSTALLATION_ID_FILENAME);
fs::create_dir_all(codex_home).await?;
tokio::task::spawn_blocking(move || {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o644);
}
let mut file = options.open(&path)?;
file.lock()?;
#[cfg(unix)]
{
let metadata = file.metadata()?;
let current_mode = metadata.permissions().mode() & 0o777;
if current_mode != 0o644 {
let mut permissions = metadata.permissions();
permissions.set_mode(0o644);
file.set_permissions(permissions)?;
}
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let trimmed = contents.trim();
if !trimmed.is_empty()
&& let Ok(existing) = Uuid::parse_str(trimmed)
{
return Ok(existing.to_string());
}
let installation_id = Uuid::new_v4().to_string();
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(installation_id.as_bytes())?;
file.flush()?;
file.sync_all()?;
Ok(installation_id)
})
.await?
}
#[cfg(test)]
mod tests {
use super::INSTALLATION_ID_FILENAME;
use super::resolve_installation_id;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[tokio::test]
async fn resolve_installation_id_generates_and_persists_uuid() {
let codex_home = TempDir::new().expect("create temp dir");
let persisted_path = codex_home.path().join(INSTALLATION_ID_FILENAME);
let installation_id = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert_eq!(
std::fs::read_to_string(&persisted_path).expect("read persisted installation id"),
installation_id
);
assert!(Uuid::parse_str(&installation_id).is_ok());
#[cfg(unix)]
{
let mode = std::fs::metadata(&persisted_path)
.expect("read installation id metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o644);
}
}
#[tokio::test]
async fn resolve_installation_id_reuses_existing_uuid() {
let codex_home = TempDir::new().expect("create temp dir");
let existing = Uuid::new_v4().to_string().to_uppercase();
std::fs::write(
codex_home.path().join(INSTALLATION_ID_FILENAME),
existing.clone(),
)
.expect("write installation id");
let resolved = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert_eq!(
resolved,
Uuid::parse_str(existing.as_str())
.expect("parse existing installation id")
.to_string()
);
}
#[tokio::test]
async fn resolve_installation_id_rewrites_invalid_file_contents() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join(INSTALLATION_ID_FILENAME),
"not-a-uuid",
)
.expect("write invalid installation id");
let resolved = resolve_installation_id(codex_home.path())
.await
.expect("resolve installation id");
assert!(Uuid::parse_str(&resolved).is_ok());
assert_eq!(
std::fs::read_to_string(codex_home.path().join(INSTALLATION_ID_FILENAME))
.expect("read rewritten installation id"),
resolved
);
}
}
+2
View File
@@ -38,6 +38,7 @@ mod flags;
mod git_info_tests;
mod guardian;
mod hook_runtime;
mod installation_id;
pub(crate) mod instructions;
pub(crate) mod landlock;
pub use landlock::spawn_command_under_linux_sandbox;
@@ -179,6 +180,7 @@ pub mod util;
pub use client::ModelClient;
pub use client::ModelClientSession;
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use client_common::Prompt;
pub use client_common::REVIEW_PROMPT;
+9
View File
@@ -31,6 +31,8 @@ fn normalize_git_remote_url(url: &str) -> String {
.to_string()
}
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
#[tokio::test]
async fn responses_stream_includes_subagent_header_on_review() {
core_test_support::skip_if_no_network!();
@@ -98,6 +100,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,
@@ -147,6 +150,10 @@ async fn responses_stream_includes_subagent_header_on_review() {
Some(expected_window_id.as_str())
);
assert_eq!(request.header("x-codex-parent-thread-id"), None);
assert_eq!(
request.body_json()["client_metadata"]["x-codex-installation-id"].as_str(),
Some(TEST_INSTALLATION_ID)
);
assert_eq!(request.header("x-codex-sandbox"), None);
}
@@ -218,6 +225,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,
@@ -331,6 +339,7 @@ async fn responses_respects_model_info_overrides_from_config() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,
+19
View File
@@ -81,6 +81,8 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const INSTALLATION_ID_FILENAME: &str = "installation_id";
#[expect(clippy::unwrap_used)]
fn assert_message_role(request_body: &serde_json::Value, role: &str) {
assert_eq!(request_body["role"].as_str().unwrap(), role);
@@ -760,10 +762,18 @@ async fn includes_conversation_id_and_model_headers_in_request() {
.header("authorization")
.expect("authorization header");
let request_originator = request.header("originator").expect("originator header");
let request_body = request.body_json();
let installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
assert_eq!(request_session_id, session_id.to_string());
assert_eq!(request_originator, originator().value);
assert_eq!(request_authorization, "Bearer Test API Key");
assert_eq!(
request_body["client_metadata"]["x-codex-installation-id"].as_str(),
Some(installation_id.as_str())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -868,6 +878,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
"unused-api-key",
))),
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
SessionSource::Exec,
config.model_verbosity,
@@ -1048,11 +1059,18 @@ async fn chatgpt_auth_sends_correct_request() {
let request_body = request.body_json();
let session_id = request.header("session_id").expect("session_id header");
let installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
assert_eq!(session_id, thread_id.to_string());
assert_eq!(request_originator, originator().value);
assert_eq!(request_authorization, "Bearer Access Token");
assert_eq!(request_chatgpt_account_id, "account_id");
assert_eq!(
request_body["client_metadata"]["x-codex-installation-id"].as_str(),
Some(installation_id.as_str())
);
assert!(request_body["stream"].as_bool().unwrap());
assert_eq!(
request_body["include"][0].as_str().unwrap(),
@@ -2179,6 +2197,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider.clone(),
SessionSource::Exec,
config.model_verbosity,
@@ -55,6 +55,7 @@ const MODEL: &str = "gpt-5.2-codex";
const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id";
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTraceContext) {
let client_metadata = body["client_metadata"]
@@ -125,6 +126,10 @@ async fn responses_websocket_streams_request() {
handshake.header(X_CLIENT_REQUEST_ID_HEADER),
Some(harness.conversation_id.to_string())
);
assert_eq!(
body["client_metadata"]["x-codex-installation-id"].as_str(),
Some(TEST_INSTALLATION_ID)
);
server.shutdown().await;
}
@@ -1756,6 +1761,7 @@ async fn websocket_harness_with_provider_options(
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
SessionSource::Exec,
config.model_verbosity,