use std::collections::BTreeMap; use std::collections::HashMap; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_protocol::ThreadId; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_utils_string::to_ascii_json_string; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; use serde::Serialize; use serde_json::Value; use crate::client::X_CODEX_INSTALLATION_ID_HEADER; use crate::client::X_CODEX_PARENT_THREAD_ID_HEADER; use crate::client::X_CODEX_TURN_METADATA_HEADER; use crate::client::X_CODEX_WINDOW_ID_HEADER; use crate::client::X_OPENAI_SUBAGENT_HEADER; pub(crate) const INSTALLATION_ID_KEY: &str = "installation_id"; pub(crate) const SESSION_ID_KEY: &str = "session_id"; pub(crate) const THREAD_ID_KEY: &str = "thread_id"; pub(crate) const TURN_ID_KEY: &str = "turn_id"; pub(crate) const WINDOW_ID_KEY: &str = "window_id"; pub(crate) const REQUEST_KIND_KEY: &str = "request_kind"; pub(crate) const COMPACTION_KEY: &str = "compaction"; pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; // App-server clients can specify additional metadata in the `responsesapi_client_metadata` param // when submitting a turn, but they must not override fields owned by core. const RESERVED_METADATA_KEYS: &[&str] = &[ INSTALLATION_ID_KEY, X_CODEX_INSTALLATION_ID_HEADER, SESSION_ID_KEY, THREAD_ID_KEY, TURN_ID_KEY, WINDOW_ID_KEY, X_CODEX_WINDOW_ID_HEADER, X_CODEX_TURN_METADATA_HEADER, X_CODEX_PARENT_THREAD_ID_HEADER, X_OPENAI_SUBAGENT_HEADER, REQUEST_KIND_KEY, COMPACTION_KEY, TURN_STARTED_AT_UNIX_MS_KEY, FORKED_FROM_THREAD_ID_KEY, PARENT_THREAD_ID_KEY, SUBAGENT_KIND_KEY, SANDBOX_KEY, WORKSPACES_KEY, ]; /// Metadata attached to model requests whose purpose is conversation compaction. /// /// This covers both local compaction requests sent through the normal `/responses` path and remote /// compaction requests sent through `/responses/compact`. These fields describe the operation at /// dispatch time. Post-response outcomes such as status, error, duration, and token deltas remain /// in compaction analytics events. #[derive(Clone, Copy, Debug, Serialize)] pub(crate) struct CompactionTurnMetadata { trigger: CompactionTrigger, reason: CompactionReason, implementation: CompactionImplementation, phase: CompactionPhase, strategy: CompactionStrategy, } impl CompactionTurnMetadata { pub(crate) fn new( trigger: CompactionTrigger, reason: CompactionReason, implementation: CompactionImplementation, phase: CompactionPhase, ) -> Self { Self { trigger, reason, implementation, phase, strategy: CompactionStrategy::Memento, } } } #[derive(Clone, Copy, Debug)] pub(crate) enum CodexResponsesRequestKind { Turn, Prewarm, Compaction(CompactionTurnMetadata), Memory, } impl CodexResponsesRequestKind { fn metadata(self) -> (&'static str, Option) { match self { CodexResponsesRequestKind::Turn => ("turn", None), CodexResponsesRequestKind::Prewarm => ("prewarm", None), CodexResponsesRequestKind::Compaction(metadata) => ("compaction", Some(metadata)), CodexResponsesRequestKind::Memory => ("memory", None), } } fn has_turn_identity(self) -> bool { !matches!(self, CodexResponsesRequestKind::Memory) } } #[derive(Clone, Debug, Serialize, Default)] pub(crate) struct TurnMetadataWorkspace { #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) associated_remote_urls: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) latest_git_commit_hash: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) has_changes: Option, } /// Caller-owned snapshot of Codex metadata sent to ResponsesAPI. /// /// The full Codex turn metadata blob is transported canonically as /// `client_metadata["x-codex-turn-metadata"]`. Flat `client_metadata` keys and direct HTTP/ws /// headers are generated compatibility projections of this snapshot, not separate sources of /// truth. #[derive(Clone, Debug)] pub struct CodexResponsesMetadata { pub(crate) installation_id: String, pub(crate) session_id: String, pub(crate) thread_id: String, pub(crate) turn_id: Option, pub(crate) window_id: String, pub(crate) request_kind: Option, pub(crate) forked_from_thread_id: Option, pub(crate) parent_thread_id: Option, pub(crate) subagent_header: Option, pub(crate) subagent_kind: Option, pub(crate) sandbox: Option, pub(crate) workspaces: BTreeMap, pub(crate) turn_started_at_unix_ms: Option, pub(crate) extra: BTreeMap, } impl CodexResponsesMetadata { pub(crate) fn new( installation_id: String, session_id: String, thread_id: String, window_id: String, ) -> Self { Self { installation_id, session_id, thread_id, turn_id: None, window_id, request_kind: None, forked_from_thread_id: None, parent_thread_id: None, subagent_header: None, subagent_kind: None, sandbox: None, workspaces: BTreeMap::new(), turn_started_at_unix_ms: None, extra: BTreeMap::new(), } } pub(crate) fn has_turn_metadata(&self) -> bool { self.request_kind.is_some() } pub(crate) fn turn_metadata_json(&self) -> Option { to_ascii_json_string(&self.turn_metadata_payload()).ok() } pub(crate) fn turn_metadata_value(&self) -> Option { serde_json::to_value(self.turn_metadata_payload()).ok() } pub(crate) fn client_metadata(&self) -> HashMap { let mut client_metadata = HashMap::from([ ( X_CODEX_INSTALLATION_ID_HEADER.to_string(), self.installation_id.clone(), ), (SESSION_ID_KEY.to_string(), self.session_id.clone()), (THREAD_ID_KEY.to_string(), self.thread_id.clone()), (X_CODEX_WINDOW_ID_HEADER.to_string(), self.window_id.clone()), ]); if let Some(turn_id) = &self.turn_id { client_metadata.insert(TURN_ID_KEY.to_string(), turn_id.clone()); } if let Some(subagent_header) = &self.subagent_header { client_metadata.insert( X_OPENAI_SUBAGENT_HEADER.to_string(), subagent_header.clone(), ); } if let Some(parent_thread_id) = self.parent_thread_id { client_metadata.insert( X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), parent_thread_id.to_string(), ); } if self.has_turn_metadata() && let Some(turn_metadata_json) = self.turn_metadata_json() { client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata_json); } client_metadata } pub(crate) fn compatibility_headers(&self) -> ApiHeaderMap { let mut headers = ApiHeaderMap::new(); insert_header(&mut headers, X_CODEX_WINDOW_ID_HEADER, &self.window_id); // Direct x-codex-turn-metadata is compatibility output. New per-request consumers should // prefer client_metadata["x-codex-turn-metadata"], which is rendered from this same object. if self.has_turn_metadata() && let Some(turn_metadata_json) = self.turn_metadata_json() { insert_header( &mut headers, X_CODEX_TURN_METADATA_HEADER, &turn_metadata_json, ); } if let Some(parent_thread_id) = self.parent_thread_id { insert_header( &mut headers, X_CODEX_PARENT_THREAD_ID_HEADER, &parent_thread_id.to_string(), ); } if let Some(subagent_header) = &self.subagent_header { insert_header(&mut headers, X_OPENAI_SUBAGENT_HEADER, subagent_header); } headers } fn turn_metadata_payload(&self) -> CodexTurnMetadataPayload<'_> { let request_kind = self.request_kind; let (request_kind_value, compaction) = request_kind.map_or((None, None), |request_kind| { let (request_kind, compaction) = request_kind.metadata(); (Some(request_kind), compaction) }); let has_turn_identity = request_kind.is_none_or(CodexResponsesRequestKind::has_turn_identity); let has_request_identity = request_kind.is_some_and(CodexResponsesRequestKind::has_turn_identity); CodexTurnMetadataPayload { installation_id: has_request_identity.then_some(self.installation_id.as_str()), session_id: has_turn_identity.then_some(self.session_id.as_str()), thread_id: has_turn_identity.then_some(self.thread_id.as_str()), turn_id: has_turn_identity .then_some(self.turn_id.as_deref()) .flatten(), window_id: has_request_identity.then_some(self.window_id.as_str()), request_kind: request_kind_value, forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, subagent_kind: self.subagent_kind.as_deref(), sandbox: self.sandbox.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), turn_started_at_unix_ms: self.turn_started_at_unix_ms, compaction, // responsesapi_client_metadata enriches the Codex turn metadata blob, not literal // top-level Responses client_metadata. Reserved Codex-owned keys are filtered when // these extras enter turn state. extra: &self.extra, } } } pub(crate) fn subagent_header_value(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => match subagent_source { SubAgentSource::Review => Some("review".to_string()), SubAgentSource::Compact => Some("compact".to_string()), SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()), SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()), SubAgentSource::Other(label) => Some(label.clone()), }, SessionSource::Internal(InternalSessionSource::MemoryConsolidation) => { Some("memory_consolidation".to_string()) } SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, } } pub(crate) fn subagent_metadata_kind(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => Some(subagent_source.kind().to_string()), SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Internal(_) | SessionSource::Unknown => None, } } fn insert_header(headers: &mut ApiHeaderMap, name: &'static str, value: &str) { if let Ok(header_value) = HeaderValue::from_str(value) { headers.insert(name, header_value); } } pub(crate) fn filter_extra_metadata(extra: HashMap) -> BTreeMap { extra .into_iter() .filter(|(key, _)| !RESERVED_METADATA_KEYS.contains(&key.as_str())) .collect() } fn non_empty_workspaces( workspaces: &BTreeMap, ) -> Option<&BTreeMap> { (!workspaces.is_empty()).then_some(workspaces) } #[derive(Serialize)] struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] installation_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] thread_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] window_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] request_kind: Option<&'static str>, #[serde(default, skip_serializing_if = "Option::is_none")] forked_from_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] parent_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] subagent_kind: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] sandbox: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] turn_started_at_unix_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] compaction: Option, #[serde(flatten)] extra: &'a BTreeMap, }