Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions codex-rs/codex-api/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pub struct CompactionInput<'a> {
pub input: &'a [ResponseItem],
#[serde(skip_serializing_if = "str::is_empty")]
pub instructions: &'a str,
pub tools: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
pub parallel_tool_calls: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Reasoning>,
Expand Down Expand Up @@ -192,7 +193,8 @@ pub struct ResponsesApiRequest {
#[serde(skip_serializing_if = "String::is_empty")]
pub instructions: String,
pub input: Vec<ResponseItem>,
pub tools: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<serde_json::Value>>,
pub tool_choice: String,
pub parallel_tool_calls: bool,
pub reasoning: Option<Reasoning>,
Expand Down Expand Up @@ -240,7 +242,8 @@ pub struct ResponseCreateWsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
pub input: Vec<ResponseItem>,
pub tools: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
Comment thread
rka-oai marked this conversation as resolved.
pub tool_choice: String,
pub parallel_tool_calls: bool,
pub reasoning: Option<Reasoning>,
Expand Down
4 changes: 2 additions & 2 deletions codex-rs/codex-api/src/endpoint/responses_websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -827,11 +827,11 @@ mod tests {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: vec![json!({
tools: Some(vec![json!({
"type": "function",
"name": "lookup",
"parameters": {"type": "object"}
})],
})]),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
Expand Down
6 changes: 3 additions & 3 deletions codex-rs/codex-api/tests/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: Vec::new(),
tools: Some(Vec::new()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
Expand Down Expand Up @@ -401,7 +401,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
model: "gpt-test".into(),
instructions: "Say hi".into(),
input: Vec::new(),
tools: Vec::new(),
tools: Some(Vec::new()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
Expand Down Expand Up @@ -520,7 +520,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> {
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
tools: Vec::new(),
tools: Some(Vec::new()),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/agent/control/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
_ => false,
},
RolloutItem::ResponseItem(
ResponseItem::AgentMessage { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
Expand Down
26 changes: 24 additions & 2 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ use codex_otel::current_span_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
Expand Down Expand Up @@ -778,14 +779,35 @@ impl ModelClient {
service_tier: Option<String>,
responses_metadata: &CodexResponsesMetadata,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
if !self.state.provider.info().is_openai() {
input
.iter_mut()
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
}
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let (instructions, tools) = if model_info.use_responses_lite {
let mut prefix = vec![ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
tools,
}];
if !prompt.base_instructions.text.is_empty() {
prefix.push(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: prompt.base_instructions.text.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
});
}
input.splice(0..0, prefix);
(String::new(), None)
} else {
(prompt.base_instructions.text.clone(), Some(tools))
};
let reasoning = Self::build_reasoning(model_info, effort, summary);
let include = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
Expand All @@ -812,7 +834,7 @@ impl ModelClient {
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
instructions,
input,
tools,
tool_choice: "auto".to_string(),
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/client_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ fn strip_image_details(items: &mut [ResponseItem]) {
}
}
}
ResponseItem::Reasoning { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
Expand Down
8 changes: 4 additions & 4 deletions codex-rs/core/src/client_common_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ fn serializes_text_verbosity_when_set() {
model: "gpt-5.4".to_string(),
instructions: "i".to_string(),
input,
tools,
tools: Some(tools),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
Expand Down Expand Up @@ -157,7 +157,7 @@ fn serializes_text_schema_with_strict_format() {
model: "gpt-5.4".to_string(),
instructions: "i".to_string(),
input,
tools,
tools: Some(tools),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
Expand Down Expand Up @@ -218,7 +218,7 @@ fn omits_text_when_not_set() {
model: "gpt-5.4".to_string(),
instructions: "i".to_string(),
input,
tools,
tools: Some(tools),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
Expand All @@ -241,7 +241,7 @@ fn serializes_flex_service_tier_when_set() {
model: "gpt-5.4".to_string(),
instructions: "i".to_string(),
input: vec![],
tools: vec![],
tools: Some(vec![]),
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/compact_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
ResponseItem::AgentMessage { .. } => true,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger { .. } => false,
ResponseItem::Reasoning { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/src/context/world_state/environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,6 @@ fn user_message(text: &str) -> ResponseItem {
text: text.to_string(),
}],
phase: None,
metadata: None,
internal_chat_message_metadata_passthrough: None,
}
}
9 changes: 6 additions & 3 deletions codex-rs/core/src/context_manager/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ impl ContextManager {
output: truncate_function_output_payload(output, policy_with_serialization_budget),
internal_chat_message_metadata_passthrough: metadata.clone(),
},
ResponseItem::Message { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::Message { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
Expand Down Expand Up @@ -476,7 +477,8 @@ pub(crate) fn truncate_function_output_payload(
fn is_api_message(message: &ResponseItem) -> bool {
match message {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::AgentMessage { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
Expand Down Expand Up @@ -722,7 +724,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger { .. } => false,
ResponseItem::FunctionCallOutput { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::AgentMessage { .. }
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/image_preparation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) {
prepare_tool_output_content(content);
}
}
ResponseItem::Reasoning { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2714,6 +2714,7 @@ impl Session {
continue;
}
let prefix = match item {
ResponseItem::AdditionalTools { .. } => "at",
ResponseItem::Message { .. } => "msg",
ResponseItem::Reasoning { .. } => "rs",
ResponseItem::LocalShellCall { .. } => "lsh",
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/core/src/session/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,19 @@ fn assign_missing_response_item_ids_skips_agent_messages() {
assert!(items[1].id().is_some_and(|id| id.starts_with("msg_")));
}

#[test]
fn assign_missing_response_item_ids_assigns_additional_tools_ids() {
let items = Cow::Owned(vec![ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
tools: Vec::new(),
}]);

let items = Session::assign_missing_response_item_ids(items);

assert!(items[0].id().is_some_and(|id| id.starts_with("at_")));
}

fn assistant_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/session/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2091,7 +2091,8 @@ async fn try_run_sampling_request(
}
ResponseItem::Reasoning { .. } => true,
ResponseItem::AgentMessage { .. } => false,
ResponseItem::LocalShellCall { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/turn_timing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::CompactionTrigger { .. } => false,
ResponseItem::FunctionCallOutput { .. }
ResponseItem::AdditionalTools { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::Other => false,
Expand Down
81 changes: 81 additions & 0 deletions codex-rs/core/tests/suite/agent_websocket.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use anyhow::Result;
use codex_features::Feature;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
Expand All @@ -10,12 +14,89 @@ use core_test_support::responses::start_websocket_server;
use core_test_support::responses::start_websocket_server_with_headers;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::time::Duration;

const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Result<()> {
skip_if_no_network!(Ok(()));

let server = start_websocket_server(vec![vec![
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
]])
.await;

let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
})
.with_model("gpt-5.3-codex");
let test = builder.build_with_websocket_server(&server).await?;

test.submit_turn("non-lite turn").await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "lite turn".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides {
model: Some("gpt-5.4".to_string()),
..Default::default()
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;

assert_eq!(server.handshakes().len(), 1);
let connection = server.single_connection();
assert_eq!(connection.len(), 3);
let non_lite_turn = connection
.get(1)
.expect("missing non-lite turn request")
.body_json();
let lite_turn = connection
.get(2)
.expect("missing lite turn request")
.body_json();

assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.3-codex"));
assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4"));
assert!(
non_lite_turn
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty())
);
assert_eq!(lite_turn.get("previous_response_id"), None);
assert_eq!(lite_turn.get("tools"), None);
assert_eq!(lite_turn.get("instructions"), None);
let additional_tools = lite_turn
.get("input")
.and_then(Value::as_array)
.and_then(|input| input.first())
.filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools"))
.and_then(|item| item.get("tools"))
.and_then(Value::as_array)
.expect("lite turn should start with an additional_tools item");
assert!(!additional_tools.is_empty());

server.shutdown().await;
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn websocket_test_codex_shell_chain() -> Result<()> {
skip_if_no_network!(Ok(()));
Expand Down
Loading
Loading