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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions codex-rs/core/src/context_manager/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ impl ContextManager {
}

/// Returns the history prepared for sending to the model. This applies a proper
/// normalization and drops un-suited items. When `input_modalities` does not
/// include `InputModality::Image`, images are stripped from messages and tool
/// outputs.
/// normalization and drops un-suited items. Unsupported image and audio content
/// is stripped from messages and tool outputs according to `input_modalities`.
pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> {
self.normalize_history(input_modalities);
self.items
Expand Down Expand Up @@ -355,7 +354,7 @@ impl ContextManager {
/// This function enforces a couple of invariants on the in-memory history:
/// 1. every call (function/custom) has a corresponding output entry
/// 2. every output has a corresponding call entry
/// 3. when images are unsupported, image content is stripped from messages and tool outputs
/// 3. unsupported image and audio content is stripped from messages and tool outputs
fn normalize_history(&mut self, input_modalities: &[InputModality]) {
// all function/tool calls must have a corresponding output
normalize::ensure_call_outputs_present(&mut self.items);
Expand All @@ -365,6 +364,9 @@ impl ContextManager {

// strip images when model does not support them
normalize::strip_images_when_unsupported(input_modalities, &mut self.items);

// strip audio when model does not support it
normalize::strip_audio_when_unsupported(input_modalities, &mut self.items);
}

fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
Expand Down
24 changes: 23 additions & 1 deletion codex-rs/core/src/context_manager/history_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ fn total_token_usage_includes_all_items_after_last_model_generated_item() {
}

#[test]
fn for_prompt_strips_images_when_model_does_not_support_images() {
fn for_prompt_strips_media_when_model_does_not_support_it() {
let items = vec![
ResponseItem::Message {
id: None,
Expand All @@ -484,6 +484,9 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
image_url: "https://example.com/img.png".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
ContentItem::InputAudio {
audio_url: "data:audio/wav;base64,YXVkaW8=".to_string(),
},
ContentItem::InputText {
text: "caption".to_string(),
},
Expand Down Expand Up @@ -554,6 +557,10 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
text: "image content omitted because you do not support image input"
.to_string(),
},
ContentItem::InputText {
text: "audio content omitted because you do not support audio input"
.to_string(),
},
ContentItem::InputText {
text: "caption".to_string(),
},
Expand Down Expand Up @@ -635,6 +642,21 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
} else {
panic!("expected Message");
}

let audio_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputAudio {
audio_url: "data:audio/wav;base64,YXVkaW8=".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
};
let with_audio = create_history_with_items(vec![audio_message.clone()]);
assert_eq!(
with_audio.for_prompt(&[InputModality::Text, InputModality::Audio]),
vec![audio_message]
);
}

#[test]
Expand Down
25 changes: 25 additions & 0 deletions codex-rs/core/src/context_manager/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use tracing::info;

const IMAGE_CONTENT_OMITTED_PLACEHOLDER: &str =
"image content omitted because you do not support image input";
const AUDIO_CONTENT_OMITTED_PLACEHOLDER: &str =
"audio content omitted because you do not support audio input";
// Changing this value would change model-visible IDs and invalidate prompt caches.
const SYNTHETIC_OUTPUT_ID_NAMESPACE: Uuid = Uuid::from_u128(0x90d38d3e_6a5b_4d52_bfe2_2f1e634bfac4);

Expand Down Expand Up @@ -366,3 +368,26 @@ pub(crate) fn strip_images_when_unsupported(
}
}
}

/// Strip audio content from messages when the model does not support audio.
/// When `input_modalities` contains `InputModality::Audio`, no stripping is performed.
pub(crate) fn strip_audio_when_unsupported(
input_modalities: &[InputModality],
items: &mut [ResponseItem],
) {
if input_modalities.contains(&InputModality::Audio) {
return;
}

for item in items.iter_mut() {
if let ResponseItem::Message { content, .. } = item {
for content_item in content.iter_mut() {
if matches!(content_item, ContentItem::InputAudio { .. }) {
*content_item = ContentItem::InputText {
text: AUDIO_CONTENT_OMITTED_PLACEHOLDER.to_string(),
};
}
}
}
}
}
16 changes: 16 additions & 0 deletions codex-rs/core/tests/common/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ impl ResponsesRequest {
.collect()
}

/// Returns all `input_audio` `audio_url` spans from `message` inputs for the provided role.
pub fn message_input_audio_urls(&self, role: &str) -> Vec<String> {
self.inputs_of_type("message")
.into_iter()
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array).cloned())
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_audio"))
.filter_map(|span| {
span.get("audio_url")
.and_then(Value::as_str)
.map(str::to_owned)
})
.collect()
}

