diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d5a3f2089c..faa5b420fb 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -698,6 +698,7 @@ impl TurnRequestProcessor { root_turn_id: params.root_turn_id, final_output_json_schema: params.output_schema, service_tier: params.service_tier_for_turn, + prompt_cache_ttl: None, cyber_access_program: params.cyber_access_program.map(Into::into), }) .with_additional_context(additional_context) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 2510f35d9b..455836c2b8 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::turn_input::CyberAccessProgram; +use codex_protocol::turn_input::PromptCacheTtl; use futures::Stream; use serde::Deserialize; use serde::Serialize; @@ -277,6 +278,11 @@ impl Serialize for ResponsesApiTools { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct PromptCacheOptions { + pub ttl: PromptCacheTtl, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct ResponsesApiRequest { // Keep routing fields first: serde serializes struct fields in declaration order, and @@ -298,6 +304,8 @@ pub struct ResponsesApiRequest { #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub client_metadata: Option>, @@ -321,6 +329,7 @@ impl<'a> From<&'a ResponsesApiRequest> for ResponseCreateWsRequest<'a> { include: &request.include, service_tier: request.service_tier.as_deref(), prompt_cache_key: request.prompt_cache_key.as_deref(), + prompt_cache_options: request.prompt_cache_options, text: request.text.as_ref(), generate: None, client_metadata: request.client_metadata.clone(), @@ -352,6 +361,8 @@ pub struct ResponseCreateWsRequest<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option<&'a TextControls>, #[serde(skip_serializing_if = "Option::is_none")] pub generate: Option, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index ce32a374c1..412fb1f0ab 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1117,6 +1117,9 @@ mod tests { include: vec!["reasoning.encrypted_content".to_string()], service_tier: Some("priority".to_string()), prompt_cache_key: Some("cache-key".to_string()), + prompt_cache_options: Some(crate::PromptCacheOptions { + ttl: codex_protocol::turn_input::PromptCacheTtl::TwelveHours, + }), text: None, access_programs: Some( codex_protocol::turn_input::CyberAccessProgram::DaybreakBlue.into(), diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 46a68f268b..68b0c1c4ae 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -37,6 +37,7 @@ pub use crate::common::AccessPrograms; pub use crate::common::MemorySummarizeInput; pub use crate::common::MemorySummarizeOutput; pub use crate::common::OpenAiVerbosity; +pub use crate::common::PromptCacheOptions; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 664697da32..fc96b300c5 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -355,6 +355,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { include: Vec::new(), service_tier: None, prompt_cache_key: None, + prompt_cache_options: None, text: None, client_metadata: None, access_programs: None, @@ -411,6 +412,7 @@ async fn responses_client_stream_request_sends_routing_fields_ahead_of_large_inp include: Vec::new(), service_tier: Some("priority".into()), prompt_cache_key: None, + prompt_cache_options: None, text: None, client_metadata: None, access_programs: None, @@ -501,6 +503,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { include: Vec::new(), service_tier: None, prompt_cache_key: None, + prompt_cache_options: None, text: None, client_metadata: None, access_programs: None, @@ -626,6 +629,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { include: Vec::new(), service_tier: None, prompt_cache_key: None, + prompt_cache_options: None, text: None, client_metadata: None, access_programs: None, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 6a10fec243..45613aef42 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -50,6 +50,7 @@ pub use codex_core::NetworkUnixSocketPermissions; pub use codex_core::NewThread; pub use codex_core::NotSubmittedReason; pub use codex_core::PromptCacheKey; +pub use codex_core::PromptCacheTtl; pub use codex_core::RecoverTurnRequest; pub use codex_core::StartIfIdleSubmission; pub use codex_core::StartThreadOptions; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index dd349ef5cf..9d13e2eb8d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -441,6 +441,7 @@ fn responses_request_properties_mismatch( include: previous_include, service_tier: previous_service_tier, prompt_cache_key: previous_prompt_cache_key, + prompt_cache_options: previous_prompt_cache_options, text: previous_text, client_metadata: _, access_programs: _, @@ -458,6 +459,7 @@ fn responses_request_properties_mismatch( include: current_include, service_tier: current_service_tier, prompt_cache_key: current_prompt_cache_key, + prompt_cache_options: current_prompt_cache_options, text: current_text, client_metadata: _, access_programs: _, @@ -485,6 +487,8 @@ fn responses_request_properties_mismatch( "service_tier_changed" } else if previous_prompt_cache_key != current_prompt_cache_key { "prompt_cache_key_changed" + } else if previous_prompt_cache_options != current_prompt_cache_options { + "prompt_cache_options_changed" } else if previous_text != current_text { "text_changed" } else { @@ -1151,6 +1155,9 @@ impl ModelClient { include, service_tier, prompt_cache_key: self.prompt_cache_key(responses_metadata), + prompt_cache_options: prompt + .prompt_cache_ttl + .map(|ttl| codex_api::PromptCacheOptions { ttl }), text, client_metadata: Some(client_metadata), access_programs: None, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 81bf9422ea..df995f71a9 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -38,6 +38,8 @@ pub struct Prompt { /// Whether the Responses API should strictly validate `output_schema`. pub output_schema_strict: bool, + pub(crate) prompt_cache_ttl: Option, + pub(crate) cyber_access_program: Option, } @@ -50,6 +52,7 @@ impl Default for Prompt { base_instructions: BaseInstructions::default(), output_schema: None, output_schema_strict: true, + prompt_cache_ttl: None, cyber_access_program: None, } } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index e4f431ad45..b9c9485d90 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -124,6 +124,7 @@ fn serializes_text_verbosity_when_set() { stream_options: None, include: vec![], prompt_cache_key: None, + prompt_cache_options: None, service_tier: None, text: Some(TextControls { verbosity: Some(OpenAiVerbosity::Low), @@ -171,6 +172,7 @@ fn serializes_text_schema_with_strict_format() { stream_options: None, include: vec![], prompt_cache_key: None, + prompt_cache_options: None, service_tier: None, text: Some(text_controls), client_metadata: None, @@ -232,6 +234,7 @@ fn omits_text_when_not_set() { stream_options: None, include: vec![], prompt_cache_key: None, + prompt_cache_options: None, service_tier: None, text: None, client_metadata: None, @@ -256,6 +259,7 @@ fn serializes_flex_service_tier_when_set() { stream_options: None, include: vec![], prompt_cache_key: None, + prompt_cache_options: None, service_tier: Some(ServiceTier::Flex.to_string()), text: None, client_metadata: None, diff --git a/codex-rs/core/src/client_tool_metadata_tests.rs b/codex-rs/core/src/client_tool_metadata_tests.rs index 0839e5c5e5..47318a3937 100644 --- a/codex-rs/core/src/client_tool_metadata_tests.rs +++ b/codex-rs/core/src/client_tool_metadata_tests.rs @@ -55,6 +55,7 @@ fn request_with_metadata_and_source( include: Vec::new(), service_tier: None, prompt_cache_key: None, + prompt_cache_options: None, text: None, client_metadata: Some(HashMap::from([("padding".to_string(), "x".to_string())])), access_programs: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9a89ccc68a..96e041cb10 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -609,9 +609,11 @@ impl CodexThread { turn_id, thread_settings, trace, + prompt_cache_ttl, cyber_access_program, } = request; let start_options = TurnStartOptions { + prompt_cache_ttl, cyber_access_program, ..self.session.recovered_turn_start_options(&turn_id).await }; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 91e7571270..095b9b68e4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -323,6 +323,7 @@ async fn run_compact_task_inner_impl( let prompt = Prompt { input: turn_input, base_instructions, + prompt_cache_ttl: turn_context.prompt_cache_ttl, cyber_access_program: turn_context.cyber_access_program, ..Default::default() }; diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index e10fb74092..23c6dda1cc 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -191,6 +191,7 @@ pub(super) async fn run_remote_compact_v2_attempt( base_instructions, output_schema: None, output_schema_strict: true, + prompt_cache_ttl: turn_context.prompt_cache_ttl, cyber_access_program: turn_context.cyber_access_program, }; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ea0c5c00bb..4393f14db0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ mod responses_metadata; mod responses_retry; pub(crate) mod session; pub use codex_protocol::turn_input::NotSubmittedReason; +pub use codex_protocol::turn_input::PromptCacheTtl; pub use codex_protocol::turn_input::RecoverTurnRequest; pub use codex_protocol::turn_input::StartIfIdleSubmission; pub use codex_protocol::turn_input::SteerSubmission; diff --git a/codex-rs/core/src/session/daemon_recovery.rs b/codex-rs/core/src/session/daemon_recovery.rs index bbf27dd367..22dfb3beb2 100644 --- a/codex-rs/core/src/session/daemon_recovery.rs +++ b/codex-rs/core/src/session/daemon_recovery.rs @@ -47,6 +47,7 @@ impl Session { service_tier: Some(settings.service_tier.clone().unwrap_or_else(|| { codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string() })), + prompt_cache_ttl: context.prompt_cache_ttl, cyber_access_program: context.cyber_access_program, ..Default::default() }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index c7f1b8a6fc..30a294cc1a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1820,6 +1820,7 @@ pub(crate) fn build_prompt( output_schema_strict: !crate::guardian::is_basic_session_source( &turn_context.session_source, ), + prompt_cache_ttl: turn_context.prompt_cache_ttl, cyber_access_program: turn_context.cyber_access_program, } } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c6d22d105b..065e90b220 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -336,6 +336,7 @@ impl std::fmt::Debug for TurnEnvironment { #[derive(Default)] pub(crate) struct NewTurnContextOptions { pub(crate) final_output_json_schema: Option, + pub(crate) prompt_cache_ttl: Option, pub(crate) cyber_access_program: Option, } @@ -390,6 +391,7 @@ pub struct TurnContext { pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) available_models: Vec, pub(crate) final_output_json_schema: Option, + pub(crate) prompt_cache_ttl: Option, pub(crate) turn_metadata_state: Arc, pub(crate) extension_data: Arc, pub(crate) turn_timing_state: Arc, @@ -477,6 +479,7 @@ impl TurnContext { #[allow(deprecated)] cwd: self.cwd.clone(), final_output_json_schema: None, + prompt_cache_ttl: None, turn_metadata_state, extension_data, turn_timing_state: Arc::new(TurnTimingState::default()), @@ -917,6 +920,7 @@ impl TurnContext { windows_sandbox_level: self.windows_sandbox_level, available_models, final_output_json_schema: self.final_output_json_schema.clone(), + prompt_cache_ttl: self.prompt_cache_ttl, turn_metadata_state: self.turn_metadata_state.clone(), extension_data: Arc::clone(&self.extension_data), turn_timing_state: Arc::clone(&self.turn_timing_state), @@ -1202,6 +1206,7 @@ impl Session { windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, final_output_json_schema: None, + prompt_cache_ttl: None, turn_metadata_state, extension_data, turn_timing_state: Arc::new(TurnTimingState::default()), @@ -1486,6 +1491,7 @@ impl Session { turn_context.realtime_active = self.conversation.running_state().await.is_some(); turn_context.final_output_json_schema = options.final_output_json_schema; + turn_context.prompt_cache_ttl = options.prompt_cache_ttl; turn_context.cyber_access_program = cyber_access_program::for_provider(&turn_context.config, options.cyber_access_program); // Tool observers retain this turn's selection even after a later turn starts. diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index 831e719ee5..eb6f48fc02 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -152,6 +152,7 @@ impl PreparedTurnInputSettings { turn_trigger, final_output_json_schema, service_tier, + prompt_cache_ttl, parent_turn_id, initiating_agent_path, root_turn_id, @@ -168,6 +169,7 @@ impl PreparedTurnInputSettings { let options = NewTurnContextOptions { final_output_json_schema, + prompt_cache_ttl, cyber_access_program, }; let turn_context = match kind { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ea5b243107..36e35680a5 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -557,6 +557,7 @@ impl Session { } let options = NewTurnContextOptions { final_output_json_schema: start_options.final_output_json_schema, + prompt_cache_ttl: start_options.prompt_cache_ttl, cyber_access_program: start_options.cyber_access_program, }; let turn_context = match session_configuration { diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index ae4a17ad0f..0e039ee77d 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -244,6 +244,7 @@ async fn root_turn_suspension_preserves_unfinished_turn_history(fail_child_write turn_id: turn_id.clone(), thread_settings: Default::default(), trace: None, + prompt_cache_ttl: None, cyber_access_program: None, }) .await diff --git a/codex-rs/core/tests/suite/cyber_access_program.rs b/codex-rs/core/tests/suite/cyber_access_program.rs index 0529a776b4..dfe4a24bbc 100644 --- a/codex-rs/core/tests/suite/cyber_access_program.rs +++ b/codex-rs/core/tests/suite/cyber_access_program.rs @@ -99,6 +99,7 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - turn_id: turn_id.clone(), thread_settings: Default::default(), trace: None, + prompt_cache_ttl: None, cyber_access_program: persisted_context.cyber_access_program, }) .await?; diff --git a/codex-rs/core/tests/suite/reasoning_effort_override.rs b/codex-rs/core/tests/suite/reasoning_effort_override.rs index be4da0d8eb..305f8f4140 100644 --- a/codex-rs/core/tests/suite/reasoning_effort_override.rs +++ b/codex-rs/core/tests/suite/reasoning_effort_override.rs @@ -518,6 +518,7 @@ async fn reasoning_effort_override_recovery_sends_latest_tail_update( ..Default::default() }, trace: None, + prompt_cache_ttl: None, cyber_access_program: None, }) .await?, diff --git a/codex-rs/core/tests/suite/turn_input_submission.rs b/codex-rs/core/tests/suite/turn_input_submission.rs index e23d3b1660..1c505abfd7 100644 --- a/codex-rs/core/tests/suite/turn_input_submission.rs +++ b/codex-rs/core/tests/suite/turn_input_submission.rs @@ -1,4 +1,5 @@ use codex_core::NotSubmittedReason; +use codex_core::PromptCacheTtl; use codex_core::RecoverTurnRequest; use codex_core::StartIfIdleSubmission; use codex_core::StartThreadOptions; @@ -62,6 +63,39 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::timeout; +#[tokio::test] +async fn host_prompt_cache_ttl_applies_only_to_the_started_turn() -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let test = test_codex().build_with_auto_env(&server).await?; + for (prompt_cache_ttl, expected) in [ + (Some(PromptCacheTtl::TwelveHours), Some("12h")), + (None, None), + (Some(PromptCacheTtl::ThirtyMinutes), Some("30m")), + ] { + let response = responses::mount_sse_once(&server, responses::sse_completed("cache")).await; + test.codex + .start_or_steer_turn(user_message_request("hello").on_start(TurnStartOptions { + prompt_cache_ttl, + ..Default::default() + })) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert_eq!( + response + .single_request() + .body_json() + .get("prompt_cache_options"), + expected + .map(|ttl| serde_json::json!({ "ttl": ttl })) + .as_ref(), + ); + } + Ok(()) +} + /// Accepted steering changes future captures, while rejected input and automatic starts /// preserve the policy belonging to each turn. #[tokio::test] @@ -666,6 +700,7 @@ async fn recover_turn_if_idle_preserves_id_and_resumes_plan_mode() { ..Default::default() }, trace: None, + prompt_cache_ttl: None, cyber_access_program: None, }) .await @@ -923,6 +958,7 @@ async fn recovery_preserves_durable_turn_attribution( ..Default::default() }, trace: None, + prompt_cache_ttl: None, cyber_access_program: None, }) .await?, diff --git a/codex-rs/core/tests/suite/turn_phase_trace.rs b/codex-rs/core/tests/suite/turn_phase_trace.rs index 0fd928d268..4c8a378976 100644 --- a/codex-rs/core/tests/suite/turn_phase_trace.rs +++ b/codex-rs/core/tests/suite/turn_phase_trace.rs @@ -250,6 +250,7 @@ async fn steer_joins_existing_turn_without_extending_sampling( turn_id: call.turn_id.clone(), thread_settings: Default::default(), trace: codex_otel::span_w3c_trace_context(&recovery), + prompt_cache_ttl: None, cyber_access_program: None, }) .await?, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs index a2fa24e1c5..505315f22f 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs @@ -245,6 +245,7 @@ impl LunaSampler { include: Vec::new(), service_tier: None, prompt_cache_key: Some(format!("guardian-v2:{}", self.config.thread_id)), + prompt_cache_options: None, text: None, client_metadata: None, access_programs: None, diff --git a/codex-rs/protocol/src/turn_input.rs b/codex-rs/protocol/src/turn_input.rs index dda1331d75..ec911d50bf 100644 --- a/codex-rs/protocol/src/turn_input.rs +++ b/codex-rs/protocol/src/turn_input.rs @@ -113,6 +113,8 @@ pub struct RecoverTurnRequest { pub turn_id: String, pub thread_settings: ThreadSettingsOverrides, pub trace: Option, + /// Cache lifetime to reapply when resuming this turn. + pub prompt_cache_ttl: Option, /// Program recorded in the interrupted turn's persisted context. pub cyber_access_program: Option, } @@ -216,6 +218,15 @@ pub enum CyberAccessProgram { DaybreakRed, } +/// Minimum lifetime requested for new prompt cache entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum PromptCacheTtl { + #[serde(rename = "30m")] + ThirtyMinutes, + #[serde(rename = "12h")] + TwelveHours, +} + /// Options for the new-turn branch of a submission. /// /// Core only records these options when input starts a turn. When input steers, @@ -232,6 +243,8 @@ pub struct TurnStartOptions { pub final_output_json_schema: Option, /// Service tier for a new turn, without changing the thread's preference. pub service_tier: Option, + /// Lifetime requested for new Responses prompt cache entries during this turn. + pub prompt_cache_ttl: Option, /// Parent turn lineage recorded if this request starts a new turn. pub parent_turn_id: Option, /// Agent owning the parent turn, retained when recovering delegated work.