Forward turn metadata to image generation and edit requests (#54058)

## Why

Native extensions need configured Responses metadata for request attribution, but extension calls used the MCP metadata template, which omits it. Image requests also did not forward the turn metadata to the backend.

## What changed

Use the Responses metadata template for native extension calls while continuing to exclude tool inventory. Forward the resulting metadata in the `x-codex-turn-metadata` header on image generation and edit requests.

## Testing

Extend metadata tests to cover configured values in extension calls and app-server tests to verify `display_name` and `turn_trigger` on image generation and edit requests, including a Unicode display name.

GitOrigin-RevId: a2ffaa4d15259c662917612391b8717ee34b9c6c
This commit is contained in:
duc-openai
2026-10-14 18:01:54 +00:00
committed by copyberry
parent 1f7fe9b0af
commit 9e7b2a577f
6 changed files with 113 additions and 18 deletions
@@ -119,10 +119,27 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let turn_id = start_image_generation_turn(
let turn_id = start_turn(
&mut mcp,
ThreadStartParams {
service_name: Some("chatgpt_cca".to_string()),
config: Some(
[(
"responses_api_metadata".to_string(),
json!({
"display_name": "Image test 🚀",
}),
)]
.into(),
),
..Default::default()
},
TurnStartParams {
input: vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
turn_trigger: Some("page_prompt".to_string()),
..Default::default()
},
)
@@ -183,6 +200,15 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
"chatgpt_cca"
);
assert_image_turn_id_header(&image_request, &turn_id)?;
let metadata = serde_json::from_str::<serde_json::Value>(
image_request
.headers
.get("x-codex-turn-metadata")
.context("image request should include the current turn metadata")?
.to_str()?,
)?;
assert_eq!(metadata["display_name"], "Image test 🚀");
assert_eq!(metadata["turn_trigger"], "page_prompt");
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
@@ -971,10 +997,13 @@ async fn start_image_generation_turn(
start_turn(
mcp,
thread_start_params,
vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
TurnStartParams {
input: vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
},
)
.await
}
@@ -1025,9 +1054,22 @@ async fn run_image_edit_test(
&mut mcp,
ThreadStartParams {
service_name: Some("chatgpt_cca".to_string()),
config: Some(
[(
"responses_api_metadata".to_string(),
json!({
"display_name": "Image edit test",
}),
)]
.into(),
),
..Default::default()
},
TurnStartParams {
input,
turn_trigger: Some("page_prompt".to_string()),
..Default::default()
},
input,
)
.await?;
let completed = timeout(
@@ -1063,6 +1105,15 @@ async fn run_image_edit_test(
"chatgpt_cca"
);
assert_image_turn_id_header(image_request, &turn_id)?;
let metadata = serde_json::from_str::<serde_json::Value>(
image_request
.headers
.get("x-codex-turn-metadata")
.context("image edit request should include the current turn metadata")?
.to_str()?,
)?;
assert_eq!(metadata["display_name"], "Image edit test");
assert_eq!(metadata["turn_trigger"], "page_prompt");
Ok((
image_request.body_json::<serde_json::Value>()?,
completed_image,
@@ -1084,7 +1135,7 @@ fn assert_image_turn_id_header(request: &wiremock::Request, expected_turn_id: &s
async fn start_turn(
mcp: &mut TestAppServer,
thread_start_params: ThreadStartParams,
input: Vec<V2UserInput>,
turn_start_params: TurnStartParams,
) -> Result<String> {
let thread_req = mcp
.send_thread_start_request_with_auto_env(thread_start_params)
@@ -1095,9 +1146,7 @@ async fn start_turn(
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input,
..Default::default()
..turn_start_params
})
.await?;
let TurnStartResponse { turn } =
@@ -195,7 +195,7 @@ async fn to_extension_call<'a>(
let codex_turn_metadata = invocation
.turn
.turn_metadata_state
.current_meta_value_for_mcp_request(ExecutionMetadata::from_settings(
.current_meta_value_for_extension_request(ExecutionMetadata::from_settings(
&invocation.step_context.settings,
))
.and_then(|metadata| to_ascii_json_string(&metadata).ok());
+18 -2
View File
@@ -276,7 +276,23 @@ impl TurnMetadataState {
&self,
execution: ExecutionMetadata<'_>,
) -> Option<serde_json::Value> {
let mut responses_metadata = self.mcp_metadata_template();
self.current_meta_value_for_tool_request(self.mcp_metadata_template(), execution)
}
pub(crate) fn current_meta_value_for_extension_request(
&self,
execution: ExecutionMetadata<'_>,
) -> Option<serde_json::Value> {
// Native extensions need configured Responses metadata for request attribution,
// which the MCP template omits for external servers.
self.current_meta_value_for_tool_request(self.responses_metadata_template(), execution)
}
fn current_meta_value_for_tool_request(
&self,
mut responses_metadata: CodexResponsesMetadata,
execution: ExecutionMetadata<'_>,
) -> Option<serde_json::Value> {
if responses_metadata
.extra
.get("misalignment_override")
@@ -286,7 +302,7 @@ impl TurnMetadataState {
responses_metadata.extra.remove("misalignment_override");
}
execution.apply_to(&mut responses_metadata);
// Never serialize harness-owned tool inventory for external MCP servers.
// Never serialize harness-owned tool inventory for tool requests.
responses_metadata.tool_namespaces_info = None;
let Value::Object(mut metadata) = responses_metadata.turn_metadata_value()? else {
return None;
+8
View File
@@ -972,6 +972,14 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert!(meta.get(WINDOW_ID_KEY).is_none());
assert!(meta.get("codex_security_surface").is_none());
assert!(meta.get(ANALYTICS_ENABLED_KEY).is_none());
let extension_meta = state
.current_meta_value_for_extension_request(test_mcp_turn_metadata_context())
.expect("extension metadata should be present");
assert_eq!(extension_meta["codex_security_surface"], "sdk");
assert_eq!(extension_meta["source"], " Configured_Source ");
assert_eq!(extension_meta[TURN_TRIGGER_KEY], "goal");
assert_eq!(extension_meta["model"], "gpt-5.4");
assert!(extension_meta.get(TOOL_NAMESPACES_INFO_KEY).is_none());
assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
assert_eq!(
(state.turn_trigger(), state.codex_turn_source()),
+17 -3
View File
@@ -5,6 +5,7 @@ use codex_api::ImageResponse;
use codex_api::ImagesClient;
use codex_api::ReqwestTransport;
use codex_api::map_api_error;
use codex_core::X_CODEX_TURN_METADATA_HEADER;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
use codex_login::default_client::add_originator_header;
@@ -101,12 +102,13 @@ impl CodexImagesBackend {
&self,
request: ImageGenerationRequest,
turn_id: &str,
codex_turn_metadata: Option<&str>,
) -> Result<(ImageResponse, Option<String>), ImageBackendError> {
self.client()
.await?
.generate(
&request,
image_request_headers(self.originator.as_deref(), turn_id),
image_request_headers(self.originator.as_deref(), turn_id, codex_turn_metadata),
)
.await
.map_err(ImageBackendError::from_image_request)
@@ -117,19 +119,24 @@ impl CodexImagesBackend {
&self,
request: ImageEditRequest,
turn_id: &str,
codex_turn_metadata: Option<&str>,
) -> Result<(ImageResponse, Option<String>), ImageBackendError> {
self.client()
.await?
.edit(
&request,
image_request_headers(self.originator.as_deref(), turn_id),
image_request_headers(self.originator.as_deref(), turn_id, codex_turn_metadata),
)
.await
.map_err(ImageBackendError::from_image_request)
}
}
fn image_request_headers(originator: Option<&str>, turn_id: &str) -> HeaderMap {
fn image_request_headers(
originator: Option<&str>,
turn_id: &str,
codex_turn_metadata: Option<&str>,
) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(turn_id) = HeaderValue::from_str(turn_id) {
headers.insert(X_CODEX_IMAGE_TURN_ID_HEADER, turn_id);
@@ -137,5 +144,12 @@ fn image_request_headers(originator: Option<&str>, turn_id: &str) -> HeaderMap {
if let Some(originator) = originator {
add_originator_header(&mut headers, originator);
}
if let Some(metadata) = codex_turn_metadata
&& let Ok(metadata) = HeaderValue::from_str(metadata)
{
headers.insert(X_CODEX_TURN_METADATA_HEADER, metadata);
}
headers
}
+10 -2
View File
@@ -169,8 +169,16 @@ impl ImageGenerationTool {
))
.await;
let result = match request {
ImageRequest::Generate(request) => self.backend.generate(request, &call.turn_id).await,
ImageRequest::Edit(request) => self.backend.edit(request, &call.turn_id).await,
ImageRequest::Generate(request) => {
self.backend
.generate(request, &call.turn_id, call.codex_turn_metadata.as_deref())
.await
}
ImageRequest::Edit(request) => {
self.backend
.edit(request, &call.turn_id, call.codex_turn_metadata.as_deref())
.await
}
}
.map_err(|error| {
(