Skip to content

Commit 5d1671c

Browse files
authored
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
1 parent 2b9bf5d commit 5d1671c

File tree

12 files changed

+219
-2
lines changed

12 files changed

+219
-2
lines changed

codex-rs/codex-api/src/common.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ pub struct ResponsesApiRequest {
169169
pub prompt_cache_key: Option<String>,
170170
#[serde(skip_serializing_if = "Option::is_none")]
171171
pub text: Option<TextControls>,
172+
#[serde(skip_serializing_if = "Option::is_none")]
173+
pub client_metadata: Option<HashMap<String, String>>,
172174
}
173175

174176
impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
@@ -189,7 +191,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
189191
prompt_cache_key: request.prompt_cache_key.clone(),
190192
text: request.text.clone(),
191193
generate: None,
192-
client_metadata: None,
194+
client_metadata: request.client_metadata.clone(),
193195
}
194196
}
195197
}

codex-rs/codex-api/tests/clients.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
278278
service_tier: None,
279279
prompt_cache_key: None,
280280
text: None,
281+
client_metadata: None,
281282
};
282283
let client = ResponsesClient::new(transport.clone(), provider, NoAuth);
283284

@@ -320,6 +321,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
320321
service_tier: None,
321322
prompt_cache_key: None,
322323
text: None,
324+
client_metadata: None,
323325
};
324326

325327
let mut extra_headers = HeaderMap::new();

codex-rs/core/src/client.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ use codex_response_debug_context::telemetry_api_error_message;
118118
use codex_response_debug_context::telemetry_transport_error_message;
119119