pub fn input(&self) -> Vec<Value> {
self.body_json()["input"]
.as_array()
Expand Down
18 changes: 16 additions & 2 deletions codex-rs/core/tests/suite/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::WebSearchAction;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
Expand Down Expand Up @@ -314,7 +315,14 @@ async fn sends_audio_urls_to_responses() {
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex = test_codex().build(&server).await.unwrap().codex;
let codex = test_codex()
.with_model_info_override("gpt-5.5", |model_info| {
model_info.input_modalities.push(InputModality::Audio);
})
.build(&server)
.await
.unwrap()
.codex;
let audio_url = "data:audio/wav;base64,AAAA";

codex
Expand Down Expand Up @@ -357,7 +365,13 @@ async fn sends_local_audio_to_responses() -> anyhow::Result<()> {
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex = test_codex().build(&server).await?.codex;
let codex = test_codex()
.with_model_info_override("gpt-5.5", |model_info| {
model_info.input_modalities.push(InputModality::Audio);
})
.build(&server)
.await?
.codex;
let temp_dir = tempfile::tempdir()?;
let audio_path = temp_dir.path().join("recording.wav");
std::fs::write(&audio_path, b"audio")?;
Expand Down
41 changes: 31 additions & 10 deletions codex-rs/core/tests/suite/model_switching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,17 +477,21 @@ async fn null_service_tier_override_is_omitted_from_http_turn_with_catalog_defau
}

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

let server = MockServer::start().await;
let image_model_slug = "test-image-model";
let multimodal_model_slug = "test-multimodal-model";
let text_model_slug = "test-text-only-model";
let image_model = test_model_info(
image_model_slug,
"Test Image Model",
"supports image input",
default_input_modalities(),
let multimodal_model = test_model_info(
multimodal_model_slug,
"Test Multimodal Model",
"supports image and audio input",
vec![
InputModality::Text,
InputModality::Image,
InputModality::Audio,
],
);
let text_model = test_model_info(
text_model_slug,
Expand All @@ -498,7 +502,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
mount_models_once(
&server,
ModelsResponse {
models: vec![image_model, text_model],
models: vec![multimodal_model, text_model],
},
)
.await;
Expand All @@ -512,7 +516,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model = Some(image_model_slug.to_string());
config.model = Some(multimodal_model_slug.to_string());
});
let test = builder.build(&server).await?;
let models_manager = test.thread_manager.get_models_manager();
Expand All @@ -533,12 +537,15 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
image_url: image_url.clone(),
detail: None,
},
UserInput::Audio {
audio_url: "data:audio/wav;base64,YXVkaW8=".to_string(),
},
UserInput::Text {
text: "first turn".to_string(),
text_elements: Vec::new(),
},
],
image_model_slug.to_string(),
multimodal_model_slug.to_string(),
))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
Expand All @@ -563,19 +570,33 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
!first_request.message_input_image_urls("user").is_empty(),
"first request should include the uploaded image"
);
assert_eq!(
first_request.message_input_audio_urls("user"),
vec!["data:audio/wav;base64,YXVkaW8=".to_string()]
);

let second_request = requests.last().expect("expected second request");
assert!(
second_request.message_input_image_urls("user").is_empty(),
"second request should strip unsupported image content"
);
assert!(
second_request.message_input_audio_urls("user").is_empty(),
"second request should strip unsupported audio content"
);
let second_user_texts = second_request.message_input_texts("user");
assert!(
second_user_texts
.iter()
.any(|text| text == "image content omitted because you do not support image input"),
"second request should include the image-omitted placeholder text"
);
assert!(
second_user_texts
.iter()
.any(|text| text == "audio content omitted because you do not support audio input"),
"second request should include the audio-omitted placeholder text"
);
Ok(())
}

Expand Down
9 changes: 7 additions & 2 deletions codex-rs/protocol/src/openai_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ pub enum InputModality {
Text,
/// Image attachments included in user turns.
Image,
/// Audio attachments included in user turns.
Audio,
}

/// Backward-compatible default when `input_modalities` is omitted on the wire.
Expand Down Expand Up @@ -1101,12 +1103,15 @@ mod tests {
"context_window": null,
"auto_compact_token_limit": null,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"]
"experimental_supported_tools": []
}))
.expect("deserialize model info");

assert_eq!(model.availability_nux, None);
assert_eq!(
model.input_modalities,
vec![InputModality::Text, InputModality::Image]
);
assert!(!model.include_skills_usage_instructions);
assert!(model.supports_reasoning_summary_parameter);
assert!(!model.supports_image_detail_original);
Expand Down
Loading