Compare commits

...

2 Commits

Author SHA1 Message Date
Curtis 'Fjord' Hawthorne
b05eaf31f0 Handle inline image request caps gracefully
git-stack-id: fjord/original_image_size_warn
git-stack-title: Handle inline image request caps gracefully
2026-03-30 16:44:41 -07:00
Curtis 'Fjord' Hawthorne
0771a56078 Log upstream inline image limit rejections
git-stack-id: fjord/original_image_size_warn-sync---4i5oof0m2upvfe
git-stack-title: Log upstream inline image limit rejections
2026-03-30 16:44:41 -07:00
30 changed files with 1613 additions and 86 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -10,6 +10,7 @@ use codex_login::token_data::PlanType;
use http::HeaderMap;
use serde::Deserialize;
use serde_json::Value;
use tracing::warn;
use crate::auth::CodexAuth;
use crate::error::CodexErr;
@@ -18,6 +19,12 @@ use crate::error::UnexpectedResponseError;
use crate::error::UsageLimitReachedError;
use crate::model_provider_info::ModelProviderInfo;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct InlineImageRequestLimitBadRequestObservation {
pub(crate) bytes_exceeded: bool,
pub(crate) images_exceeded: bool,
}
pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
match err {
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
@@ -63,6 +70,17 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
.contains("The image data you provided does not represent a valid image")
{
CodexErr::InvalidImageRequest()
} else if let Some(observation) =
inline_image_request_limit_bad_request_observation(&body_text)
{
warn!(
response_status = %status,
bytes_exceeded = observation.bytes_exceeded,
images_exceeded = observation.images_exceeded,
response_body = %body_text,
"responses request rejected by upstream inline image limit"
);
CodexErr::InvalidRequest(body_text)
} else {
CodexErr::InvalidRequest(body_text)
}
@@ -138,6 +156,59 @@ fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
}
pub(crate) fn inline_image_request_limit_bad_request_observation(
body: &str,
) -> Option<InlineImageRequestLimitBadRequestObservation> {
if let Ok(error) = serde_json::from_str::<BadRequestErrorResponse>(body) {
return inline_image_request_limit_observation(
&error.error.message,
error.error.code.as_deref(),
error.error.error_type.as_deref(),
);
}
inline_image_request_limit_observation_from_message(body)
}
pub(crate) fn inline_image_request_limit_observation(
message: &str,
code: Option<&str>,
error_type: Option<&str>,
) -> Option<InlineImageRequestLimitBadRequestObservation> {
if matches!(
(code, error_type),
(Some("max_images_per_request"), _) | (_, Some("max_images_per_request"))
) {
return Some(InlineImageRequestLimitBadRequestObservation {
bytes_exceeded: false,
images_exceeded: true,
});
}
inline_image_request_limit_observation_from_message(message)
}
fn inline_image_request_limit_observation_from_message(
message: &str,
) -> Option<InlineImageRequestLimitBadRequestObservation> {
let bytes_exceeded = matches_inline_image_byte_limit_message(message);
if !bytes_exceeded {
return None;
}
Some(InlineImageRequestLimitBadRequestObservation {
bytes_exceeded,
images_exceeded: false,
})
}
fn matches_inline_image_byte_limit_message(message: &str) -> bool {
message
.strip_prefix("Total image data in 'input' exceeds the ")
.and_then(|rest| rest.split_once(" byte limit"))
.is_some_and(|(limit, _)| !limit.is_empty() && limit.chars().all(|c| c.is_ascii_digit()))
}
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
extract_header(headers, REQUEST_ID_HEADER)
.or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER))
@@ -201,6 +272,19 @@ struct UsageErrorResponse {
error: UsageErrorBody,
}
#[derive(Debug, Deserialize)]
struct BadRequestErrorResponse {
error: BadRequestErrorBody,
}
#[derive(Debug, Deserialize)]
struct BadRequestErrorBody {
message: String,
#[serde(rename = "type")]
error_type: Option<String>,
code: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UsageErrorBody {
#[serde(rename = "type")]

View File

@@ -131,6 +131,65 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
}
#[test]
fn inline_image_request_limit_bad_request_matches_byte_limit_copy() {
assert_eq!(
inline_image_request_limit_bad_request_observation(
"Total image data in 'input' exceeds the 536870912 byte limit."
),
Some(InlineImageRequestLimitBadRequestObservation {
bytes_exceeded: true,
images_exceeded: false,
})
);
}
#[test]
fn inline_image_request_limit_bad_request_matches_live_byte_limit_copy() {
assert_eq!(
inline_image_request_limit_bad_request_observation(
"Total image data in 'input' exceeds the 536870912 byte limit for a single /v1/responses request."
),
Some(InlineImageRequestLimitBadRequestObservation {
bytes_exceeded: true,
images_exceeded: false,
})
);
}
#[test]
fn inline_image_request_limit_bad_request_matches_structured_image_count_error() {
assert_eq!(
inline_image_request_limit_bad_request_observation(
r#"{"error":{"message":"Invalid request.","type":"max_images_per_request","param":null,"code":"max_images_per_request"}}"#
),
Some(InlineImageRequestLimitBadRequestObservation {
bytes_exceeded: false,
images_exceeded: true,
})
);
}
#[test]
fn inline_image_request_limit_bad_request_ignores_message_only_image_count_copy() {
assert_eq!(
inline_image_request_limit_bad_request_observation(
"This request contains 1501 images, which exceeds the 1500 image limit for a single Responses API request."
),
None
);
}
#[test]
fn inline_image_request_limit_bad_request_ignores_other_bad_requests() {
assert_eq!(
inline_image_request_limit_bad_request_observation(
"Request body is missing required field: input"
),
None
);
}
#[test]
fn core_auth_provider_reports_when_auth_header_will_attach() {
let auth = CoreAuthProvider {

View File

@@ -32,6 +32,7 @@ use std::sync::atomic::Ordering;
use crate::api_bridge::CoreAuthProvider;
use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::inline_image_request_limit_bad_request_observation;
use crate::api_bridge::map_api_error;
use crate::auth::UnauthorizedRecovery;
use crate::auth_env_telemetry::AuthEnvTelemetry;
@@ -61,6 +62,7 @@ use codex_api::error::ApiError;
use codex_api::requests::responses::Compression;
use codex_api::response_create_client_metadata;
use codex_otel::SessionTelemetry;
use codex_otel::WellKnownApiRequestError;
use codex_otel::current_span_w3c_trace_context;
use codex_protocol::ThreadId;
@@ -102,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;
@@ -690,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 {
@@ -1674,6 +1680,22 @@ fn api_error_http_status(error: &ApiError) -> Option<u16> {
}
}
fn upstream_inline_image_request_limit_observation_from_transport_error(
error: &TransportError,
) -> Option<crate::api_bridge::InlineImageRequestLimitBadRequestObservation> {
let TransportError::Http {
status,
body: Some(body_text),
..
} = error
else {
return None;
};
if *status != StatusCode::BAD_REQUEST {
return None;
}
inline_image_request_limit_bad_request_observation(body_text)
}
struct ApiTelemetry {
session_telemetry: SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
@@ -1710,6 +1732,17 @@ impl RequestTelemetry for ApiTelemetry {
let debug = error
.map(extract_response_debug_context)
.unwrap_or_default();
let well_known_error = match error
.and_then(upstream_inline_image_request_limit_observation_from_transport_error)
{
Some(observation) if observation.images_exceeded => {
WellKnownApiRequestError::TooManyImages
}
Some(observation) if observation.bytes_exceeded => {
WellKnownApiRequestError::RequestSizeExceeded
}
Some(_) | None => WellKnownApiRequestError::None,
};
self.session_telemetry.record_api_request(
attempt,
status,
@@ -1725,6 +1758,7 @@ impl RequestTelemetry for ApiTelemetry {
debug.cf_ray.as_deref(),
debug.auth_error.as_deref(),
debug.auth_error_code.as_deref(),
well_known_error,
);
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {

View File

@@ -2,13 +2,34 @@ use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
use super::UnauthorizedRecoveryExecution;
use crate::client_common::Prompt;
use codex_otel::SessionTelemetry;
use codex_otel::WellKnownApiRequestError;
use codex_otel::metrics::MetricsClient;
use codex_otel::metrics::MetricsConfig;
use codex_otel::metrics::names::API_CALL_COUNT_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use opentelemetry::KeyValue;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
use opentelemetry_sdk::metrics::data::Metric;
use opentelemetry_sdk::metrics::data::MetricData;
use opentelemetry_sdk::metrics::data::ResourceMetrics;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
@@ -72,6 +93,51 @@ fn test_session_telemetry() -> SessionTelemetry {
)
}
fn test_session_telemetry_with_metrics() -> SessionTelemetry {
let exporter = InMemoryMetricExporter::default();
let metrics = MetricsClient::new(
MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter)
.with_runtime_reader(),
)
.expect("in-memory metrics client");
test_session_telemetry().with_metrics_without_metadata_tags(metrics)
}
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
for scope_metrics in resource_metrics.scope_metrics() {
for metric in scope_metrics.metrics() {
if metric.name() == name {
return metric;
}
}
}
panic!("metric {name} missing");
}
fn attributes_to_map<'a>(
attributes: impl Iterator<Item = &'a KeyValue>,
) -> BTreeMap<String, String> {
attributes
.map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string()))
.collect()
}
fn metric_point(resource_metrics: &ResourceMetrics, name: &str) -> (BTreeMap<String, String>, u64) {
let metric = find_metric(resource_metrics, name);
match metric.data() {
AggregatedMetrics::U64(data) => match data {
MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
let point = points[0];
(attributes_to_map(point.attributes()), point.value())
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
}
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
@@ -120,3 +186,78 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
assert_eq!(auth_context.recovery_mode, Some("managed"));
assert_eq!(auth_context.recovery_phase, Some("refresh_token"));
}
#[tokio::test]
async fn compact_conversation_history_emits_metric_for_upstream_inline_image_limit_rejection() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses/compact"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"message": "Invalid request.",
"type": "max_images_per_request",
"param": null,
"code": "max_images_per_request"
}
})))
.mount(&server)
.await;
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
&format!("{}/v1", server.uri()),
crate::model_provider_info::WireApi::Responses,
);
let client = ModelClient::new(
/*auth_manager*/ None,
ThreadId::new(),
provider,
SessionSource::Cli,
/*model_verbosity*/ None,
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
);
let session_telemetry = test_session_telemetry_with_metrics();
let prompt = Prompt {
input: vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "https://example.com/one.png".to_string(),
}],
end_turn: None,
phase: None,
}],
base_instructions: BaseInstructions::default(),
..Default::default()
};
let err = client
.compact_conversation_history(
&prompt,
&test_model_info(),
/*effort*/ None,
ReasoningSummaryConfig::Auto,
&session_telemetry,
)
.await
.expect_err("compact request should be rejected upstream");
assert!(matches!(err, crate::error::CodexErr::InvalidRequest(_)));
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
let (api_attrs, api_value) = metric_point(&snapshot, API_CALL_COUNT_METRIC);
assert_eq!(api_value, 1);
assert_eq!(
api_attrs,
BTreeMap::from([
("status".to_string(), "400".to_string()),
("success".to_string(), "false".to_string()),
(
"well_known_error".to_string(),
WellKnownApiRequestError::TooManyImages.as_str().to_string(),
),
])
);
}