120120
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
121+
pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
121122
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
122123
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
123124
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
@@ -142,6 +143,7 @@ struct ModelClientState {
142143
auth_manager: Option<Arc<AuthManager>>,
143144
conversation_id: ThreadId,
144145
window_generation: AtomicU64,
146+
installation_id: String,
145147
provider: ModelProviderInfo,
146148
auth_env_telemetry: AuthEnvTelemetry,
147149
session_source: SessionSource,
@@ -263,6 +265,7 @@ impl ModelClient {
263265
pub fn new(
264266
auth_manager: Option<Arc<AuthManager>>,
265267
conversation_id: ThreadId,
268+
installation_id: String,
266269
provider: ModelProviderInfo,
267270
session_source: SessionSource,
268271
model_verbosity: Option<VerbosityConfig>,
@@ -280,6 +283,7 @@ impl ModelClient {
280283
auth_manager,
281284
conversation_id,
282285
window_generation: AtomicU64::new(0),
286+
installation_id,
283287
provider,
284288
auth_env_telemetry,
285289
session_source,
@@ -429,7 +433,11 @@ impl ModelClient {
429433
text,
430434
};
431435

432-
let mut extra_headers = self.build_responses_identity_headers();
436+
let mut extra_headers = ApiHeaderMap::new();
437+
if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) {
438+
extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
439+
}
440+
extra_headers.extend(self.build_responses_identity_headers());
433441
extra_headers.extend(build_conversation_headers(Some(
434442
self.state.conversation_id.to_string(),
435443
)));
@@ -515,6 +523,10 @@ impl ModelClient {
515523
turn_metadata_header: Option<&str>,
516524
) -> HashMap<String, String> {
517525
let mut client_metadata = HashMap::new();
526+
client_metadata.insert(
527+
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
528+
self.state.installation_id.clone(),
529+
);
518530
client_metadata.insert(
519531
X_CODEX_WINDOW_ID_HEADER.to_string(),
520532
self.current_window_id(),
@@ -817,6 +829,10 @@ impl ModelClientSession {
817829
},
818830
prompt_cache_key,
819831
text,
832+
client_metadata: Some(HashMap::from([(
833+
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
834+
self.client.state.installation_id.clone(),
835+
)])),
820836
};
821837
Ok(request)
822838
}

codex-rs/core/src/client_common_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ fn serializes_text_verbosity_when_set() {
2929
verbosity: Some(OpenAiVerbosity::Low),
3030
format: None,
3131
}),
32+
client_metadata: None,
3233
};
3334

3435
let v = serde_json::to_value(&req).expect("json");
@@ -69,6 +70,7 @@ fn serializes_text_schema_with_strict_format() {
6970
prompt_cache_key: None,
7071
service_tier: None,
7172
text: Some(text_controls),
73+
client_metadata: None,
7274
};
7375

7476
let v = serde_json::to_value(&req).expect("json");
@@ -106,6 +108,7 @@ fn omits_text_when_not_set() {
106108
prompt_cache_key: None,
107109
service_tier: None,
108110
text: None,
111+
client_metadata: None,
109112
};
110113

111114
let v = serde_json::to_value(&req).expect("json");
@@ -128,6 +131,7 @@ fn serializes_flex_service_tier_when_set() {
128131
prompt_cache_key: None,
129132
service_tier: Some(ServiceTier::Flex.to_string()),
130133
text: None,
134+
client_metadata: None,
131135
};
132136

133137
let v = serde_json::to_value(&req).expect("json");

codex-rs/core/src/client_tests.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::AuthRequestTelemetryContext;
22
use super::ModelClient;
33
use super::PendingUnauthorizedRetry;
44
use super::UnauthorizedRecoveryExecution;
5+
use super::X_CODEX_INSTALLATION_ID_HEADER;
56
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
67
use super::X_CODEX_TURN_METADATA_HEADER;
78
use super::X_CODEX_WINDOW_ID_HEADER;
@@ -23,6 +24,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
2324
ModelClient::new(
2425
/*auth_manager*/ None,
2526
ThreadId::new(),
27+
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
2628
provider,
2729
session_source,
2830
/*model_verbosity*/ None,
@@ -107,6 +109,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
107109
assert_eq!(
108110
client_metadata,
109111
std::collections::HashMap::from([
112+
(
113+
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
114+
"11111111-1111-4111-8111-111111111111".to_string(),
115+
),
110116
(
111117
X_CODEX_WINDOW_ID_HEADER.to_string(),
112118
format!("{conversation_id}:1"),

codex-rs/core/src/codex.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task;
2424
use crate::config::ManagedFeatures;
2525
use crate::connectors;
2626
use crate::exec_policy::ExecPolicyManager;
27+
use crate::installation_id::resolve_installation_id;
2728
use crate::parse_turn_item;
2829
use crate::path_utils::normalize_for_native_workdir;
2930
use crate::realtime_conversation::RealtimeConversationManager;
@@ -1927,6 +1928,7 @@ impl Session {
19271928
});
19281929
}
19291930

1931+
let installation_id = resolve_installation_id(&config.codex_home).await?;
19301932
let services = SessionServices {
19311933
// Initialize the MCP connection manager with an uninitialized
19321934
// instance. It will be replaced with one created via
@@ -1970,6 +1972,7 @@ impl Session {
19701972
model_client: ModelClient::new(
19711973
Some(Arc::clone(&auth_manager)),
19721974
conversation_id,
1975+
installation_id,
19731976
session_configuration.provider.clone(),
19741977
session_configuration.session_source.clone(),
19751978
config.model_verbosity,

codex-rs/core/src/codex_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
255255
/*auth_manager*/ None,
256256
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
257257
.expect("test thread id should be valid"),
258+
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
258259
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
259260
codex_protocol::protocol::SessionSource::Exec,
260261
/*model_verbosity*/ None,
@@ -2772,6 +2773,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
27722773
model_client: ModelClient::new(
27732774
Some(auth_manager.clone()),
27742775
conversation_id,
2776+
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
27752777
session_configuration.provider.clone(),
27762778
session_configuration.session_source.clone(),
27772779
config.model_verbosity,
@@ -3613,6 +3615,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
36133615
model_client: ModelClient::new(
36143616
Some(Arc::clone(&auth_manager)),
36153617
conversation_id,
3618+
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
36163619
session_configuration.provider.clone(),
36173620
session_configuration.session_source.clone(),
36183621
config.model_verbosity,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
use std::fs::OpenOptions;
2+
use std::io::Read;
3+
use std::io::Result;
4+
use std::io::Seek;
5+
use std::io::SeekFrom;
6+
use std::io::Write;
7+
use std::path::Path;
8+
9+
#[cfg(unix)]
10+
use std::os::unix::fs::OpenOptionsExt;
11+
#[cfg(unix)]
12+
use std::os::unix::fs::PermissionsExt;
13+
14+
use tokio::fs;
15+
use uuid::Uuid;
16+
17+
pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id";
18+
19+
pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result<String> {
20+
let path = codex_home.join(INSTALLATION_ID_FILENAME);
21+
fs::create_dir_all(codex_home).await?;
22+
tokio::task::spawn_blocking(move || {
23+
let mut options = OpenOptions::new();
24+
options.read(true).write(true).create(true);
25+
26+
#[cfg(unix)]
27+
{
28+
options.mode(0o644);
29+
}
30+
31+
let mut file = options.open(&path)?;
32+
file.lock()?;
33+
34+
#[cfg(unix)]
35+
{
36+
let metadata = file.metadata()?;
37+
let current_mode = metadata.permissions().mode() & 0o777;
38+
if current_mode != 0o644 {
39+
let mut permissions = metadata.permissions();
40+
permissions.set_mode(0o644);
41+
file.set_permissions(permissions)?;
42+
}
43+
}
44+
45+
let mut contents = String::new();
46+
file.read_to_string(&mut contents)?;
47+
let trimmed = contents.trim();
48+
if !trimmed.is_empty()
49+
&& let Ok(existing) = Uuid::parse_str(trimmed)
50+
{
51+
return Ok(existing.to_string());
52+
}
53+
54+
let installation_id = Uuid::new_v4().to_string();
55+
file.set_len(0)?;
56+
file.seek(SeekFrom::Start(0))?;
57+
file.write_all(installation_id.as_bytes())?;
58+
file.flush()?;
59+
file.sync_all()?;
60+
61+
Ok(installation_id)
62+
})
63+
.await?
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use super::INSTALLATION_ID_FILENAME;
69+
use super::resolve_installation_id;
70+
use pretty_assertions::assert_eq;
71+
use tempfile::TempDir;
72+
use uuid::Uuid;
73+
74+
#[cfg(unix)]
75+
use std::os::unix::fs::PermissionsExt;
76+
77+
#[tokio::test]
78+
async fn resolve_installation_id_generates_and_persists_uuid() {
79+
let codex_home = TempDir::new().expect("create temp dir");
80+
let persisted_path = codex_home.path().join(INSTALLATION_ID_FILENAME);
81+
82+
let installation_id = resolve_installation_id(codex_home.path())
83+
.await
84+
.expect("resolve installation id");
85+
86+
assert_eq!(
87+
std::fs::read_to_string(&persisted_path).expect("read persisted installation id"),
88+
installation_id
89+
);
90+
assert!(Uuid::parse_str(&installation_id).is_ok());
91+
92+
#[cfg(unix)]
93+
{
94+
let mode = std::fs::metadata(&persisted_path)
95+
.expect("read installation id metadata")
96+
.permissions()
97+
.mode()
98+
& 0o777;
99+
assert_eq!(mode, 0o644);
100+
}
101+
}
102+
103+
#[tokio::test]
104+
async fn resolve_installation_id_reuses_existing_uuid() {
105+
let codex_home = TempDir::new().expect("create temp dir");
106+
let existing = Uuid::new_v4().to_string().to_uppercase();
107+
std::fs::write(
108+
codex_home.path().join(INSTALLATION_ID_FILENAME),
109+
existing.clone(),
110+
)
111+
.expect("write installation id");
112+
113+
let resolved = resolve_installation_id(codex_home.path())
114+
.await
115+
.expect("resolve installation id");
116+
117+
assert_eq!(
118+
resolved,
119+
Uuid::parse_str(existing.as_str())
120+
.expect("parse existing installation id")
121+
.to_string()
122+
);
123+
}
124+
125+
#[tokio::test]
126+
async fn resolve_installation_id_rewrites_invalid_file_contents() {
127+
let codex_home = TempDir::new().expect("create temp dir");
128+
std::fs::write(
129+
codex_home.path().join(INSTALLATION_ID_FILENAME),
130+
"not-a-uuid",
131+
)
132+
.expect("write invalid installation id");
133+
134+
let resolved = resolve_installation_id(codex_home.path())
135+
.await
136+
.expect("resolve installation id");
137+
138+
assert!(Uuid::parse_str(&resolved).is_ok());
139+
assert_eq!(
140+
std::fs::read_to_string(codex_home.path().join(INSTALLATION_ID_FILENAME))
141+
.expect("read rewritten installation id"),
142+
resolved
143+
);
144+
}
145+
}

codex-rs/core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod flags;
3838
mod git_info_tests;
3939
mod guardian;
4040
mod hook_runtime;
41+
mod installation_id;
4142
pub(crate) mod instructions;
4243
pub(crate) mod landlock;
4344
pub use landlock::spawn_command_under_linux_sandbox;
@@ -179,6 +180,7 @@ pub mod util;
179180

180181
pub use client::ModelClient;
181182
pub use client::ModelClientSession;
183+
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
182184
pub use client::X_CODEX_TURN_METADATA_HEADER;
183185
pub use client_common::Prompt;
184186
pub use client_common::REVIEW_PROMPT;

0 commit comments

Comments
 (0)