Skip to content
Open
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
2 changes: 2 additions & 0 deletions codex-rs/app-server/tests/common/models_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
inline_image_request_limit_bytes: None,
inline_image_request_limit_image_count: None,
context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/codex-api/tests/models_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ async fn models_client_hits_models_endpoint() {
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
inline_image_request_limit_bytes: None,
inline_image_request_limit_image_count: None,
context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ use crate::default_client::build_reqwest_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::inline_image_request_limit::inline_image_request_limit_error;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::response_debug_context::extract_response_debug_context;
Expand Down Expand Up @@ -692,6 +693,9 @@ impl ModelClientSession {
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let input = prompt.get_formatted_input();
if let Some(error) = inline_image_request_limit_error(&input, model_info) {
return Err(CodexErr::InlineImageRequestLimitExceeded(error));
}
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let default_reasoning_effort = model_info.default_reasoning_level;
let reasoning = if model_info.supports_reasoning_summaries {
Expand Down
64 changes: 59 additions & 5 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ use crate::error::CodexErr;
use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::inline_image_request_limit::inline_image_request_limit_error;
use codex_config::CONFIG_TOML_FILE;

mod rollout_reconstruction;
Expand Down Expand Up @@ -3326,16 +3327,69 @@ impl Session {
.await
}

/// Records input items: always append to conversation history and
/// persist these response items to rollout.
/// Records input items, sanitizing inline tool images before persistence when
/// they would overflow the model request.
pub(crate) async fn record_conversation_items(
&self,
turn_context: &TurnContext,
items: &[ResponseItem],
) {
self.record_into_history(items, turn_context).await;
self.persist_rollout_response_items(items).await;
self.send_raw_response_items(turn_context, items).await;
let items_to_record = {
let mut state = self.state.lock().await;
let items_to_record = Self::prepare_items_for_recording(&state, turn_context, items);
state.record_items(items_to_record.iter(), turn_context.truncation_policy);
items_to_record
};

self.persist_rollout_response_items(&items_to_record).await;
self.send_raw_response_items(turn_context, &items_to_record)
.await;
}

fn prepare_items_for_recording(
state: &SessionState,
turn_context: &TurnContext,
items: &[ResponseItem],
) -> Vec<ResponseItem> {
let mut items_to_record = items.to_vec();
let should_check_image_request_limits = items_to_record.iter().any(|item| match item {
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
output.content_items().is_some_and(|content_items| {
content_items.iter().any(|content_item| {
matches!(
content_item,
codex_protocol::models::FunctionCallOutputContentItem::InputImage { .. }
)
})
})
}
_ => false,
});
if !should_check_image_request_limits {
return items_to_record;
}

let mut candidate_history = state.clone_history();
candidate_history.record_items(items_to_record.iter(), turn_context.truncation_policy);
let prompt_input = candidate_history.for_prompt(&turn_context.model_info.input_modalities);
let Some(error) = inline_image_request_limit_error(&prompt_input, &turn_context.model_info)
else {
return items_to_record;
};

let mut pending_items = ContextManager::new();
pending_items.replace(items_to_record.clone());
if pending_items.replace_last_turn_tool_outputs_with_failure_message(
&error.tool_output_recovery_message(),
) {
warn!(
"inline image request limit would be exceeded before upload; sanitizing tool image output before persistence"
);
items_to_record = pending_items.raw_items().to_vec();
}

items_to_record
}

/// Append ResponseItems to the in-memory conversation history only.
Expand Down
96 changes: 96 additions & 0 deletions codex-rs/core/src/codex_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::tools::format_exec_output_str;
use codex_features::Features;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
Expand Down Expand Up @@ -925,6 +926,101 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
assert_eq!(reconstructed.history, replacement_history);
}

#[tokio::test]
async fn record_conversation_items_sanitizes_inline_tool_images_before_persistence() {
let (session, mut turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let rollout_path = attach_rollout_recorder(&session).await;
let user = user_message("describe the image");
let image_url = "data:image/png;base64,AAAA".to_string();
turn_context.model_info.inline_image_request_limit_bytes = Some(10);
let tool_call = ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "view-image".to_string(),
};
let original_output = ResponseItem::FunctionCallOutput {
call_id: "view-image".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "captured".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: image_url.clone(),
detail: None,
},
]),
};
let recovery_message =
crate::error::InlineImageRequestLimitExceededError::local_preflight_bytes(
image_url.len(),
10,
)
.tool_output_recovery_message();
let recovered_output = ResponseItem::FunctionCallOutput {
call_id: "view-image".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "captured".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: recovery_message,
},
]),
success: Some(false),
},
};

session
.record_conversation_items(&turn_context, std::slice::from_ref(&user))
.await;
session
.record_conversation_items(&turn_context, std::slice::from_ref(&tool_call))
.await;
session
.record_conversation_items(&turn_context, std::slice::from_ref(&original_output))
.await;

assert_eq!(
session.clone_history().await.raw_items().to_vec(),
vec![user.clone(), tool_call.clone(), recovered_output.clone()]
);

session.flush_rollout().await;
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
.await
.expect("read rollout history")
else {
panic!("expected resumed rollout history");
};

let persisted_response_items = resumed
.history
.into_iter()
.filter_map(|item| match item {
RolloutItem::ResponseItem(item) => Some(item),
_ => None,
})
.collect::<Vec<_>>();
let ResponseItem::FunctionCallOutput { output, .. } = recovered_output else {
panic!("expected function call output");
};
let persisted_output = ResponseItem::FunctionCallOutput {
call_id: "view-image".to_string(),
output: FunctionCallOutputPayload {
body: output.body,
success: None,
},
};
assert_eq!(
persisted_response_items,
vec![user, tool_call, persisted_output]
);
}

#[tokio::test]
async fn record_initial_history_reconstructs_resumed_transcript() {
let (session, turn_context) = make_session_and_context().await;
Expand Down
Loading
Loading