View File

@@ -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;
@@ -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.

View File

@@ -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;
@@ -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;

View File

@@ -3,6 +3,8 @@ use crate::context_manager::normalize;
use crate::event_mapping::has_non_contextual_dev_message_content;
use crate::event_mapping::is_contextual_dev_message_content;
use crate::event_mapping::is_contextual_user_message_content;
use crate::inline_image_request_limit::parse_base64_image_data_url;
use crate::inline_image_request_limit::visit_response_item_input_images;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_protocol::models::BaseInstructions;
@@ -181,32 +183,60 @@ impl ContextManager {
/// Replace image content in the last turn if it originated from a tool output.
/// Returns true when a tool image was replaced, false otherwise.
pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool {
let Some(index) = self.items.iter().rposition(|item| {
matches!(item, ResponseItem::FunctionCallOutput { .. }) || is_user_turn_boundary(item)
}) else {
return false;
};
self.visit_last_turn_tool_outputs_mut(|output| {
let Some(content_items) = output.content_items_mut() else {
return false;
};
let mut replaced = false;
let placeholder = placeholder.to_string();
for item in content_items.iter_mut() {
if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) {
*item = FunctionCallOutputContentItem::InputText {
text: placeholder.clone(),
};
replaced = true;
}
}
replaced
})
}
match &mut self.items[index] {
ResponseItem::FunctionCallOutput { output, .. } => {
let Some(content_items) = output.content_items_mut() else {
return false;
};
let mut replaced = false;
let placeholder = placeholder.to_string();
for item in content_items.iter_mut() {
if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) {
*item = FunctionCallOutputContentItem::InputText {
text: placeholder.clone(),
};
replaced = true;
/// Replace image-bearing tool outputs from the current turn with a textual failure so the
/// model can adapt and continue in the same turn.
pub(crate) fn replace_last_turn_tool_outputs_with_failure_message(
&mut self,
message: &str,
) -> bool {
self.visit_last_turn_tool_outputs_mut(|output| {
let Some(content_items) = output.content_items() else {
return false;
};
let mut preserved_items = Vec::with_capacity(content_items.len().saturating_add(1));
let mut removed_images = false;
for item in content_items {
match item {
FunctionCallOutputContentItem::InputText { text } => {
preserved_items
.push(FunctionCallOutputContentItem::InputText { text: text.clone() });
}
FunctionCallOutputContentItem::InputImage { .. } => {
removed_images = true;
}
}
replaced
}
ResponseItem::Message { .. } => false,
_ => false,
}
if !removed_images {
return false;
}
let message = message.to_string();
output.body = if preserved_items.is_empty() {
FunctionCallOutputBody::Text(message)
} else {
preserved_items.push(FunctionCallOutputContentItem::InputText { text: message });
FunctionCallOutputBody::ContentItems(preserved_items)
};
output.success = Some(false);
true
})
}
/// Drop the last `num_turns` instruction turns from this history.
@@ -544,40 +574,6 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) ->
}
}
/// Returns the base64 payload byte length for inline image data URLs that are
/// eligible for token-estimation discounting.
///
/// 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> {
if !url
.get(.."data:".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
{
return None;
}
let comma_index = url.find(',')?;
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.
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/"))
{
return None;
}
if !has_base64_marker {
return None;
}
Some(payload)
}
fn estimate_original_image_bytes(image_url: &str) -> Option<i64> {
let key = sha1_digest(image_url.as_bytes());
ORIGINAL_IMAGE_ESTIMATE_CACHE.get_or_insert_with(key, || {
@@ -621,7 +617,7 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
let mut payload_bytes = 0i64;
let mut replacement_bytes = 0i64;
let mut accumulate = |image_url: &str, detail: Option<ImageDetail>| {
let accumulate = |image_url: &str, detail: Option<ImageDetail>| {
if let Some(payload_len) = parse_base64_image_data_url(image_url).map(str::len) {
payload_bytes =
payload_bytes.saturating_add(i64::try_from(payload_len).unwrap_or(i64::MAX));
@@ -634,32 +630,35 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
}
};
match item {
ResponseItem::Message { content, .. } => {
for content_item in content {
if let ContentItem::InputImage { image_url } = content_item {
accumulate(image_url, None);
}
}
}
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let FunctionCallOutputBody::ContentItems(items) = &output.body {
for content_item in items {
if let FunctionCallOutputContentItem::InputImage { image_url, detail } =
content_item
{
accumulate(image_url, *detail);
}
}
}
}
_ => {}
}
visit_response_item_input_images(item, accumulate);
(payload_bytes, replacement_bytes)
}
impl ContextManager {
fn visit_last_turn_tool_outputs_mut(
&mut self,
mut visitor: impl FnMut(&mut FunctionCallOutputPayload) -> bool,
) -> bool {
let start = self
.items
.iter()
.rposition(is_user_turn_boundary)
.map_or(0, |index| index.saturating_add(1));
let mut changed = false;
for item in &mut self.items[start..] {
match item {
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
changed |= visitor(output);
}
_ => {}
}
}
changed
}
}
fn is_model_generated_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } => role == "assistant",

