mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## 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`
367 lines
14 KiB
Rust
367 lines
14 KiB
Rust
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<CompactionTurnMetadata>) {
|
|
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<BTreeMap<String, String>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) latest_git_commit_hash: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) has_changes: Option<bool>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub(crate) window_id: String,
|
|
pub(crate) request_kind: Option<CodexResponsesRequestKind>,
|
|
pub(crate) forked_from_thread_id: Option<ThreadId>,
|
|
pub(crate) parent_thread_id: Option<ThreadId>,
|
|
pub(crate) subagent_header: Option<String>,
|
|
pub(crate) subagent_kind: Option<String>,
|
|
pub(crate) sandbox: Option<String>,
|
|
pub(crate) workspaces: BTreeMap<String, TurnMetadataWorkspace>,
|
|
pub(crate) turn_started_at_unix_ms: Option<i64>,
|
|
pub(crate) extra: BTreeMap<String, String>,
|
|
}
|
|
|
|
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<String> {
|
|
to_ascii_json_string(&self.turn_metadata_payload()).ok()
|
|
}
|
|
|
|
pub(crate) fn turn_metadata_value(&self) -> Option<Value> {
|
|
serde_json::to_value(self.turn_metadata_payload()).ok()
|
|
}
|
|
|
|
pub(crate) fn client_metadata(&self) -> HashMap<String, String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String, String>) -> BTreeMap<String, String> {
|
|
extra
|
|
.into_iter()
|
|
.filter(|(key, _)| !RESERVED_METADATA_KEYS.contains(&key.as_str()))
|
|
.collect()
|
|
}
|
|
|
|
fn non_empty_workspaces(
|
|
workspaces: &BTreeMap<String, TurnMetadataWorkspace>,
|
|
) -> Option<&BTreeMap<String, TurnMetadataWorkspace>> {
|
|
(!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<ThreadId>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
parent_thread_id: Option<ThreadId>,
|
|
#[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<String, TurnMetadataWorkspace>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
turn_started_at_unix_ms: Option<i64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
compaction: Option<CompactionTurnMetadata>,
|
|
#[serde(flatten)]
|
|
extra: &'a BTreeMap<String, String>,
|
|
}
|