mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Forward turn metadata in standalone web search (#32835)
## What changed - Add optional serialized Codex turn metadata to extension tool calls. - Populate it from the current turn context and send it as the `x-codex-turn-metadata` header on standalone web search requests. ## Testing - Extend the app-server web search integration test to verify that client metadata, including the model ID, reaches the standalone search request. GitOrigin-RevId: c0950108d89a1d317ac94ccd23efcee72fffc6f0
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -44,6 +45,18 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
#[tokio::test]
|
||||
async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
let call_id = "web-run-1";
|
||||
let expected_model_id = "model-id-from-search-context";
|
||||
let search_context = json!({
|
||||
"telemetry_attributes": {
|
||||
"model_id": expected_model_id,
|
||||
"model_slug": "mock-model",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let client_metadata = HashMap::from([(
|
||||
"mcp_request_meta".to_string(),
|
||||
json!({ "openai/search_context": search_context }).to_string(),
|
||||
)]);
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_search_response(&server).await;
|
||||
|
||||
@@ -105,6 +118,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
text: "Search the web".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
responsesapi_client_metadata: Some(client_metadata.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -144,7 +158,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
"standalone web search should replace hosted web search"
|
||||
);
|
||||
|
||||
let search_body = search_request_body(&server).await?;
|
||||
let search_request = search_request(&server).await?;
|
||||
let search_body = search_request
|
||||
.body_json::<Value>()
|
||||
.context("search request body should be JSON")?;
|
||||
assert_eq!(search_body["model"], json!("mock-model"));
|
||||
assert_eq!(
|
||||
search_body["commands"],
|
||||
@@ -169,6 +186,30 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
"content": [{"type": "input_text", "text": "Search the web"}],
|
||||
}))
|
||||
);
|
||||
let turn_metadata_header = search_request
|
||||
.headers
|
||||
.get("x-codex-turn-metadata")
|
||||
.context("standalone search should include x-codex-turn-metadata")?
|
||||
.to_str()
|
||||
.context("x-codex-turn-metadata should be valid ASCII")?;
|
||||
let turn_metadata: Value = serde_json::from_str(turn_metadata_header)
|
||||
.context("x-codex-turn-metadata should be valid JSON")?;
|
||||
let mcp_request_meta = turn_metadata["mcp_request_meta"]
|
||||
.as_str()
|
||||
.context("mcp_request_meta should be a JSON string")?;
|
||||
let mcp_request_meta: Value = serde_json::from_str(mcp_request_meta)
|
||||
.context("mcp_request_meta should contain valid JSON")?;
|
||||
let search_context = mcp_request_meta["openai/search_context"]
|
||||
.as_str()
|
||||
.context("openai/search_context should be a JSON string")?;
|
||||
let search_context: Value = serde_json::from_str(search_context)
|
||||
.context("openai/search_context should contain valid JSON")?;
|
||||
assert_eq!(
|
||||
search_context
|
||||
.pointer("/telemetry_attributes/model_id")
|
||||
.and_then(Value::as_str),
|
||||
Some(expected_model_id)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
responses::strip_metadata_from_json(requests[1].function_call_output(call_id)),
|
||||
@@ -285,16 +326,15 @@ fn has_hosted_web_search(body: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_request_body(server: &MockServer) -> Result<Value> {
|
||||
server
|
||||
async fn search_request(server: &MockServer) -> Result<wiremock::Request> {
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.context("failed to fetch received requests")?
|
||||
.context("failed to fetch received requests")?;
|
||||
requests
|
||||
.into_iter()
|
||||
.find(|request| request.url.path() == "/api/codex/alpha/search")
|
||||
.context("expected standalone search request")?
|
||||
.body_json()
|
||||
.context("search request body should be JSON")
|
||||
.context("expected standalone search request")
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_tools::ToolSearchInfo;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::TurnItemEmissionFuture;
|
||||
use codex_tools::TurnItemEmitter;
|
||||
use codex_utils_string::to_ascii_json_string;
|
||||
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::session::session::Session;
|
||||
@@ -22,6 +23,7 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use crate::turn_metadata::McpTurnMetadataContext;
|
||||
|
||||
pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);
|
||||
|
||||
@@ -114,6 +116,14 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
let conversation_history =
|
||||
ConversationHistory::new(invocation.session.clone_history().await.into_raw_items());
|
||||
let codex_turn_metadata = invocation
|
||||
.turn
|
||||
.turn_metadata_state
|
||||
.current_meta_value_for_mcp_request(McpTurnMetadataContext {
|
||||
model: invocation.turn.model_info.slug.as_str(),
|
||||
reasoning_effort: invocation.turn.effective_reasoning_effort(),
|
||||
})
|
||||
.and_then(|metadata| to_ascii_json_string(&metadata).ok());
|
||||
let mut environments =
|
||||
Vec::with_capacity(invocation.step_context.environments.turn_environments.len());
|
||||
for environment in &invocation.step_context.environments.turn_environments {
|
||||
@@ -146,6 +156,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
call_id: invocation.call_id.clone(),
|
||||
tool_name: invocation.tool_name.clone(),
|
||||
model: invocation.turn.model_info.slug.clone(),
|
||||
codex_turn_metadata,
|
||||
truncation_policy: invocation.turn.model_info.truncation_policy.into(),
|
||||
conversation_history,
|
||||
turn_item_emitter: Arc::new(CoreTurnItemEmitter {
|
||||
|
||||
@@ -1343,6 +1343,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To
|
||||
call_id: call_id.to_string(),
|
||||
tool_name: codex_extension_api::ToolName::plain(tool_name),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
|
||||
@@ -212,6 +212,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -256,6 +257,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -301,6 +303,7 @@ async fn read_tool_reads_memory_file() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -349,6 +352,7 @@ async fn search_tool_accepts_multiple_queries() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -423,6 +427,7 @@ async fn search_tool_accepts_windowed_all_match_mode() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
@@ -477,6 +482,7 @@ async fn search_tool_rejects_legacy_single_query() {
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
conversation_history: codex_extension_api::ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
|
||||
@@ -438,6 +438,7 @@ async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResu
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: list_tool.tool_name(),
|
||||
model: "gpt-test".to_string(),
|
||||
codex_turn_metadata: None,
|
||||
truncation_policy: TruncationPolicy::Bytes(1_024),
|
||||
conversation_history: ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_api::SearchCommands;
|
||||
use codex_api::SearchQuery;
|
||||
use codex_api::SearchRequest;
|
||||
use codex_api::SearchSettings;
|
||||
use codex_core::X_CODEX_TURN_METADATA_HEADER;
|
||||
use codex_core::web_search_action_detail;
|
||||
use codex_extension_api::ExtensionTurnItem;
|
||||
use codex_extension_api::FunctionCallError;
|
||||
@@ -28,6 +29,7 @@ use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolExposure;
|
||||
use codex_tools::default_namespace_description;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use url::Url;
|
||||
|
||||
use crate::history::recent_input;
|
||||
@@ -113,6 +115,12 @@ impl WebSearchTool {
|
||||
u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX),
|
||||
),
|
||||
};
|
||||
let mut extra_headers = HeaderMap::new();
|
||||
if let Some(turn_metadata) = call.codex_turn_metadata.as_deref()
|
||||
&& let Ok(header_value) = HeaderValue::from_str(turn_metadata)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_TURN_METADATA_HEADER, header_value);
|
||||
}
|
||||
call.turn_item_emitter
|
||||
.emit_started(extension_turn_item(
|
||||
WebSearchItem {
|
||||
@@ -126,7 +134,7 @@ impl WebSearchTool {
|
||||
))
|
||||
.await;
|
||||
let response = client
|
||||
.search(&request, HeaderMap::new())
|
||||
.search(&request, extra_headers)
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
|
||||
let legacy_action = match &command_action {
|
||||
|
||||
@@ -92,6 +92,7 @@ pub struct ToolCall {
|
||||
pub call_id: String,
|
||||
pub tool_name: ToolName,
|
||||
pub model: String,
|
||||
pub codex_turn_metadata: Option<String>,
|
||||
pub truncation_policy: TruncationPolicy,
|
||||
pub conversation_history: ConversationHistory,
|
||||
pub turn_item_emitter: Arc<dyn TurnItemEmitter>,
|
||||
@@ -106,6 +107,10 @@ impl std::fmt::Debug for ToolCall {
|
||||
.field("call_id", &self.call_id)
|
||||
.field("tool_name", &self.tool_name)
|
||||
.field("model", &self.model)
|
||||
.field(
|
||||
"has_codex_turn_metadata",
|
||||
&self.codex_turn_metadata.is_some(),
|
||||
)
|
||||
.field("truncation_policy", &self.truncation_policy)
|
||||
.field("conversation_history", &self.conversation_history)
|
||||
.field("turn_item_emitter", &"<host turn item emitter>")
|
||||
|
||||
Reference in New Issue
Block a user