mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
14df0e8833
## What Introduce a `CodexResponsesMetadata` struct that defines all the core metadata we send to Responses API. Example fields are `thread_id`, `turn_id`, `window_id`, etc. Going forward, `client_metadata["x-codex-turn-metadata"]` will be the canonical way Codex sends metadata to Responses API across both HTTP and websocket transports. For now, we continue to emit the existing top-level HTTP headers and top-level `client_metadata` fields from the same `CodexResponsesMetadata` struct for compatibility reasons. Also, app-server clients who specify additional `responsesapi_client_metadata` via `turn/start` and `turn/steer` will have those fields merged into `client_metadata["x-codex-turn-metadata"]`, but cannot override the reserved fields that core uses (i.e. the fields in `CodexResponsesMetadata`). ## Why Responses API request instrumentation is the source of truth for downstream Codex analytics that join requests by Codex IDs such as session, thread, turn, and context window. Before this change, those values were assembled through several request-specific paths: HTTP request bodies, websocket handshake headers, websocket `response.create` payloads, compaction requests, and the rich `x-codex-turn-metadata` envelope all had their own wiring. That made metadata propagation easy to drift across API-key/direct Responses API requests, ChatGPT-auth/proxied requests, websocket requests, and compaction requests. It also made additions like `window_id` error-prone because a field could be added to one transport projection but missed in another. ## What changed - Added `CodexResponsesMetadata` as the core-owned snapshot for Codex metadata sent to ResponsesAPI. - Render `client_metadata["x-codex-turn-metadata"]`, flat `client_metadata` projections, and direct compatibility headers from that same snapshot. - Include the known Codex-owned fields in the turn metadata blob, including installation/session/thread/turn/window IDs, request kind, lineage, sandbox/workspace metadata, timing, and compaction details. - Treat app-server `responsesapi_client_metadata` as enrichment for the Codex turn metadata blob while preventing those extras from overriding Codex-owned fields. - Use the same metadata path for normal turns, websocket prewarm, local compaction, remote v1 compaction, and remote v2 compaction. - Keep websocket connection-only preconnect metadata separate so handshakes carry compatibility identity headers without inventing a fake turn metadata blob. ## Verification - `cargo check -p codex-core` - `just fix -p codex-core`
350 lines
12 KiB
Rust
350 lines
12 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::RwLock;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use serde_json::Value;
|
|
use tokio::task::JoinHandle;
|
|
|
|
use crate::responses_metadata::CodexResponsesMetadata;
|
|
use crate::responses_metadata::CodexResponsesRequestKind;
|
|
use crate::responses_metadata::TurnMetadataWorkspace;
|
|
use crate::responses_metadata::filter_extra_metadata;
|
|
use crate::responses_metadata::subagent_header_value;
|
|
use crate::responses_metadata::subagent_metadata_kind;
|
|
use crate::sandbox_tags::permission_profile_sandbox_tag;
|
|
use codex_git_utils::get_git_remote_urls_assume_git_repo;
|
|
use codex_git_utils::get_git_repo_root;
|
|
use codex_git_utils::get_has_changes;
|
|
use codex_git_utils::get_head_commit_hash;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
|
|
const MODEL_KEY: &str = "model";
|
|
const REASONING_EFFORT_KEY: &str = "reasoning_effort";
|
|
const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn";
|
|
const WORKSPACE_KIND_KEY: &str = "workspace_kind";
|
|
|
|
pub(crate) struct McpTurnMetadataContext<'a> {
|
|
pub(crate) model: &'a str,
|
|
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct WorkspaceGitMetadata {
|
|
associated_remote_urls: Option<BTreeMap<String, String>>,
|
|
latest_git_commit_hash: Option<String>,
|
|
has_changes: Option<bool>,
|
|
}
|
|
|
|
impl WorkspaceGitMetadata {
|
|
fn is_empty(&self) -> bool {
|
|
self.associated_remote_urls.is_none()
|
|
&& self.latest_git_commit_hash.is_none()
|
|
&& self.has_changes.is_none()
|
|
}
|
|
}
|
|
|
|
impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
|
|
fn from(value: WorkspaceGitMetadata) -> Self {
|
|
Self {
|
|
associated_remote_urls: value.associated_remote_urls,
|
|
latest_git_commit_hash: value.latest_git_commit_hash,
|
|
has_changes: value.has_changes,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn detached_memory_responses_metadata(
|
|
installation_id: String,
|
|
session_id: String,
|
|
thread_id: String,
|
|
window_id: String,
|
|
session_source: &SessionSource,
|
|
cwd: &AbsolutePathBuf,
|
|
sandbox: Option<&str>,
|
|
) -> CodexResponsesMetadata {
|
|
CodexResponsesMetadata {
|
|
request_kind: Some(CodexResponsesRequestKind::Memory),
|
|
subagent_header: subagent_header_value(session_source),
|
|
sandbox: sandbox.map(ToString::to_string),
|
|
workspaces: memory_workspaces(cwd).await,
|
|
..CodexResponsesMetadata::new(installation_id, session_id, thread_id, window_id)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct TurnMetadataState {
|
|
cwd: AbsolutePathBuf,
|
|
repo_root: Option<String>,
|
|
session_id: String,
|
|
thread_id: String,
|
|
forked_from_thread_id: Option<ThreadId>,
|
|
parent_thread_id: Option<ThreadId>,
|
|
subagent_header: Option<String>,
|
|
subagent_kind: Option<String>,
|
|
turn_id: String,
|
|
sandbox: Option<String>,
|
|
enriched_workspaces: Arc<RwLock<Option<BTreeMap<String, TurnMetadataWorkspace>>>>,
|
|
turn_started_at_unix_ms: Arc<RwLock<Option<i64>>>,
|
|
responsesapi_client_metadata: Arc<RwLock<BTreeMap<String, String>>>,
|
|
user_input_requested_during_turn: Arc<AtomicBool>,
|
|
enrichment_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
|
}
|
|
|
|
impl TurnMetadataState {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn new(
|
|
session_id: String,
|
|
thread_id: String,
|
|
forked_from_thread_id: Option<ThreadId>,
|
|
parent_thread_id: Option<ThreadId>,
|
|
session_source: &SessionSource,
|
|
turn_id: String,
|
|
cwd: AbsolutePathBuf,
|
|
permission_profile: &PermissionProfile,
|
|
windows_sandbox_level: WindowsSandboxLevel,
|
|
enforce_managed_network: bool,
|
|
) -> Self {
|
|
let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned());
|
|
let sandbox = Some(
|
|
permission_profile_sandbox_tag(
|
|
permission_profile,
|
|
windows_sandbox_level,
|
|
enforce_managed_network,
|
|
)
|
|
.to_string(),
|
|
);
|
|
Self {
|
|
cwd,
|
|
repo_root,
|
|
session_id,
|
|
thread_id,
|
|
forked_from_thread_id,
|
|
parent_thread_id,
|
|
subagent_header: subagent_header_value(session_source),
|
|
subagent_kind: subagent_metadata_kind(session_source),
|
|
turn_id,
|
|
sandbox,
|
|
enriched_workspaces: Arc::new(RwLock::new(None)),
|
|
turn_started_at_unix_ms: Arc::new(RwLock::new(None)),
|
|
responsesapi_client_metadata: Arc::new(RwLock::new(BTreeMap::new())),
|
|
user_input_requested_during_turn: Arc::new(AtomicBool::new(false)),
|
|
enrichment_task: Arc::new(Mutex::new(None)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn current_meta_value_for_mcp_request(
|
|
&self,
|
|
context: McpTurnMetadataContext<'_>,
|
|
) -> Option<serde_json::Value> {
|
|
let Value::Object(mut metadata) =
|
|
self.responses_metadata_template().turn_metadata_value()?
|
|
else {
|
|
return None;
|
|
};
|
|
metadata.insert(
|
|
MODEL_KEY.to_string(),
|
|
Value::String(context.model.to_string()),
|
|
);
|
|
match context.reasoning_effort {
|
|
Some(reasoning_effort) => {
|
|
metadata.insert(
|
|
REASONING_EFFORT_KEY.to_string(),
|
|
Value::String(reasoning_effort.to_string()),
|
|
);
|
|
}
|
|
None => {
|
|
metadata.remove(REASONING_EFFORT_KEY);
|
|
}
|
|
}
|
|
if self
|
|
.user_input_requested_during_turn
|
|
.load(Ordering::Relaxed)
|
|
{
|
|
metadata.insert(
|
|
USER_INPUT_REQUESTED_DURING_TURN_KEY.to_string(),
|
|
Value::Bool(true),
|
|
);
|
|
} else {
|
|
metadata.remove(USER_INPUT_REQUESTED_DURING_TURN_KEY);
|
|
}
|
|
Some(Value::Object(metadata))
|
|
}
|
|
|
|
pub(crate) fn to_responses_metadata(
|
|
&self,
|
|
installation_id: String,
|
|
window_id: String,
|
|
request_kind: CodexResponsesRequestKind,
|
|
) -> CodexResponsesMetadata {
|
|
CodexResponsesMetadata {
|
|
installation_id,
|
|
window_id,
|
|
request_kind: Some(request_kind),
|
|
..self.responses_metadata_template()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mark_user_input_requested_during_turn(&self) {
|
|
self.user_input_requested_during_turn
|
|
.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
pub(crate) fn set_responsesapi_client_metadata(
|
|
&self,
|
|
responsesapi_client_metadata: HashMap<String, String>,
|
|
) {
|
|
*self
|
|
.responsesapi_client_metadata
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
|
filter_extra_metadata(responsesapi_client_metadata);
|
|
}
|
|
|
|
pub(crate) fn workspace_kind(&self) -> Option<String> {
|
|
self.responsesapi_client_metadata
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.get(WORKSPACE_KIND_KEY)
|
|
.cloned()
|
|
}
|
|
|
|
fn responses_metadata_template(&self) -> CodexResponsesMetadata {
|
|
CodexResponsesMetadata {
|
|
turn_id: Some(self.turn_id.clone()),
|
|
forked_from_thread_id: self.forked_from_thread_id,
|
|
parent_thread_id: self.parent_thread_id,
|
|
subagent_header: self.subagent_header.clone(),
|
|
subagent_kind: self.subagent_kind.clone(),
|
|
sandbox: self.sandbox.clone(),
|
|
workspaces: self.current_workspaces(),
|
|
turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(),
|
|
extra: self
|
|
.responsesapi_client_metadata
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone(),
|
|
..CodexResponsesMetadata::new(
|
|
String::new(),
|
|
self.session_id.clone(),
|
|
self.thread_id.clone(),
|
|
String::new(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn current_workspaces(&self) -> BTreeMap<String, TurnMetadataWorkspace> {
|
|
self.enriched_workspaces
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn current_turn_started_at_unix_ms(&self) -> Option<i64> {
|
|
*self
|
|
.turn_started_at_unix_ms
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) {
|
|
*self
|
|
.turn_started_at_unix_ms
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(turn_started_at_unix_ms);
|
|
}
|
|
|
|
pub(crate) fn spawn_git_enrichment_task(&self) {
|
|
if self.repo_root.is_none() {
|
|
return;
|
|
}
|
|
|
|
let mut task_guard = self
|
|
.enrichment_task
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if task_guard.is_some() {
|
|
return;
|
|
}
|
|
|
|
let state = self.clone();
|
|
*task_guard = Some(tokio::spawn(async move {
|
|
let workspace_git_metadata = state.fetch_workspace_git_metadata().await;
|
|
let Some(repo_root) = state.repo_root.clone() else {
|
|
return;
|
|
};
|
|
|
|
if workspace_git_metadata.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut workspaces = BTreeMap::new();
|
|
workspaces.insert(repo_root, workspace_git_metadata.into());
|
|
*state
|
|
.enriched_workspaces
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(workspaces);
|
|
}));
|
|
}
|
|
|
|
pub(crate) fn cancel_git_enrichment_task(&self) {
|
|
let mut task_guard = self
|
|
.enrichment_task
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some(task) = task_guard.take() {
|
|
task.abort();
|
|
}
|
|
}
|
|
|
|
async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata {
|
|
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
|
|
get_head_commit_hash(&self.cwd),
|
|
get_git_remote_urls_assume_git_repo(&self.cwd),
|
|
get_has_changes(&self.cwd),
|
|
);
|
|
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
|
|
|
|
WorkspaceGitMetadata {
|
|
associated_remote_urls,
|
|
latest_git_commit_hash,
|
|
has_changes,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn memory_workspaces(cwd: &AbsolutePathBuf) -> BTreeMap<String, TurnMetadataWorkspace> {
|
|
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
|
|
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
|
|
get_head_commit_hash(cwd),
|
|
get_git_remote_urls_assume_git_repo(cwd),
|
|
get_has_changes(cwd),
|
|
);
|
|
let workspace_git_metadata = WorkspaceGitMetadata {
|
|
associated_remote_urls,
|
|
latest_git_commit_hash: head_commit_hash.map(|sha| sha.0),
|
|
has_changes,
|
|
};
|
|
let mut workspaces = BTreeMap::new();
|
|
if let Some(repo_root) = repo_root
|
|
&& !workspace_git_metadata.is_empty()
|
|
{
|
|
workspaces.insert(repo_root, workspace_git_metadata.into());
|
|
}
|
|
workspaces
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "turn_metadata_tests.rs"]
|
|
mod tests;
|