Avoid cloning Responses WebSocket payloads (#34381)

## Why

Building a Responses WebSocket request copied the model, instructions, input
history, tools, and other request settings into a second payload before
serialization.

## What changed

- Make `ResponseCreateWsRequest` borrow shared fields from
  `ResponsesApiRequest`.
- Keep only WebSocket-specific fields owned, and allocate a separate input
  vector only for incremental continuations.
- Restore response item IDs after preparing a full request so the request
  retained for continuation matching keeps its original state.

## Testing

Update the serialization test to verify that the borrowed WebSocket request
produces the same wire payload as the Responses API request plus its
WebSocket-specific fields.

GitOrigin-RevId: b63d746b7f5164b5204ab95b057c914a4aeb71e1
This commit is contained in:
Charlie Marsh
2026-07-20 17:18:25 +00:00
committed by copyberry
parent 8431dc590a
commit 6b9a5592a6
3 changed files with 105 additions and 80 deletions
+28 -28
View File
@@ -238,24 +238,24 @@ pub struct ResponsesApiRequest {
pub client_metadata: Option<HashMap<String, String>>,
}
impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
fn from(request: &ResponsesApiRequest) -> Self {
impl<'a> From<&'a ResponsesApiRequest> for ResponseCreateWsRequest<'a> {
fn from(request: &'a ResponsesApiRequest) -> Self {
Self {
model: request.model.clone(),
instructions: request.instructions.clone(),
model: &request.model,
instructions: &request.instructions,
previous_response_id: None,
input: request.input.clone(),
tools: request.tools.clone(),
tool_choice: request.tool_choice.clone(),
input: &request.input,
tools: request.tools.as_deref(),
tool_choice: &request.tool_choice,
parallel_tool_calls: request.parallel_tool_calls,
reasoning: request.reasoning.clone(),
reasoning: request.reasoning.as_ref(),
store: request.store,
stream: request.stream,
stream_options: request.stream_options.clone(),
include: request.include.clone(),
service_tier: request.service_tier.clone(),
prompt_cache_key: request.prompt_cache_key.clone(),
text: request.text.clone(),
stream_options: request.stream_options.as_ref(),
include: &request.include,
service_tier: request.service_tier.as_deref(),
prompt_cache_key: request.prompt_cache_key.as_deref(),
text: request.text.as_ref(),
generate: None,
client_metadata: request.client_metadata.clone(),
}
@@ -263,29 +263,29 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
}
#[derive(Debug, Serialize)]
pub struct ResponseCreateWsRequest {
pub model: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub instructions: String,
pub struct ResponseCreateWsRequest<'a> {
pub model: &'a str,
#[serde(skip_serializing_if = "str::is_empty")]
pub instructions: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
pub input: Vec<ResponseItem>,
pub input: &'a [ResponseItem],
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
pub tool_choice: String,
pub tools: Option<&'a [Value]>,
pub tool_choice: &'a str,
pub parallel_tool_calls: bool,
pub reasoning: Option<Reasoning>,
pub reasoning: Option<&'a Reasoning>,
pub store: bool,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
pub include: Vec<String>,
pub stream_options: Option<&'a StreamOptions>,
pub include: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
pub service_tier: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
pub prompt_cache_key: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<TextControls>,
pub text: Option<&'a TextControls>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -317,9 +317,9 @@ pub fn response_create_client_metadata(
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum ResponsesWsRequest {
pub enum ResponsesWsRequest<'a> {
#[serde(rename = "response.create")]
ResponseCreate(ResponseCreateWsRequest),
ResponseCreate(ResponseCreateWsRequest<'a>),
}
pub fn create_text_param_for_request(
@@ -230,7 +230,7 @@ impl ResponsesWebsocketConnection {
)]
pub async fn stream_request(
&self,
request: ResponsesWsRequest,
request: ResponsesWsRequest<'_>,
connection_reused: bool,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponseStream, ApiError> {
@@ -245,7 +245,7 @@ impl ResponsesWebsocketConnection {
let ResponsesWsRequest::ResponseCreate(ws_request) = &request;
let client_metadata = ws_request.client_metadata.as_ref();
let timing_log_context = ResponsesWebsocketTimingLogContext {
model: ws_request.model.clone(),
model: ws_request.model.to_string(),
session_id: client_metadata
.and_then(|metadata| metadata.get(SESSION_ID_CLIENT_METADATA_KEY))
.cloned(),
@@ -883,7 +883,7 @@ async fn send_websocket_request(
Ok(())
}
fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result<String, ApiError> {
fn serialize_websocket_request(request: &ResponsesWsRequest<'_>) -> Result<String, ApiError> {
serde_json::to_string(request)
.map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}")))
}
@@ -892,6 +892,7 @@ fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result<String, A
mod tests {
use super::*;
use crate::common::ResponseCreateWsRequest;
use crate::common::ResponsesApiRequest;
use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -901,10 +902,9 @@ mod tests {
#[test]
fn direct_serialization_preserves_websocket_request_payload() {
let request = ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
let api_request = ResponsesApiRequest {
model: "gpt-test".to_string(),
instructions: "Use the available tools.".to_string(),
previous_response_id: Some("resp-1".to_string()),
input: vec![ResponseItem::Message {
id: Some(ResponseItemId::with_suffix("msg", "1")),
role: "user".to_string(),
@@ -929,20 +929,28 @@ mod tests {
service_tier: Some("priority".to_string()),
prompt_cache_key: Some("cache-key".to_string()),
text: None,
generate: Some(false),
client_metadata: Some(HashMap::from([(
"traceparent".to_string(),
"00-0123456789abcdef0123456789abcdef-0123456789abcdef-01".to_string(),
)])),
};
let request = ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some("resp-1".to_string()),
generate: Some(false),
..ResponseCreateWsRequest::from(&api_request)
});
let previous_payload = serde_json::to_value(&request).expect("serialize previous payload");
let mut expected_payload =
serde_json::to_value(&api_request).expect("serialize responses API request");
expected_payload["type"] = json!("response.create");
expected_payload["previous_response_id"] = json!("resp-1");
expected_payload["generate"] = json!(false);
let request_text =
serialize_websocket_request(&request).expect("serialize websocket request");
let wire_payload =
serde_json::from_str::<Value>(&request_text).expect("parse websocket request");
assert_eq!(wire_payload, previous_payload);
assert_eq!(wire_payload, expected_payload);
}
#[test]