mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
Preserve audio across history and tool outputs (#34385)
## Why Audio attachments could be dropped from tool outputs and reconstructed thread history, while inline base64 payload size could overstate their model context cost. ## What changed - Preserve remote and local audio attachments in legacy user-message events and thread history. - Keep audio from function and custom tool outputs when the model supports it and the output fits the truncation budget; otherwise replace it with an omission marker. - Estimate inline audio cost from its duration, with a serialized-size fallback for malformed or unsupported data URLs, so compaction and truncation use model-visible cost instead of raw base64 size. - Replace audio in messages and tool outputs with an explanatory placeholder for models without audio input support. ## Testing Add coverage for history round trips, dynamic tool responses, modality filtering, duration-based context estimates, output-budget truncation, and remote compaction. GitOrigin-RevId: 5ed4628850a668f8383b7cd73db71b144cc02cd1
This commit is contained in:
1 parent
692a0fb7e5
commit
6f785632b0
20 files changed
+975
-38
No files matched your search
@@ -104,6 +104,7 @@ serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
similar = { workspace = true }
|
||||
symphonia = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
|
||||
@@ -3,6 +3,17 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_utils_cache::BlockingLruCache;
|
||||
use codex_utils_cache::sha1_digest;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use std::io::Cursor;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::LazyLock;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::formats::TrackType;
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use tracing::warn;
|
||||
|
||||
const AUDIO_PROCESSING_ERROR_PLACEHOLDER: &str =
|
||||
@@ -17,6 +28,15 @@ const UNSUPPORTED_AUDIO_FORMAT_PLACEHOLDER: &str =
|
||||
/// This matches the Responses API audio input limit.
|
||||
const MAX_PROMPT_AUDIO_INPUT_BYTES: usize = 50 * 1024 * 1024;
|
||||
const MAX_PROMPT_AUDIO_BASE64_BYTES: usize = MAX_PROMPT_AUDIO_INPUT_BYTES.div_ceil(3) * 4;
|
||||
const AUDIO_TOKEN_ESTIMATE_CACHE_SIZE: usize = 32;
|
||||
const AUDIO_TOKENS_PER_SECOND: f64 = 10.0;
|
||||
|
||||
static AUDIO_TOKEN_ESTIMATE_CACHE: LazyLock<BlockingLruCache<[u8; 20], usize>> =
|
||||
LazyLock::new(|| {
|
||||
BlockingLruCache::new(
|
||||
NonZeroUsize::new(AUDIO_TOKEN_ESTIMATE_CACHE_SIZE).unwrap_or(NonZeroUsize::MIN),
|
||||
)
|
||||
});
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum AudioPreparationError {
|
||||
@@ -121,6 +141,65 @@ fn canonical_audio_mime(mime: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn estimate_audio_token_count(audio_url: &str) -> usize {
|
||||
let key = sha1_digest(audio_url.as_bytes());
|
||||
AUDIO_TOKEN_ESTIMATE_CACHE.get_or_insert_with(key, || {
|
||||
let Some(duration_seconds) = audio_duration_seconds(audio_url) else {
|
||||
return approx_token_count(audio_url);
|
||||
};
|
||||
let token_count = (duration_seconds * AUDIO_TOKENS_PER_SECOND).ceil();
|
||||
if token_count >= usize::MAX as f64 {
|
||||
usize::MAX
|
||||
} else {
|
||||
token_count as usize
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn audio_duration_seconds(audio_url: &str) -> Option<f64> {
|
||||
let (metadata, payload) = audio_url.split_once(',')?;
|
||||
let metadata = metadata.get("data:".len()..)?;
|
||||
let mut metadata_parts = metadata.split(';');
|
||||
let canonical_mime = canonical_audio_mime(metadata_parts.next()?)?;
|
||||
if !metadata_parts.any(|part| part.eq_ignore_ascii_case("base64")) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bytes = match BASE64_STANDARD.decode(payload) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
tracing::trace!(%error, "failed to decode audio payload for token estimation");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let media_source = MediaSourceStream::new(Box::new(Cursor::new(bytes)), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
hint.mime_type(canonical_mime);
|
||||
let format = match symphonia::default::get_probe().probe(
|
||||
&hint,
|
||||
media_source,
|
||||
FormatOptions::default(),
|
||||
MetadataOptions::default(),
|
||||
) {
|
||||
Ok(format) => format,
|
||||
Err(error) => {
|
||||
tracing::trace!(%error, "failed to read audio duration for token estimation");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let track = format.default_track(TrackType::Audio)?;
|
||||
let timing = track.time_base.zip(track.duration).or_else(|| {
|
||||
format
|
||||
.media_info()
|
||||
.time_base
|
||||
.zip(format.media_info().duration)
|
||||
});
|
||||
let (time_base, duration) = timing?;
|
||||
let duration_seconds =
|
||||
duration.get() as f64 * f64::from(time_base.numer.get()) / f64::from(time_base.denom.get());
|
||||
duration_seconds.is_finite().then_some(duration_seconds)
|
||||
}
|
||||
|
||||
fn prepare_audio(audio_url: &mut String) -> Result<(), AudioPreparationError> {
|
||||
if !is_data_url(audio_url) {
|
||||
return Err(AudioPreparationError::InvalidDataUrl {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::audio_preparation::estimate_audio_token_count;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::world_state::WorldState;
|
||||
use crate::context::world_state::WorldStateSnapshot;
|
||||
@@ -437,7 +438,7 @@ pub(crate) fn truncate_function_output_payload(
|
||||
FunctionCallOutputBody::Text(truncate_text(content, policy))
|
||||
}
|
||||
FunctionCallOutputBody::ContentItems(items) => FunctionCallOutputBody::ContentItems(
|
||||
truncate_function_output_items_with_policy(items, policy),
|
||||
truncate_function_output_items_with_policy(items, policy, estimate_audio_token_count),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -531,14 +532,18 @@ fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 {
|
||||
.unwrap_or_default();
|
||||
let (image_payload_bytes, image_replacement_bytes) =
|
||||
image_data_url_estimate_adjustment(item);
|
||||
let (audio_payload_bytes, audio_replacement_bytes) =
|
||||
audio_data_url_estimate_adjustment(item);
|
||||
let (encrypted_payload_bytes, encrypted_replacement_bytes) =
|
||||
encrypted_function_output_estimate_adjustment(item);
|
||||
// Replace raw base64 payload bytes with a per-image estimate.
|
||||
// Replace raw base64 payload bytes with per-modality estimates.
|
||||
// We intentionally preserve the data URL prefix and JSON
|
||||
// wrapper bytes already included in `raw`.
|
||||
let raw = raw
|
||||
.saturating_sub(image_payload_bytes)
|
||||
.saturating_add(image_replacement_bytes);
|
||||
.saturating_add(image_replacement_bytes)
|
||||
.saturating_sub(audio_payload_bytes)
|
||||
.saturating_add(audio_replacement_bytes);
|
||||
raw.saturating_sub(encrypted_payload_bytes)
|
||||
.saturating_add(encrypted_replacement_bytes)
|
||||
}
|
||||
@@ -551,6 +556,16 @@ fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 {
|
||||
/// We only discount payloads for `data:image/...;base64,...` URLs (case
|
||||
/// insensitive markers) and leave everything else at raw serialized size.
|
||||
fn parse_base64_image_data_url(url: &str) -> Option<&str> {
|
||||
parse_base64_data_url(url, "image/")
|
||||
}
|
||||
|
||||
/// Returns the base64 payload for inline audio data URLs that are eligible for
|
||||
/// token-estimation discounting.
|
||||
fn parse_base64_audio_data_url(url: &str) -> Option<&str> {
|
||||
parse_base64_data_url(url, "audio/")
|
||||
}
|
||||
|
||||
fn parse_base64_data_url<'a>(url: &'a str, media_type_prefix: &str) -> Option<&'a str> {
|
||||
if !url
|
||||
.get(.."data:".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
@@ -561,15 +576,15 @@ fn parse_base64_image_data_url(url: &str) -> Option<&str> {
|
||||
let metadata = &url[..comma_index];
|
||||
let payload = &url[comma_index + 1..];
|
||||
// Parse the media type and parameters without decoding. This keeps the
|
||||
// estimator cheap while ensuring we only apply the fixed-cost image
|
||||
// heuristic to image-typed base64 data URLs.
|
||||
// estimator cheap while ensuring we only apply modality heuristics to
|
||||
// appropriately typed base64 data URLs.
|
||||
let metadata_without_scheme = &metadata["data:".len()..];
|
||||
let mut metadata_parts = metadata_without_scheme.split(';');
|
||||
let mime_type = metadata_parts.next().unwrap_or_default();
|
||||
let has_base64_marker = metadata_parts.any(|part| part.eq_ignore_ascii_case("base64"));
|
||||
if !mime_type
|
||||
.get(.."image/".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
|
||||
.get(..media_type_prefix.len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(media_type_prefix))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -662,6 +677,50 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
(payload_bytes, replacement_bytes)
|
||||
}
|
||||
|
||||
/// Scans one response item for inline base64 audio data URLs and returns:
|
||||
/// - total base64 payload bytes to subtract from raw serialized size
|
||||
/// - total replacement byte estimate for those audio inputs
|
||||
fn audio_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
let mut payload_bytes = 0i64;
|
||||
let mut replacement_bytes = 0i64;
|
||||
|
||||
let mut accumulate = |audio_url: &str| {
|
||||
if let Some(payload_len) = parse_base64_audio_data_url(audio_url).map(str::len) {
|
||||
payload_bytes =
|
||||
payload_bytes.saturating_add(i64::try_from(payload_len).unwrap_or(i64::MAX));
|
||||
replacement_bytes = replacement_bytes.saturating_add(
|
||||
i64::try_from(approx_bytes_for_tokens(estimate_audio_token_count(
|
||||
audio_url,
|
||||
)))
|
||||
.unwrap_or(i64::MAX),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content {
|
||||
if let ContentItem::InputAudio { audio_url } = content_item {
|
||||
accumulate(audio_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let FunctionCallOutputBody::ContentItems(items) = &output.body {
|
||||
for content_item in items {
|
||||
if let FunctionCallOutputContentItem::InputAudio { audio_url } = content_item {
|
||||
accumulate(audio_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
(payload_bytes, replacement_bytes)
|
||||
}
|
||||
|
||||
fn encrypted_function_output_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
let ResponseItem::FunctionCallOutput { output, .. } = item else {
|
||||
return (0, 0);
|
||||
|
||||
@@ -40,6 +40,31 @@ use regex_lite::Regex;
|
||||
|
||||
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
|
||||
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
|
||||
const TEST_WAV_SAMPLE_RATE: u32 = 8_000;
|
||||
|
||||
fn pcm_wav_data_url(sample_count: u32) -> (String, usize) {
|
||||
let padding = sample_count % 2;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + sample_count + padding).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&8u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&sample_count.to_le_bytes());
|
||||
bytes.resize(
|
||||
bytes.len() + sample_count as usize + padding as usize,
|
||||
/*value*/ 0,
|
||||
);
|
||||
let payload = BASE64_STANDARD.encode(bytes);
|
||||
let payload_len = payload.len();
|
||||
(format!("data:audio/wav;base64,{payload}"), payload_len)
|
||||
}
|
||||
|
||||
fn assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
@@ -513,6 +538,9 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
|
||||
image_url: "https://example.com/result.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputAudio {
|
||||
audio_url: "data:audio/mpeg;base64,YXVkaW8=".to_string(),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -537,10 +565,14 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
|
||||
image_url: "https://example.com/js-repl-result.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputAudio {
|
||||
audio_url: "data:audio/ogg;base64,YXVkaW8=".to_string(),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
let fully_supported_items = items.clone();
|
||||
let history = create_history_with_items(items);
|
||||
let text_only_modalities = vec![InputModality::Text];
|
||||
let stripped = history.for_prompt(&text_only_modalities);
|
||||
@@ -587,6 +619,10 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
|
||||
text: "image content omitted because you do not support image input"
|
||||
.to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "audio content omitted because you do not support audio input"
|
||||
.to_string(),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -611,11 +647,23 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
|
||||
text: "image content omitted because you do not support image input"
|
||||
.to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "audio content omitted because you do not support audio input"
|
||||
.to_string(),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(stripped, expected);
|
||||
assert_eq!(
|
||||
create_history_with_items(fully_supported_items.clone()).for_prompt(&[
|
||||
InputModality::Text,
|
||||
InputModality::Image,
|
||||
InputModality::Audio,
|
||||
]),
|
||||
fully_supported_items
|
||||
);
|
||||
|
||||
// With image support, images are preserved
|
||||
let modalities = default_input_modalities();
|
||||
@@ -1949,6 +1997,121 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_data_url_payload_does_not_dominate_message_estimate() {
|
||||
let (audio_url, payload_len) = pcm_wav_data_url(/*sample_count*/ 801);
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputAudio { audio_url }],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
let expected = raw_len - payload_len as i64 + approx_bytes_for_tokens(/*tokens*/ 2) as i64;
|
||||
|
||||
assert_eq!(estimated, expected);
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_data_url_payload_does_not_dominate_function_call_output_estimate() {
|
||||
let (audio_url, payload_len) = pcm_wav_data_url(/*sample_count*/ 800);
|
||||
let item = ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
call_id: "call-audio".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url },
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
let expected = raw_len - payload_len as i64 + approx_bytes_for_tokens(/*tokens*/ 1) as i64;
|
||||
|
||||
assert_eq!(estimated, expected);
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
|
||||
let (audio_url, payload_len) = pcm_wav_data_url(/*sample_count*/ 80_000);
|
||||
let item = ResponseItem::CustomToolCallOutput {
|
||||
id: None,
|
||||
call_id: "call-custom-audio".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url },
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
let expected = raw_len - payload_len as i64 + approx_bytes_for_tokens(/*tokens*/ 100) as i64;
|
||||
|
||||
assert_eq!(estimated, expected);
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_audio_data_url_falls_back_to_whole_url_size_cost() {
|
||||
let payload = "A".repeat(/*n*/ 100_000);
|
||||
let audio_url = format!("data:audio/wav;base64,{payload}");
|
||||
let fallback_bytes = approx_bytes_for_tokens(approx_token_count(&audio_url)) as i64;
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputAudio { audio_url }],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
|
||||
assert_eq!(estimated, raw_len - payload.len() as i64 + fallback_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_items_omits_audio_that_exceeds_the_output_budget() {
|
||||
let (audio_url, _) = pcm_wav_data_url(/*sample_count*/ 80_000);
|
||||
let item = ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
call_id: "call-audio".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url },
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let mut history = ContextManager::new();
|
||||
|
||||
history.record_items([&item], TruncationPolicy::Tokens(50));
|
||||
|
||||
assert_eq!(
|
||||
history.items,
|
||||
vec![ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
call_id: "call-audio".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "[omitted 1 audio items ...]".to_string(),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_base64_image_urls_are_unchanged() {
|
||||
let message_item = ResponseItem::Message {
|
||||
|
||||
@@ -369,7 +369,7 @@ pub(crate) fn strip_images_when_unsupported(
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip audio content from messages when the model does not support audio.
|
||||
/// Strip audio content from messages and tool outputs 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],
|
||||
@@ -380,14 +380,32 @@ pub(crate) fn strip_audio_when_unsupported(
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content.iter_mut() {
|
||||
if matches!(content_item, ContentItem::InputAudio { .. }) {
|
||||
*content_item = ContentItem::InputText {
|
||||
text: AUDIO_CONTENT_OMITTED_PLACEHOLDER.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content_items) = output.content_items_mut() {
|
||||
for content_item in content_items.iter_mut() {
|
||||
if matches!(
|
||||
content_item,
|
||||
FunctionCallOutputContentItem::InputAudio { .. }
|
||||
) {
|
||||
*content_item = FunctionCallOutputContentItem::InputText {
|
||||
text: AUDIO_CONTENT_OMITTED_PLACEHOLDER.to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use serde_json::Value as JsonValue;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::audio_preparation::estimate_audio_token_count;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::original_image_detail::sanitize_original_image_detail as sanitize_image_detail_items;
|
||||
@@ -269,7 +270,7 @@ fn truncate_code_mode_result(
|
||||
return truncated_items;
|
||||
}
|
||||
|
||||
truncate_function_output_items_with_policy(&items, policy)
|
||||
truncate_function_output_items_with_policy(&items, policy, estimate_audio_token_count)
|
||||
}
|
||||
|
||||
async fn call_nested_tool(
|
||||
@@ -427,6 +428,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_audio_output_is_omitted() {
|
||||
let items = vec![FunctionCallOutputContentItem::InputAudio {
|
||||
audio_url: format!("data:audio/wav;base64,{}", "A".repeat(100)),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
truncate_code_mode_result(items, Some(5)),
|
||||
vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "[omitted 1 audio items ...]".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_process_host_falls_back_to_in_process_session() {
|
||||
let service = CodeModeService::new(Arc::new(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::sse;
|
||||
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;
|
||||
|
||||
const TEST_WAV_SAMPLE_RATE: u32 = 8_000;
|
||||
const OMITTED_AUDIO_MARKER: &str = "[omitted 1 audio items ...]";
|
||||
|
||||
fn pcm_wav_data_url(sample_count: u32) -> String {
|
||||
let padding = sample_count % 2;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + sample_count + padding).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&8u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&sample_count.to_le_bytes());
|
||||
bytes.resize(
|
||||
bytes.len() + sample_count as usize + padding as usize,
|
||||
/*value*/ 0,
|
||||
);
|
||||
format!("data:audio/wav;base64,{}", BASE64_STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let call_id = "audio-call";
|
||||
let tool_name = "recording";
|
||||
let responses_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(call_id, "codex_app", tool_name, "{}"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-1", "done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_model_info_override("gpt-5.5", |model_info| {
|
||||
model_info.input_modalities.push(InputModality::Audio);
|
||||
model_info.truncation_policy = TruncationPolicyConfig::tokens(/*limit*/ 50);
|
||||
});
|
||||
let base_test = builder.build_with_auto_env(&server).await?;
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Audio tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool_name.to_string(),
|
||||
description: "Returns a recording.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
},
|
||||
)],
|
||||
});
|
||||
let new_thread = base_test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool])
|
||||
.await?;
|
||||
let mut test = base_test;
|
||||
test.codex = new_thread.thread;
|
||||
test.session_configured = new_thread.session_configured;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "Return a recording".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::DynamicToolCallRequest(_))
|
||||
})
|
||||
.await
|
||||
else {
|
||||
unreachable!("event guard guarantees DynamicToolCallRequest");
|
||||
};
|
||||
test.codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: request.call_id,
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputAudio {
|
||||
audio_url: pcm_wav_data_url(/*sample_count*/ 80_000),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = responses_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output = requests[1]
|
||||
.function_call_output(call_id)
|
||||
.get("output")
|
||||
.cloned()
|
||||
.expect("follow-up request should contain the dynamic tool output");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FunctionCallOutputPayload>(output)?,
|
||||
FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: OMITTED_AUDIO_MARKER.to_string(),
|
||||
},
|
||||
]),
|
||||
success: None,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use core_test_support::test_codex::local_selections;
|
||||
use std::fs;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_core::compact::SUMMARY_PREFIX;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -9,13 +11,19 @@ use codex_login::auth::AgentIdentityAuth;
|
||||
use codex_login::auth::AgentIdentityAuthRecord;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::protocol::ConversationStartParams;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ItemCompletedEvent;
|
||||
@@ -52,6 +60,29 @@ use wiremock::ResponseTemplate;
|
||||
|
||||
const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
|
||||
"Output exceeded the available model context and was truncated";
|
||||
const TEST_WAV_SAMPLE_RATE: u32 = 8_000;
|
||||
|
||||
fn pcm_wav_data_url(sample_count: u32) -> String {
|
||||
let padding = sample_count % 2;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + sample_count + padding).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&TEST_WAV_SAMPLE_RATE.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&8u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&sample_count.to_le_bytes());
|
||||
bytes.resize(
|
||||
bytes.len() + sample_count as usize + padding as usize,
|
||||
/*value*/ 0,
|
||||
);
|
||||
format!("data:audio/wav;base64,{}", BASE64_STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
fn approx_token_count(text: &str) -> i64 {
|
||||
i64::try_from(text.len().saturating_add(3) / 4).unwrap_or(i64::MAX)
|
||||
@@ -1315,6 +1346,123 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_compact_does_not_charge_inline_audio_payload_as_text() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let call_id = "audio-call";
|
||||
let tool_name = "recording";
|
||||
let audio_url = pcm_wav_data_url(/*sample_count*/ 300_000);
|
||||
let responses_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(call_id, "codex_app", tool_name, "{}"),
|
||||
responses::ev_completed_with_tokens("resp-1", /*total_tokens*/ 100),
|
||||
]),
|
||||
sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-1", "done"),
|
||||
responses::ev_completed_with_tokens("resp-2", /*total_tokens*/ 200),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let compact_mock = responses::mount_compact_json_once(
|
||||
&server,
|
||||
json!({ "output": compacted_summary_only_output("compact summary") }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_model_info_override("gpt-5.5", |model_info| {
|
||||
model_info.input_modalities.push(InputModality::Audio);
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.model_context_window = Some(50_000);
|
||||
});
|
||||
let mut test = builder.build(&server).await?;
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Audio tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool_name.to_string(),
|
||||
description: "Returns a recording.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
},
|
||||
)],
|
||||
});
|
||||
let new_thread = test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(test.config.clone(), vec![dynamic_tool])
|
||||
.await?;
|
||||
test.codex = new_thread.thread;
|
||||
test.session_configured = new_thread.session_configured;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "Return a recording".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::DynamicToolCallRequest(_))
|
||||
})
|
||||
.await
|
||||
else {
|
||||
unreachable!("event guard guarantees DynamicToolCallRequest");
|
||||
};
|
||||
test.codex
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: request.call_id,
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputAudio {
|
||||
audio_url: audio_url.clone(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_turn_complete(&test.codex).await;
|
||||
|
||||
test.codex.submit(Op::Compact).await?;
|
||||
wait_for_turn_complete(&test.codex).await;
|
||||
|
||||
assert_eq!(responses_mock.requests().len(), 2);
|
||||
let output = compact_mock
|
||||
.single_request()
|
||||
.function_call_output(call_id)
|
||||
.get("output")
|
||||
.cloned()
|
||||
.expect("compact request should retain the dynamic tool output");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FunctionCallOutputPayload>(output)?,
|
||||
FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url },
|
||||
]),
|
||||
success: None,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_compact_runs_automatically() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -37,6 +37,7 @@ mod agents_md;
|
||||
mod apply_patch_cli;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod approvals;
|
||||
mod audio_truncation;
|
||||
mod auto_review;
|
||||
mod catalog_permission_messages;
|
||||
mod cli_stream;
|
||||
|
||||
Reference in new issue
Block a user