diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index aeea85e7fc..10eab17c69 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub parallel_tool_calls: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, @@ -192,7 +193,8 @@ pub struct ResponsesApiRequest { #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, @@ -240,7 +242,8 @@ pub struct ResponseCreateWsRequest { #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 488f6dc674..3874dad7d9 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -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, @@ -520,7 +520,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { phase: None, metadata: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3f746ef790..1329f768ad 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -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 { .. } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 73d7398a3d..4647668bf5 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -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; @@ -778,12 +779,31 @@ impl ModelClient { service_tier: Option, responses_metadata: &CodexResponsesMetadata, ) -> Result { - 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_metadata); } 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 { + 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, + }); + } + 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()] @@ -810,7 +830,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(), diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e0c2ff03c3..54ad70daaa 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -83,7 +83,8 @@ fn strip_image_details(items: &mut [ResponseItem]) { } } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8107049ab3..1f489f8e41 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -104,13 +104,11 @@ fn responses_lite_request_copies_strip_image_details() { #[test] fn serializes_text_verbosity_when_set() { - let input: Vec = vec![]; - let tools: Vec = vec![]; let req = ResponsesApiRequest { model: "gpt-5.4".to_string(), instructions: "i".to_string(), - input, - tools, + input: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -137,8 +135,6 @@ fn serializes_text_verbosity_when_set() { #[test] fn serializes_text_schema_with_strict_format() { - let input: Vec = vec![]; - let tools: Vec = vec![]; let schema = serde_json::json!({ "type": "object", "properties": { @@ -156,8 +152,8 @@ fn serializes_text_schema_with_strict_format() { let req = ResponsesApiRequest { model: "gpt-5.4".to_string(), instructions: "i".to_string(), - input, - tools, + input: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -212,13 +208,11 @@ fn serializes_text_schema_with_non_strict_format() { #[test] fn omits_text_when_not_set() { - let input: Vec = vec![]; - let tools: Vec = vec![]; let req = ResponsesApiRequest { model: "gpt-5.4".to_string(), instructions: "i".to_string(), - input, - tools, + input: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -241,7 +235,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, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 7a47881e47..99f1f175ee 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -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 { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ce1c6148f..b21122ffb5 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -362,7 +362,8 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), metadata: metadata.clone(), }, - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -452,7 +453,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 { .. } @@ -698,7 +700,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 { .. } diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index d30dfbb5ca..0210ff96ed 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -52,7 +52,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 { .. } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 041da729ea..d7e64dc5d2 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2072,7 +2072,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 { .. } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index e7a90800b0..04f7aeb69b 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -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, diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc73..d4ea6d6f4c 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -54,6 +54,70 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) } +fn additional_tools(body: &Value) -> Result<&[Value]> { + body["input"] + .as_array() + .context("Responses request input should be an array")? + .iter() + .find(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .context("Responses request should contain additional_tools")?["tools"] + .as_array() + .map(Vec::as_slice) + .context("additional_tools tools should be an array") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let body = response_mock.single_request().body_json(); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + + let input = body["input"] + .as_array() + .context("Responses request input should be an array")?; + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[0]["role"], "developer"); + assert_eq!( + input[1], + serde_json::json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "test instructions", + }], + }) + ); + + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> Result<()> { skip_if_no_network!(Ok(())); @@ -144,17 +208,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res request.header(RESPONSES_LITE_HEADER).as_deref(), Some("true") ); - request - .tool_by_name("web", "run") - .context("Responses Lite should expose standalone web search")?; - request - .tool_by_name("image_gen", "imagegen") - .context("Responses Lite should expose standalone image generation")?; - let body = request.body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); @@ -242,9 +299,8 @@ async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Re test.submit_turn("Do not use hosted tools").await?; let body = response_mock.single_request().body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3e2cfd1792..d4451a8926 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -256,7 +256,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::CustomToolCall { call_id, .. } => { custom_tool_call_ids.insert(call_id.as_str()); } - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -296,7 +297,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { image_urls.push(format!("data:image/png;base64,{result}")); } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index c8e35b9f7d..66fab81ef7 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1218,6 +1218,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { + ResponseItem::AdditionalTools { .. } => "additional_tools".into(), ResponseItem::Message { role, .. } => format!("message_from_{role}"), ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index b0d373cfb0..0196709ad4 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -917,6 +917,12 @@ pub struct ResponseItemMetadata { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { + #[schemars(skip)] + #[ts(skip)] + AdditionalTools { + role: String, + tools: Vec, + }, Message { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index b342d699b7..be72d622fc 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,7 +30,8 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { #[inline] pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -62,7 +63,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. }