View File

@@ -761,6 +761,142 @@ fn replace_last_turn_images_does_not_touch_user_images() {
assert_eq!(history.raw_items(), items);
}
#[test]
fn replace_last_turn_images_replaces_custom_tool_output_images() {
let items = vec![
user_input_text_msg("hi"),
ResponseItem::CustomToolCallOutput {
call_id: "call-custom".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: None,
},
]),
success: Some(true),
},
},
];
let mut history = create_history_with_items(items);
assert!(history.replace_last_turn_images("Invalid image"));
assert_eq!(
history.raw_items(),
vec![
user_input_text_msg("hi"),
ResponseItem::CustomToolCallOutput {
call_id: "call-custom".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "Invalid image".to_string(),
},
]),
success: Some(true),
},
},
]
);
}
#[test]
fn replace_last_turn_tool_outputs_with_failure_message_replaces_only_latest_turn_images() {
let items = vec![
user_input_text_msg("first"),
ResponseItem::FunctionCallOutput {
call_id: "call-old".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,OLD".to_string(),
detail: None,
},
]),
success: Some(true),
},
},
user_input_text_msg("second"),
ResponseItem::FunctionCallOutput {
call_id: "call-new".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "captured".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,NEW".to_string(),
detail: None,
},
]),
success: Some(true),
},
},
ResponseItem::CustomToolCallOutput {
call_id: "call-custom".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,CUSTOM".to_string(),
detail: Some(ImageDetail::Original),
},
]),
success: Some(true),
},
},
];
let mut history = create_history_with_items(items);
let message = "Tool image output omitted because total inline image data in this request is 99 bytes, which exceeds the 10 byte limit for a single Responses API request. Retry with fewer images, smaller images, lower detail, or JPEG compression.";
assert!(history.replace_last_turn_tool_outputs_with_failure_message(message));
assert_eq!(
history.raw_items(),
vec![
user_input_text_msg("first"),
ResponseItem::FunctionCallOutput {
call_id: "call-old".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,OLD".to_string(),
detail: None,
},
]),
success: Some(true),
},
},
user_input_text_msg("second"),
ResponseItem::FunctionCallOutput {
call_id: "call-new".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "captured".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: message.to_string(),
},
]),
success: Some(false),
},
},
ResponseItem::CustomToolCallOutput {
call_id: "call-custom".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message.to_string()),
success: Some(false),
},
},
]
);
}
#[test]
fn remove_first_item_handles_local_shell_pair() {
let items = vec![

View File

@@ -27,6 +27,100 @@ pub type Result<T> = std::result::Result<T, CodexErr>;
/// Limit UI error messages to a reasonable size while keeping useful context.
const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024; // 2 KiB
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineImageRequestLimitExceededError {
total_bytes: Option<usize>,
limit_bytes: Option<usize>,
total_images: Option<usize>,
limit_images: Option<usize>,
}
impl InlineImageRequestLimitExceededError {
pub fn local_preflight_bytes(total_bytes: usize, limit_bytes: usize) -> Self {
Self {
total_bytes: Some(total_bytes),
limit_bytes: Some(limit_bytes),
total_images: None,
limit_images: None,
}
}
pub fn local_preflight_images(total_images: usize, limit_images: usize) -> Self {
Self {
total_bytes: None,
limit_bytes: None,
total_images: Some(total_images),
limit_images: Some(limit_images),
}
}
pub fn local_preflight_bytes_and_images(
total_bytes: usize,
limit_bytes: usize,
total_images: usize,
limit_images: usize,
) -> Self {
Self {
total_bytes: Some(total_bytes),
limit_bytes: Some(limit_bytes),
total_images: Some(total_images),
limit_images: Some(limit_images),
}
}
pub fn tool_output_recovery_message(&self) -> String {
format!(
"Tool image output omitted because {} Retry with fewer images, smaller images, lower detail, or JPEG compression.",
self.message_body()
)
}
fn message_body(&self) -> String {
match (
self.total_images,
self.limit_images,
self.total_bytes,
self.limit_bytes,
) {
(Some(total_images), Some(limit_images), Some(total_bytes), Some(limit_bytes)) => {
format!(
"this request contains {total_images} images, which exceeds the {limit_images} image limit, and total inline image data is {total_bytes} bytes, which exceeds the {limit_bytes} byte limit for a single Responses API request."
)
}
(Some(total_images), Some(limit_images), _, _) => format!(
"this request contains {total_images} images, which exceeds the {limit_images} image limit for a single Responses API request."
),
(None, None, Some(total_bytes), Some(limit_bytes)) => format!(
"total inline image data in this request is {total_bytes} bytes, which exceeds the {limit_bytes} byte limit for a single Responses API request."
),
(None, None, None, Some(limit_bytes)) => format!(
"total inline image data in this request exceeds the {limit_bytes} byte limit for a single Responses API request."
),
(None, None, Some(total_bytes), None) => format!(
"total inline image data in this request is {total_bytes} bytes, which exceeds the single-request limit for the Responses API."
),
_ => {
"total inline image data in this request exceeds the single-request limit for the Responses API.".to_string()
}
}
}
}
impl std::fmt::Display for InlineImageRequestLimitExceededError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = self.message_body();
let mut chars = message.chars();
let message = match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
};
write!(
f,
"{message} Use fewer images, smaller images, lower detail, or JPEG compression and try again."
)
}
}
#[derive(Error, Debug)]
pub enum SandboxErr {
/// Error from sandbox execution
@@ -116,6 +210,9 @@ pub enum CodexErr {
#[error("Image poisoning")]
InvalidImageRequest(),
#[error("{0}")]
InlineImageRequestLimitExceeded(InlineImageRequestLimitExceededError),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
@@ -203,6 +300,7 @@ impl CodexErr {
| CodexErr::UsageNotIncluded
| CodexErr::QuotaExceeded
| CodexErr::InvalidImageRequest()
| CodexErr::InlineImageRequestLimitExceeded(_)
| CodexErr::InvalidRequest(_)
| CodexErr::RefreshTokenFailed(_)
| CodexErr::UnsupportedOperation(_)
@@ -584,6 +682,7 @@ impl CodexErr {
| CodexErr::InternalServerError
| CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError,
CodexErr::UnsupportedOperation(_)
| CodexErr::InlineImageRequestLimitExceeded(_)
| CodexErr::ThreadNotFound(_)
| CodexErr::AgentLimitReached { .. } => CodexErrorInfo::BadRequest,
CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError,

View File

@@ -70,6 +70,30 @@ fn server_overloaded_maps_to_protocol() {
);
}
#[test]
fn inline_image_request_limit_maps_to_bad_request() {
let err = CodexErr::InlineImageRequestLimitExceeded(
InlineImageRequestLimitExceededError::local_preflight_bytes(123, 45),
);
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BadRequest);
assert_eq!(
err.to_string(),
"Total inline image data in this request is 123 bytes, which exceeds the 45 byte limit for a single Responses API request. Use fewer images, smaller images, lower detail, or JPEG compression and try again."
);
}
#[test]
fn inline_image_request_count_limit_maps_to_bad_request() {
let err = CodexErr::InlineImageRequestLimitExceeded(
InlineImageRequestLimitExceededError::local_preflight_images(1_501, 1_500),
);
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BadRequest);
assert_eq!(
err.to_string(),
"This request contains 1501 images, which exceeds the 1500 image limit for a single Responses API request. Use fewer images, smaller images, lower detail, or JPEG compression and try again."
);
}
#[test]
fn sandbox_denied_uses_aggregated_output_when_stderr_empty() {
let output = ExecToolCallOutput {

View File

@@ -0,0 +1,141 @@
use crate::error::InlineImageRequestLimitExceededError;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
pub(crate) const DEFAULT_INLINE_IMAGE_REQUEST_LIMIT_BYTES: i64 = 512 * 1024 * 1024;
pub(crate) const DEFAULT_INLINE_IMAGE_REQUEST_LIMIT_IMAGE_COUNT: i64 = 1_500;
pub(crate) fn inline_image_request_limit_bytes(model_info: &ModelInfo) -> usize {
model_info
.inline_image_request_limit_bytes
.filter(|limit| *limit > 0)
.and_then(|limit| usize::try_from(limit).ok())
.unwrap_or(usize::try_from(DEFAULT_INLINE_IMAGE_REQUEST_LIMIT_BYTES).unwrap_or(usize::MAX))
}
pub(crate) fn inline_image_request_limit_image_count(model_info: &ModelInfo) -> usize {
model_info
.inline_image_request_limit_image_count
.filter(|limit| *limit > 0)
.and_then(|limit| usize::try_from(limit).ok())
.unwrap_or(
usize::try_from(DEFAULT_INLINE_IMAGE_REQUEST_LIMIT_IMAGE_COUNT).unwrap_or(usize::MAX),
)
}
pub(crate) fn visit_response_item_input_images(
item: &ResponseItem,
mut visitor: impl FnMut(&str, Option<ImageDetail>),
) {
match item {
ResponseItem::Message { content, .. } => {
for content_item in content {
if let ContentItem::InputImage { image_url } = content_item {
visitor(image_url, None);
}
}
}
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let FunctionCallOutputBody::ContentItems(items) = &output.body {
for content_item in items {
if let FunctionCallOutputContentItem::InputImage { image_url, detail } =
content_item
{
visitor(image_url, *detail);
}
}
}
}
_ => {}
}
}
pub(crate) fn is_inline_data_url(url: &str) -> bool {
url.get(.."data:".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
}
pub(crate) fn parse_base64_image_data_url(url: &str) -> Option<&str> {
if !is_inline_data_url(url) {
return None;
}
let comma_index = url.find(',')?;
let metadata = &url[..comma_index];
let payload = &url[comma_index + 1..];
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/"))
{
return None;
}
if !has_base64_marker {
return None;
}
Some(payload)
}
pub(crate) fn total_inline_image_request_bytes(items: &[ResponseItem]) -> usize {
let mut total = 0usize;
for item in items {
visit_response_item_input_images(item, |image_url, _detail| {
if is_inline_data_url(image_url) {
total = total.saturating_add(image_url.len());
}
});
}
total
}
pub(crate) fn total_image_request_count(items: &[ResponseItem]) -> usize {
let mut total = 0usize;
for item in items {
visit_response_item_input_images(item, |_image_url, _detail| {
total = total.saturating_add(1);
});
}
total
}
pub(crate) fn inline_image_request_limit_error(
items: &[ResponseItem],
model_info: &ModelInfo,
) -> Option<InlineImageRequestLimitExceededError> {
let total_inline_image_bytes = total_inline_image_request_bytes(items);
let limit_bytes = inline_image_request_limit_bytes(model_info);
let total_images = total_image_request_count(items);
let limit_images = inline_image_request_limit_image_count(model_info);
let exceeds_bytes = total_inline_image_bytes > limit_bytes;
let exceeds_images = total_images > limit_images;
if !exceeds_bytes && !exceeds_images {
return None;
}
Some(if exceeds_bytes && exceeds_images {
InlineImageRequestLimitExceededError::local_preflight_bytes_and_images(
total_inline_image_bytes,
limit_bytes,
total_images,
limit_images,
)
} else if exceeds_bytes {
InlineImageRequestLimitExceededError::local_preflight_bytes(
total_inline_image_bytes,
limit_bytes,
)
} else {
InlineImageRequestLimitExceededError::local_preflight_images(total_images, limit_images)
})
}
#[cfg(test)]
#[path = "inline_image_request_limit_tests.rs"]
mod tests;

View File

@@ -0,0 +1,145 @@
use super::*;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
#[test]
fn total_inline_image_request_bytes_counts_across_messages_and_tool_outputs() {
let first = "data:image/png;base64,AAA".to_string();
let second = "data:image/jpeg;base64,BBBB".to_string();
let third = "data:image/gif;base64,CCCCC".to_string();
let items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: first.clone(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: second.clone(),
detail: Some(ImageDetail::Original),
},
]),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
name: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: third.clone(),
detail: None,
},
]),
},
];
assert_eq!(
total_inline_image_request_bytes(&items),
first.len() + second.len() + third.len()
);
}
#[test]
fn total_inline_image_request_bytes_ignores_remote_and_file_backed_images() {
let items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "file:///tmp/image.png".to_string(),
detail: None,
},
]),
},
];
assert_eq!(total_inline_image_request_bytes(&items), 0);
}
#[test]
fn total_inline_image_request_bytes_uses_utf8_byte_length() {
let image_url = "data:image/svg+xml,<svg>😀</svg>".to_string();
let items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: image_url.clone(),
}],
end_turn: None,
phase: None,
}];
assert_eq!(total_inline_image_request_bytes(&items), image_url.len());
}
#[test]
fn total_image_request_count_counts_all_request_images() {
let items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: "file:///tmp/first.png".to_string(),
},
ContentItem::InputImage {
image_url: "https://example.com/second.png".to_string(),
},
],
end_turn: None,
phase: None,
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,THIRD".to_string(),
detail: None,
},
]),
},
];
assert_eq!(total_image_request_count(&items), 3);
}
#[test]
fn inline_image_request_limit_error_applies_to_user_images() {
let mut model_info = crate::models_manager::model_info::model_info_from_slug("test-model");
model_info.inline_image_request_limit_image_count = Some(1);
let items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: "https://example.com/one.png".to_string(),
},
ContentItem::InputImage {
image_url: "https://example.com/two.png".to_string(),
},
],
end_turn: None,
phase: None,
}];
assert_eq!(
inline_image_request_limit_error(&items, &model_info)
.expect("count overflow should produce an error")
.to_string(),
"This request contains 2 images, which exceeds the 1 image limit for a single Responses API request. Use fewer images, smaller images, lower detail, or JPEG compression and try again."
);
}

