mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Give Responses Lite prefix items stable IDs (#40962)
## Why Responses Lite rebuilds its tools and base-instruction prefix for each request. Stable item identities let follow-up WebSocket requests recognize that unchanged prefix and send only incremental input. ## What changed - Derive deterministic IDs for additional-tools and base-instruction items from the thread ID and each item's serialized payload. - Preserve IDs when rebuilding an unchanged request or resuming a session, while changing them when the thread or payload changes. ## Testing - Cover ID stability and payload/thread sensitivity in request construction. - Verify resumed Responses Lite sessions reuse prefix IDs and WebSocket follow-ups use `previous_response_id` with only new input items. GitOrigin-RevId: 3c8548b96979d7578865b0ccc51c147926823478
This commit is contained in:
@@ -74,6 +74,7 @@ use codex_login::default_client::add_originator_header;
|
||||
use codex_login::default_client::create_client_for_route;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_protocol::ResponseItemId;
|
||||
use codex_protocol::auth::AuthMode;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -108,6 +109,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::instrument;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::attestation::AttestationContext;
|
||||
use crate::attestation::AttestationProvider;
|
||||
@@ -898,20 +900,34 @@ impl ModelClient {
|
||||
let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
|
||||
let is_openai = self.state.provider.info().is_openai();
|
||||
let (instructions, tools) = if model_info.use_responses_lite {
|
||||
// These prompt-only items are rebuilt on every request. Hash their visible payloads
|
||||
// within the thread so retries and resumed sessions preserve their identity.
|
||||
let prefix_namespace = Uuid::new_v5(
|
||||
&Uuid::NAMESPACE_OID,
|
||||
self.state.thread_id.to_string().as_bytes(),
|
||||
);
|
||||
let tools = if self.state.provider.capabilities().namespace_tools {
|
||||
create_tools_json_for_responses_lite(&prompt.tools)?
|
||||
} else {
|
||||
create_tools_json_for_responses_api(&prompt.tools)?
|
||||
};
|
||||
let mut prefix = vec![ResponseItem::AdditionalTools {
|
||||
id: None,
|
||||
id: Some(ResponseItemId::with_suffix(
|
||||
"at",
|
||||
Uuid::new_v5(&prefix_namespace, &serde_json::to_vec(&tools)?),
|
||||
)),
|
||||
role: "developer".to_string(),
|
||||
tools,
|
||||
}];
|
||||
if !prompt.base_instructions.text.is_empty() {
|
||||
prefix.push(ContextualUserFragment::into(BaseInstructionsFragment(
|
||||
let mut instructions = ContextualUserFragment::into(BaseInstructionsFragment(
|
||||
prompt.base_instructions.text.clone(),
|
||||
));
|
||||
instructions.set_id(Some(ResponseItemId::with_suffix(
|
||||
"msg",
|
||||
Uuid::new_v5(&prefix_namespace, prompt.base_instructions.text.as_bytes()),
|
||||
)));
|
||||
prefix.push(instructions);
|
||||
}
|
||||
input.splice(0..0, prefix);
|
||||
(String::new(), None)
|
||||
|
||||
@@ -284,6 +284,71 @@ fn test_model_info() -> ModelInfo {
|
||||
.expect("deserialize test model info")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_lite_prefix_ids_track_thread_and_payload() -> anyhow::Result<()> {
|
||||
let thread_id = ThreadId::new();
|
||||
let client = test_model_client_with_thread_id(thread_id, SessionSource::Cli);
|
||||
let mut model = test_model_info();
|
||||
model.use_responses_lite = true;
|
||||
let mut prompt = Prompt {
|
||||
base_instructions: BaseInstructions {
|
||||
text: "base instructions".to_string(),
|
||||
provenance: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let build = |client: &ModelClient, prompt: &Prompt| {
|
||||
client.build_responses_request(
|
||||
prompt,
|
||||
&model,
|
||||
/*effort*/ None,
|
||||
codex_protocol::config_types::ReasoningSummary::None,
|
||||
/*service_tier*/ None,
|
||||
&test_responses_metadata_for_client(
|
||||
client,
|
||||
/*turn_id*/ None,
|
||||
format!("{}:0", client.state.thread_id),
|
||||
/*parent_thread_id*/ None,
|
||||
TestCodexResponsesRequestKind::Turn,
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let original = build(&client, &prompt)?;
|
||||
assert_eq!(build(&client, &prompt)?, original);
|
||||
|
||||
prompt.base_instructions.text.push_str(" with an update");
|
||||
let changed_instructions = build(&client, &prompt)?;
|
||||
assert_eq!(changed_instructions.input[0], original.input[0]);
|
||||
assert_ne!(changed_instructions.input[1].id(), original.input[1].id());
|
||||
|
||||
prompt.tools = vec![codex_tools::ToolSpec::Freeform(codex_tools::FreeformTool {
|
||||
name: "exec".to_string(),
|
||||
description: "Execute JavaScript.".to_string(),
|
||||
defer_loading: None,
|
||||
format: codex_tools::FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: "start: /.+/".to_string(),
|
||||
},
|
||||
})]
|
||||
.into();
|
||||
let changed_tools = build(&client, &prompt)?;
|
||||
assert_ne!(
|
||||
changed_tools.input[0].id(),
|
||||
changed_instructions.input[0].id()
|
||||
);
|
||||
assert_eq!(changed_tools.input[1], changed_instructions.input[1]);
|
||||
|
||||
let independent = build(
|
||||
&test_model_client_with_thread_id(ThreadId::new(), SessionSource::Cli),
|
||||
&prompt,
|
||||
)?;
|
||||
assert_ne!(independent.input[0].id(), changed_tools.input[0].id());
|
||||
assert_ne!(independent.input[1].id(), changed_tools.input[1].id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_session_telemetry() -> SessionTelemetry {
|
||||
SessionTelemetry::new(
|
||||
ThreadId::new(),
|
||||
|
||||
@@ -1834,6 +1834,65 @@ async fn responses_websocket_uses_incremental_create_on_prefix() {
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_lite_websocket_uses_incremental_create_on_prefix() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_websocket_server(vec![vec![
|
||||
vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg_1", "assistant output"),
|
||||
ev_completed("resp-1"),
|
||||
],
|
||||
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let mut harness = websocket_harness(&server).await;
|
||||
harness.model_info.use_responses_lite = true;
|
||||
let mut client_session = harness.client.new_session();
|
||||
let mut initial_item = message_item("hello");
|
||||
initial_item.set_id(Some(ResponseItemId::with_suffix("msg", "supplied")));
|
||||
let prompt_one = prompt_with_input_and_instructions(vec![initial_item.clone()], "base");
|
||||
let prompt_two = prompt_with_input_and_instructions(
|
||||
vec![
|
||||
initial_item,
|
||||
assistant_message_item("1", "assistant output"),
|
||||
message_item("second"),
|
||||
],
|
||||
"base",
|
||||
);
|
||||
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
||||
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
||||
|
||||
let connection = server.single_connection();
|
||||
assert_eq!(connection.len(), 2);
|
||||
let first = connection.first().expect("missing request").body_json();
|
||||
let second = connection.get(1).expect("missing request").body_json();
|
||||
let first_input = first["input"].as_array().expect("request input");
|
||||
|
||||
assert_eq!(first_input.len(), 3);
|
||||
assert!(
|
||||
first_input[0]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("at_"))
|
||||
);
|
||||
assert!(
|
||||
first_input[1]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("msg_"))
|
||||
);
|
||||
assert_eq!(first_input[2]["id"], "msg_supplied");
|
||||
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
||||
assert_eq!(
|
||||
second["input"],
|
||||
serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental items")
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() {
|
||||
skip_if_no_network!();
|
||||
|
||||
@@ -102,16 +102,18 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_model_info_override("gpt-5.4", |model_info| {
|
||||
model_info.use_responses_lite = true;
|
||||
model_info.tool_mode = Some(ToolMode::CodeMode);
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.base_instructions = Some("test instructions".to_string());
|
||||
config.code_mode.disable_in_process_fallback = true;
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let builder = || {
|
||||
test_codex()
|
||||
.with_model_info_override("gpt-5.4", |model_info| {
|
||||
model_info.use_responses_lite = true;
|
||||
model_info.tool_mode = Some(ToolMode::CodeMode);
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.base_instructions = Some("test instructions".to_string());
|
||||
config.code_mode.disable_in_process_fallback = true;
|
||||
})
|
||||
};
|
||||
let test = builder().build(&server).await?;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
@@ -124,9 +126,20 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
|
||||
.context("Responses request input should be an array")?;
|
||||
assert_eq!(input[0]["type"], "additional_tools");
|
||||
assert_eq!(input[0]["role"], "developer");
|
||||
assert!(
|
||||
input[0]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("at_"))
|
||||
);
|
||||
assert!(
|
||||
input[1]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("msg_"))
|
||||
);
|
||||
assert_eq!(
|
||||
input[1],
|
||||
serde_json::json!({
|
||||
"id": input[1]["id"],
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{
|
||||
@@ -164,6 +177,15 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
|
||||
|
||||
assert!(turn_metadata.get("tool_namespaces_info").is_none());
|
||||
|
||||
let followup = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![responses::ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
let resumed = builder().restart(&server, &test).await?;
|
||||
resumed.submit_turn("continue").await?;
|
||||
assert_eq!(&followup.single_request().input()[..2], &input[..2]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user