Reduce cloning when building Responses requests (#34825)

## Why

Preparing Responses API requests rebuilt tool definitions as a generic JSON
tree, and incremental WebSocket requests cloned their full item prefix for
comparison.

## What changed

- Serialize tool definitions into shared raw JSON that can be embedded directly
  in HTTP and WebSocket requests.
- Compare incremental request prefixes in place while still ignoring internal
  message metadata.

## Testing

- Verify raw tool JSON matches the existing value encoding.
- Preserve the serialized WebSocket request payload.

GitOrigin-RevId: 66e2921792c332e8904954dafa597f6d39abcf29
This commit is contained in:
jif
2026-07-22 20:47:57 +00:00
committed by copyberry
parent bbfc3f0152
commit 12c115d558
12 changed files with 145 additions and 42 deletions

View File

@@ -19,7 +19,7 @@ http = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true }

View File

@@ -12,8 +12,10 @@ use futures::Stream;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use serde_json::value::RawValue;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context;
use std::task::Poll;
use tokio::sync::mpsc;
@@ -29,7 +31,7 @@ pub struct CompactionInput<'a> {
#[serde(skip_serializing_if = "str::is_empty")]
pub instructions: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
pub tools: Option<ResponsesApiTools>,
pub parallel_tool_calls: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
@@ -212,6 +214,40 @@ impl From<VerbosityConfig> for OpenAiVerbosity {
}
}
/// Serialized tool definitions for Responses API requests.
///
/// Keeping the tool list as raw JSON avoids rebuilding a generic JSON value
/// tree, while the shared allocation keeps request clones cheap.
#[derive(Debug, Clone)]
pub struct ResponsesApiTools(Arc<RawValue>);
impl ResponsesApiTools {
pub(crate) fn as_raw_value(&self) -> &RawValue {
&self.0
}
}
impl From<Arc<RawValue>> for ResponsesApiTools {
fn from(value: Arc<RawValue>) -> Self {
Self(value)
}
}
impl PartialEq for ResponsesApiTools {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0) || self.0.get() == other.0.get()
}
}
impl Serialize for ResponsesApiTools {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct ResponsesApiRequest {
pub model: String,
@@ -219,7 +255,7 @@ pub struct ResponsesApiRequest {
pub instructions: String,
pub input: Vec<ResponseItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<serde_json::Value>>,
pub tools: Option<ResponsesApiTools>,
pub tool_choice: String,
pub parallel_tool_calls: bool,
pub reasoning: Option<Reasoning>,
@@ -245,7 +281,7 @@ impl<'a> From<&'a ResponsesApiRequest> for ResponseCreateWsRequest<'a> {
instructions: &request.instructions,
previous_response_id: None,
input: &request.input,
tools: request.tools.as_deref(),
tools: request.tools.as_ref().map(ResponsesApiTools::as_raw_value),
tool_choice: &request.tool_choice,
parallel_tool_calls: request.parallel_tool_calls,
reasoning: request.reasoning.as_ref(),
@@ -271,7 +307,7 @@ pub struct ResponseCreateWsRequest<'a> {
pub previous_response_id: Option<String>,
pub input: &'a [ResponseItem],
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<&'a [Value]>,
pub tools: Option<&'a RawValue>,
pub tool_choice: &'a str,
pub parallel_tool_calls: bool,
pub reasoning: Option<&'a Reasoning>,

View File

@@ -907,7 +907,10 @@ mod tests {
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use serde_json::json;
use serde_json::value::RawValue;
use serde_json::value::to_raw_value;
use std::collections::HashMap;
use std::sync::Arc;
#[test]
fn direct_serialization_preserves_websocket_request_payload() {
@@ -923,11 +926,17 @@ mod tests {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: Some(vec![json!({
"type": "function",
"name": "lookup",
"parameters": {"type": "object"}
})]),
tools: Some(
Arc::<RawValue>::from(
to_raw_value(&vec![json!({
"type": "function",
"name": "lookup",
"parameters": {"type": "object"}
})])
.expect("serialize tools"),
)
.into(),
),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,

View File

@@ -39,6 +39,7 @@ pub use crate::common::ResponseCreateWsRequest;
pub use crate::common::ResponseEvent;
pub use crate::common::ResponseStream;
pub use crate::common::ResponsesApiRequest;
pub use crate::common::ResponsesApiTools;
pub use crate::common::ResponsesWsRequest;
pub use crate::common::StreamOptions;
pub use crate::common::TextControls;

View File

@@ -28,6 +28,7 @@ use http::HeaderMap;
use http::HeaderValue;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::value::RawValue;
fn assert_path_ends_with(requests: &[Request], suffix: &str) {
assert_eq!(requests.len(), 1);
@@ -38,6 +39,10 @@ fn assert_path_ends_with(requests: &[Request], suffix: &str) {
);
}
fn empty_tools() -> Arc<RawValue> {
Arc::from(RawValue::from_string("[]".to_string()).expect("valid tool JSON"))
}
fn request_body_bytes(request: &Request) -> &[u8] {
let Some(RequestBody::EncodedJson(body)) = request.body.as_ref() else {
panic!("expected a prepared request body");
@@ -316,7 +321,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: Some(Vec::new()),
tools: Some(empty_tools().into()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
@@ -403,7 +408,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
model: "gpt-test".into(),
instructions: "Say hi".into(),
input: Vec::new(),
tools: Some(Vec::new()),
tools: Some(empty_tools().into()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
@@ -523,7 +528,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: Some(Vec::new()),
tools: Some(empty_tools().into()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,