Track cumulative MCP attribution across requests and thread history (#47081)

## What changed

- Record each MCP source and its first turn, including connector and plugin identifiers when available. Cover direct and Code Mode calls, including returned errors and results that are not printed.
- Persist cumulative attribution checkpoints and restore them across compaction, resume, and forks, including forks limited to recent turns.
- Attach fresh attribution to Responses API `client_metadata` under `mcp_attribution`, outside model-visible content. Restrict transmission to allowed OpenAI HTTPS destinations for both HTTP and WebSocket requests.
- Report `attribution_error` for legacy history without checkpoints, malformed or conflicting attribution, and serialized attribution exceeding 16 KiB.

## Testing

Add coverage for source deduplication, checkpoint persistence and restoration, recent-turn forks, direct and Code Mode calls, destination filtering, and the serialized size limit.

GitOrigin-RevId: fd285e152ca9e24bfa000debb20013709d67a86e
This commit is contained in:
peilin-openai
2026-09-21 18:19:07 +00:00
committed by copyberry
parent 7cec3e110c
commit ac98537678
28 changed files with 1119 additions and 73 deletions
+30 -33
View File
@@ -405,6 +405,16 @@ fn response_items_equal_ignoring_internal_metadata(
previous == current
}
/// Whether the resolved outbound Responses destination may receive internal tool metadata.
fn is_internal_metadata_destination(provider: &ApiProvider) -> bool {
url::Url::parse(&provider.base_url).ok().is_some_and(|url| {
url.scheme() == "https"
&& url.host_str().is_some_and(|host| {
host == "api.openai.com" || codex_http_client::is_allowed_chatgpt_host(host)
})
})
}
impl WebsocketSession {
fn reset(&mut self, reason: Option<&'static str>) {
// Per-socket backend metrics call a resend after reconnect "initial".
@@ -802,9 +812,10 @@ impl ModelClient {
fn build_ws_client_metadata(
&self,
responses_metadata: &CodexResponsesMetadata,
include_internal: bool,
use_responses_lite: bool,
) -> HashMap<String, String> {
let mut client_metadata = responses_metadata.client_metadata();
let mut client_metadata = responses_metadata.client_metadata(include_internal);
if use_responses_lite {
client_metadata.insert(
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(),
@@ -866,6 +877,7 @@ impl ModelClient {
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_responses_request(
&self,
prompt: &Prompt,
@@ -874,6 +886,7 @@ impl ModelClient {
summary: ReasoningSummaryConfig,
service_tier: Option<String>,
responses_metadata: &CodexResponsesMetadata,
include_internal: bool,
) -> Result<ResponsesApiRequest> {
let mut input = prompt.get_formatted_input_for_request(model_info);
if !self.reasoning_effort_override_enabled(model_info) {
@@ -963,6 +976,12 @@ impl ModelClient {
} else {
model_info.service_tier_for_request(service_tier)
};
if !include_internal {
for item in &mut input {
item.clear_tool_result_metadata();
}
}
let client_metadata = responses_metadata.client_metadata(include_internal);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions,
@@ -978,32 +997,12 @@ impl ModelClient {
service_tier,
prompt_cache_key,
text,
client_metadata: Some(responses_metadata.client_metadata()),
client_metadata: Some(client_metadata),
access_programs: None,
};
Ok(request)
}
fn filter_tool_result_metadata(input: &mut [ResponseItem], api_provider: &ApiProvider) {
// Check the resolved destination only when sending, not for local budget estimates.
// HTTP and WS (including v2 compaction) share this raw-metadata-only filter.
let result_metadata_allowed =
url::Url::parse(&api_provider.base_url)
.ok()
.is_some_and(|url| {
url.scheme() == "https"
&& url.host_str().is_some_and(|host| {
host == "api.openai.com"
|| codex_http_client::is_allowed_chatgpt_host(host)
})
});
if !result_metadata_allowed {
for item in input {
item.clear_tool_result_metadata();
}
}
}
fn prepare_response_items_for_request(&self, input: &mut [ResponseItem]) {
for item in input {
if item.id().is_some_and(|id| !id.is_prefixed()) {
@@ -1629,6 +1628,7 @@ impl ModelClientSession {
.client
.current_client_setup(ClientRouting::Workspace)
.await?;
let include_internal = is_internal_metadata_destination(&client_setup.api_provider);
let responses_headers = self
.client
.responses_headers(client_setup.auth.as_ref(), &model_info.slug);
@@ -1666,11 +1666,8 @@ impl ModelClientSession {
summary,
service_tier.clone(),
responses_metadata,
include_internal,
)?;
ModelClient::filter_tool_result_metadata(
&mut request.input,
&client_setup.api_provider,
);
self.client.set_guardian_metadata(
&mut request.client_metadata,
responses_metadata.parent_response_id.as_deref(),
@@ -1811,6 +1808,7 @@ impl ModelClientSession {
.client
.current_client_setup(ClientRouting::Workspace)
.await?;
let include_internal = is_internal_metadata_destination(&client_setup.api_provider);
let responses_headers = self
.client
.responses_headers(client_setup.auth.as_ref(), &model_info.slug);
@@ -1828,11 +1826,8 @@ impl ModelClientSession {
summary,
service_tier.clone(),
responses_metadata,
include_internal,
)?;
ModelClient::filter_tool_result_metadata(
&mut request.input,
&client_setup.api_provider,
);
let guardian_reviewer = responses_headers
.get("x-codex-guardian")
.is_some_and(|value| value == "reviewer");
@@ -1905,9 +1900,11 @@ impl ModelClientSession {
{
crate::guardian::observe_guardian_request(session_telemetry, &request);
}
let mut client_metadata = self
.client
.build_ws_client_metadata(responses_metadata, model_info.use_responses_lite);
let mut client_metadata = self.client.build_ws_client_metadata(
responses_metadata,
include_internal,
model_info.use_responses_lite,
);
if let Some(turn_state) = self.turn_state.get() {
client_metadata.insert(X_CODEX_TURN_STATE_HEADER.to_string(), turn_state.clone());
}