View File

@@ -43,6 +43,7 @@ mod flags;
mod git_info_tests;
mod guardian;
mod hook_runtime;
mod inline_image_request_limit;
pub mod instructions;
pub mod landlock;
pub mod mcp;

View File

@@ -83,6 +83,8 @@ pub(crate) fn model_info_from_slug(slug: &str) -> 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,

View File

@@ -95,6 +95,8 @@ fn test_model_info(
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,
@@ -911,6 +913,8 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
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(large_context_window),
auto_compact_token_limit: None,
effective_context_window_percent,

View File

@@ -347,6 +347,8 @@ fn test_remote_model(slug: &str, 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,

View File

@@ -664,6 +664,8 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
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(128_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
@@ -780,6 +782,8 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
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(128_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,

View File

@@ -113,6 +113,47 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_get_model_info_preserves_inline_image_request_limits() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = MockServer::start().await;
let mut remote_model = test_remote_model("gpt-5.3-codex", ModelVisibility::List, 1_000);
remote_model.inline_image_request_limit_bytes = Some(8);
remote_model.inline_image_request_limit_image_count = Some(9);
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let codex_home = TempDir::new()?;
let config = load_default_config_for_test(&codex_home).await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers(/* openai_base_url */ None)["openai"].clone()
};
let manager = codex_core::test_support::models_manager_with_provider(
codex_home.path().to_path_buf(),
codex_core::test_support::auth_manager_from_auth(auth),
provider,
);
manager.list_models(RefreshStrategy::OnlineIfUncached).await;
let model_info = manager.get_model_info("gpt-5.3-codex", &config).await;
assert_eq!(model_info.inline_image_request_limit_bytes, Some(8));
assert_eq!(model_info.inline_image_request_limit_image_count, Some(9));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -307,6 +348,8 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
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,
@@ -551,6 +594,8 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
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,
@@ -1031,6 +1076,8 @@ fn test_remote_model_with_policy(
truncation_policy,
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,

View File

@@ -418,6 +418,8 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
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,

View File

@@ -80,6 +80,8 @@ fn test_model_info(
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,

View File

@@ -646,6 +646,146 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_over_limit_replaces_output_with_text_and_continues() -> anyhow::Result<()>
{
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let model_slug = "image-limit-model";
let model = ModelInfo {
slug: model_slug.to_string(),
display_name: "Image limit model".to_string(),
description: Some("Remote model with a tiny inline image cap".to_string()),
default_reasoning_level: Some(ReasoningEffort::Medium),
supported_reasoning_levels: vec![ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities: vec![InputModality::Text, InputModality::Image],
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
supports_reasoning_summaries: false,
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
web_search_tool_type: Default::default(),
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
inline_image_request_limit_bytes: Some(8),
inline_image_request_limit_image_count: None,
context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
};
let model_catalog = ModelsResponse {
models: vec![model],
};
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model = Some(model_slug.to_string());
config.model_catalog = Some(model_catalog);
});
let test = builder.build_remote_aware(&server).await?;
let TestCodex {
codex,
config,
session_configured,
..
} = &test;
let rel_path = "assets/over-limit.png";
write_workspace_png(&test, rel_path, 20, 20, [0u8, 128, 255, 255]).await?;
let call_id = "view-image-over-limit";
let arguments = serde_json::json!({ "path": rel_path }).to_string();
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "view_image", &arguments),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "used the fallback text"),
ev_completed("resp-2"),
]),
)
.await;
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "view the image".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: config.cwd.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_configured.model.clone(),
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
approvals_reviewer: None,
})
.await?;
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = second_mock.single_request();
let body = request.body_json();
let call_output = request.call_output(call_id, "function_call_output");
let output_text = request
.function_call_output_text(call_id)
.unwrap_or_else(|| {
panic!(
"expected string tool output after inline-image recovery, got {}",
serde_json::to_string_pretty(&call_output).expect("serialize call output")
)
});
assert!(
output_text.contains(
"Tool image output omitted because total inline image data in this request is"
),
"unexpected tool output: {output_text}"
);
assert!(
output_text.contains("8 byte limit"),
"expected limit to be included in tool output: {output_text}"
);
assert!(
!body.to_string().contains("\"type\":\"input_image\""),
"over-limit request should not include inline images: {body}"
);
assert!(
find_image_message(&body).is_none(),
"over-limit tool output should not include input_image items"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -1362,6 +1502,8 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
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,

View File

@@ -1,5 +1,6 @@
use crate::TelemetryAuthMode;
use crate::ToolDecisionSource;
use crate::WellKnownApiRequestError;
use crate::events::shared::log_and_trace_event;
use crate::events::shared::log_event;
use crate::events::shared::trace_event;
@@ -9,6 +10,7 @@ use crate::metrics::MetricsError;
use crate::metrics::Result as MetricsResult;
use crate::metrics::names::API_CALL_COUNT_METRIC;
use crate::metrics::names::API_CALL_DURATION_METRIC;
use crate::metrics::names::INLINE_IMAGE_REQUEST_LIMIT_METRIC;
use crate::metrics::names::PROFILE_USAGE_METRIC;
use crate::metrics::names::RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC;
use crate::metrics::names::RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC;
@@ -62,6 +64,63 @@ const RESPONSES_API_ENGINE_IAPI_TTFT_FIELD: &str = "engine_iapi_ttft_total_ms";
const RESPONSES_API_ENGINE_SERVICE_TTFT_FIELD: &str = "engine_service_ttft_total_ms";
const RESPONSES_API_ENGINE_IAPI_TBT_FIELD: &str = "engine_iapi_tbt_across_engine_calls_ms";
const RESPONSES_API_ENGINE_SERVICE_TBT_FIELD: &str = "engine_service_tbt_across_engine_calls_ms";
const INLINE_IMAGE_REQUEST_LIMIT_OUTCOME_UPSTREAM_REJECTED: &str = "upstream_rejected";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct InlineImageRequestLimitObservation {
bytes_exceeded: bool,
images_exceeded: bool,
}
fn bool_metric_tag(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn matches_inline_image_byte_limit_message(message: &str) -> bool {
message
.strip_prefix("Total image data in 'input' exceeds the ")
.and_then(|rest| rest.split_once(" byte limit"))
.is_some_and(|(limit, _)| !limit.is_empty() && limit.chars().all(|c| c.is_ascii_digit()))
}
fn inline_image_request_limit_observation_from_error_fields(
message: Option<&str>,
code: Option<&str>,
error_type: Option<&str>,
) -> Option<InlineImageRequestLimitObservation> {
if matches!(
(code, error_type),
(Some("max_images_per_request"), _) | (_, Some("max_images_per_request"))
) {
return Some(InlineImageRequestLimitObservation {
bytes_exceeded: false,
images_exceeded: true,
});
}
if message.is_some_and(matches_inline_image_byte_limit_message) {
return Some(InlineImageRequestLimitObservation {
bytes_exceeded: true,
images_exceeded: false,
});
}
None
}
fn inline_image_request_limit_observation_from_event_json(
event_json: &serde_json::Value,
) -> Option<InlineImageRequestLimitObservation> {
let error = event_json
.get("response")
.and_then(|response| response.get("error"))
.or_else(|| event_json.get("error"))?;
inline_image_request_limit_observation_from_error_fields(
error.get("message").and_then(serde_json::Value::as_str),
error.get("code").and_then(serde_json::Value::as_str),
error.get("type").and_then(serde_json::Value::as_str),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthEnvTelemetryMetadata {
@@ -98,6 +157,30 @@ pub struct SessionTelemetry {
}
impl SessionTelemetry {
fn record_upstream_inline_image_request_limit_observation(
&self,
observation: InlineImageRequestLimitObservation,
) {
self.counter(
INLINE_IMAGE_REQUEST_LIMIT_METRIC,
/*inc*/ 1,
&[
(
"outcome",
INLINE_IMAGE_REQUEST_LIMIT_OUTCOME_UPSTREAM_REJECTED,
),
(
"bytes_exceeded",
bool_metric_tag(observation.bytes_exceeded),
),
(
"images_exceeded",
bool_metric_tag(observation.images_exceeded),
),
],
);
}
pub fn with_auth_env(mut self, auth_env: AuthEnvTelemetryMetadata) -> Self {
self.metadata.auth_env = auth_env;
self
@@ -382,6 +465,7 @@ impl SessionTelemetry {
/*cf_ray*/ None,
/*auth_error*/ None,
/*auth_error_code*/ None,
WellKnownApiRequestError::None,
);
response
@@ -404,21 +488,31 @@ impl SessionTelemetry {
cf_ray: Option<&str>,
auth_error: Option<&str>,
auth_error_code: Option<&str>,
well_known_error: WellKnownApiRequestError,
) {
let success = status.is_some_and(|code| (200..=299).contains(&code)) && error.is_none();
let success_str = if success { "true" } else { "false" };
let status_str = status
.map(|code| code.to_string())
.unwrap_or_else(|| "none".to_string());
let well_known_error_str = well_known_error.as_str();
self.counter(
API_CALL_COUNT_METRIC,
/*inc*/ 1,
&[("status", status_str.as_str()), ("success", success_str)],
&[
("status", status_str.as_str()),
("success", success_str),
("well_known_error", well_known_error_str),
],
);
self.record_duration(
API_CALL_DURATION_METRIC,
duration,
&[("status", status_str.as_str()), ("success", success_str)],
&[
("status", status_str.as_str()),
("success", success_str),
("well_known_error", well_known_error_str),
],
);
log_and_trace_event!(
self,
@@ -444,6 +538,7 @@ impl SessionTelemetry {
auth.cf_ray = cf_ray,
auth.error = auth_error,
auth.error_code = auth_error_code,
well_known_error = well_known_error_str,
},
log: {},
trace: {},
@@ -603,6 +698,13 @@ impl SessionTelemetry {
self.record_responses_websocket_timing_metrics(&value);
}
if kind.as_deref() == Some("response.failed") {
if let Some(observation) =
inline_image_request_limit_observation_from_event_json(&value)
{
self.record_upstream_inline_image_request_limit_observation(
observation,
);
}
success = false;
error_message = value
.get("response")
@@ -683,6 +785,13 @@ impl SessionTelemetry {
} else {
match serde_json::from_str::<serde_json::Value>(&sse.data) {
Ok(error) if sse.event == "response.failed" => {
if let Some(observation) =
inline_image_request_limit_observation_from_event_json(&error)
{
self.record_upstream_inline_image_request_limit_observation(
observation,
);
}
self.sse_event_failed(Some(&sse.event), duration, &error);
}
Ok(content) if sse.event == "response.output_item.done" => {
@@ -1083,6 +1192,10 @@ impl SessionTelemetry {
}
}
#[cfg(test)]
#[path = "session_telemetry_tests.rs"]
mod tests;
fn duration_from_ms_value(value: Option<&serde_json::Value>) -> Option<Duration> {
let value = value?;
let ms = value

View File

@@ -0,0 +1,147 @@
use super::AuthEnvTelemetryMetadata;
use super::SessionTelemetry;
use crate::TelemetryAuthMode;
use crate::metrics::MetricsClient;
use crate::metrics::MetricsConfig;
use crate::metrics::names::INLINE_IMAGE_REQUEST_LIMIT_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use eventsource_stream::Event as StreamEvent;
use opentelemetry::KeyValue;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
use opentelemetry_sdk::metrics::data::Metric;
use opentelemetry_sdk::metrics::data::MetricData;
use opentelemetry_sdk::metrics::data::ResourceMetrics;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message;
fn auth_env_metadata() -> AuthEnvTelemetryMetadata {
AuthEnvTelemetryMetadata {
openai_api_key_env_present: true,
codex_api_key_env_present: false,
codex_api_key_env_enabled: true,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(true),
refresh_token_url_override_present: true,
}
}
fn test_session_telemetry_with_metrics() -> SessionTelemetry {
let exporter = InMemoryMetricExporter::default();
let metrics = MetricsClient::new(
MetricsConfig::in_memory("test", "codex-otel", env!("CARGO_PKG_VERSION"), exporter)
.with_runtime_reader(),
)
.expect("in-memory metrics client");
SessionTelemetry::new(
ThreadId::new(),
"gpt-test",
"gpt-test",
Some("account-id".to_string()),
/*account_email*/ None,
Some(TelemetryAuthMode::ApiKey),
"test-originator".to_string(),
/*log_user_prompts*/ false,
"test-terminal".to_string(),
SessionSource::Cli,
)
.with_auth_env(auth_env_metadata())
.with_metrics_without_metadata_tags(metrics)
}
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
for scope_metrics in resource_metrics.scope_metrics() {
for metric in scope_metrics.metrics() {
if metric.name() == name {
return metric;
}
}
}
panic!("metric {name} missing");
}
fn attributes_to_map<'a>(
attributes: impl Iterator<Item = &'a KeyValue>,
) -> BTreeMap<String, String> {
attributes
.map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string()))
.collect()
}
fn metric_point(resource_metrics: &ResourceMetrics, name: &str) -> (BTreeMap<String, String>, u64) {
let metric = find_metric(resource_metrics, name);
match metric.data() {
AggregatedMetrics::U64(data) => match data {
MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
let point = points[0];
(attributes_to_map(point.attributes()), point.value())
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
}
}
#[test]
fn log_sse_event_records_inline_image_limit_metric_for_response_failed() {
let session_telemetry = test_session_telemetry_with_metrics();
let sse_response: std::result::Result<
Option<std::result::Result<StreamEvent, eventsource_stream::EventStreamError<&str>>>,
tokio::time::error::Elapsed,
> = Ok(Some(Ok(StreamEvent {
event: "response.failed".to_string(),
data: r#"{"type":"response.failed","response":{"error":{"code":"max_images_per_request","message":"Invalid request."}}}"#
.to_string(),
id: String::new(),
retry: None,
})));
session_telemetry.log_sse_event(&sse_response, Duration::from_millis(25));
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
let (attrs, value) = metric_point(&snapshot, INLINE_IMAGE_REQUEST_LIMIT_METRIC);
assert_eq!(value, 1);
assert_eq!(
attrs,
BTreeMap::from([
("bytes_exceeded".to_string(), "false".to_string()),
("images_exceeded".to_string(), "true".to_string()),
("outcome".to_string(), "upstream_rejected".to_string()),
])
);
}
#[test]
fn record_websocket_event_records_inline_image_limit_metric_for_response_failed() {
let session_telemetry = test_session_telemetry_with_metrics();
let websocket_response: std::result::Result<
Option<std::result::Result<Message, tokio_tungstenite::tungstenite::Error>>,
codex_api::ApiError,
> = Ok(Some(Ok(Message::Text(
r#"{"type":"response.failed","response":{"error":{"code":"max_images_per_request","message":"Invalid request."}}}"#
.into(),
))));
session_telemetry.record_websocket_event(&websocket_response, Duration::from_millis(25));
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
let (attrs, value) = metric_point(&snapshot, INLINE_IMAGE_REQUEST_LIMIT_METRIC);
assert_eq!(value, 1);
assert_eq!(
attrs,
BTreeMap::from([
("bytes_exceeded".to_string(), "false".to_string()),
("images_exceeded".to_string(), "true".to_string()),
("outcome".to_string(), "upstream_rejected".to_string()),
])
);
}

View File

@@ -43,6 +43,24 @@ pub enum TelemetryAuthMode {
Chatgpt,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WellKnownApiRequestError {
#[default]
None,
TooManyImages,
RequestSizeExceeded,
}
impl WellKnownApiRequestError {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "None",
Self::TooManyImages => "TooManyImages",
Self::RequestSizeExceeded => "RequestSizeExceeded",
}
}
}
impl From<codex_app_server_protocol::AuthMode> for TelemetryAuthMode {
fn from(mode: codex_app_server_protocol::AuthMode) -> Self {
match mode {

View File

@@ -9,6 +9,7 @@ pub const WEBSOCKET_REQUEST_COUNT_METRIC: &str = "codex.websocket.request";
pub const WEBSOCKET_REQUEST_DURATION_METRIC: &str = "codex.websocket.request.duration_ms";
pub const WEBSOCKET_EVENT_COUNT_METRIC: &str = "codex.websocket.event";
pub const WEBSOCKET_EVENT_DURATION_METRIC: &str = "codex.websocket.event.duration_ms";
pub const INLINE_IMAGE_REQUEST_LIMIT_METRIC: &str = "codex.responses.inline_image_limit";
pub const RESPONSES_API_OVERHEAD_DURATION_METRIC: &str = "codex.responses_api_overhead.duration_ms";
pub const RESPONSES_API_INFERENCE_TIME_DURATION_METRIC: &str =
"codex.responses_api_inference_time.duration_ms";

View File

@@ -2,6 +2,7 @@ use codex_otel::AuthEnvTelemetryMetadata;
use codex_otel::OtelProvider;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::WellKnownApiRequestError;
use opentelemetry::KeyValue;
use opentelemetry::logs::AnyValue;
use opentelemetry::trace::TracerProvider as _;
@@ -527,6 +528,7 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
Some("ray-401"),
Some("missing_authorization_header"),
Some("token_expired"),
WellKnownApiRequestError::None,
);
});
@@ -584,6 +586,12 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
request_log_attrs.get("endpoint").map(String::as_str),
Some("/responses")
);
assert_eq!(
request_log_attrs
.get("well_known_error")
.map(String::as_str),
Some("None")
);
assert_eq!(
request_log_attrs.get("auth.error").map(String::as_str),
Some("missing_authorization_header")
@@ -636,6 +644,12 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
request_trace_attrs.get("endpoint").map(String::as_str),
Some("/responses")
);
assert_eq!(
request_trace_attrs
.get("well_known_error")
.map(String::as_str),
Some("None")
);
assert_eq!(
request_trace_attrs
.get("auth.env_openai_api_key_present")

View File

@@ -2,6 +2,7 @@ use codex_otel::RuntimeMetricTotals;
use codex_otel::RuntimeMetricsSummary;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::WellKnownApiRequestError;
use codex_otel::metrics::MetricsClient;
use codex_otel::metrics::MetricsConfig;
use codex_otel::metrics::Result;
@@ -62,6 +63,7 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<(
/*cf_ray*/ None,
/*auth_error*/ None,
/*auth_error_code*/ None,
WellKnownApiRequestError::None,
);
manager.record_websocket_request(
Duration::from_millis(400),

View File

@@ -270,6 +270,10 @@ pub struct ModelInfo {
#[serde(default)]
pub supports_image_detail_original: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline_image_request_limit_bytes: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline_image_request_limit_image_count: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<i64>,
/// Token threshold for automatic compaction. When omitted, core derives it
/// from `context_window` (90%). When provided, core clamps it to 90% of the
@@ -540,6 +544,8 @@ mod tests {
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: None,
auto_compact_token_limit: None,
effective_context_window_percent: 95,
@@ -752,6 +758,8 @@ mod tests {
},
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"inline_image_request_limit_bytes": null,
"inline_image_request_limit_image_count": null,
"context_window": null,
"auto_compact_token_limit": null,
"effective_context_window_percent": 95,
@@ -762,6 +770,8 @@ mod tests {
assert_eq!(model.availability_nux, None);
assert!(!model.supports_image_detail_original);
assert_eq!(model.inline_image_request_limit_bytes, None);
assert_eq!(model.inline_image_request_limit_image_count, None);
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
assert!(!model.supports_search_tool);
}