Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions crates/tui/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,32 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option<Value> {
}
Some(value)
}
ContentBlock::ImageUrl { image_url } => Some(json!({
"type": "image",
"source": { "type": "url", "url": image_url.url },
})),
ContentBlock::ImageUrl { image_url } => {
// Anthropic Messages does not accept a `url` source carrying a
// `data:` URL — inline images (materialized local images) must be
// split into the native base64 source shape. Remote URLs keep the
// existing passthrough.
Some(
match crate::vision::image_input::split_data_url(&image_url.url) {
Some((media_type, data)) => json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
}),
None => json!({
"type": "image",
"source": { "type": "url", "url": image_url.url },
}),
},
)
}
// Local image references are materialized into `ImageUrl` before a
// request reaches the client; like server-tool blocks they have no
// wire equivalent of their own.
ContentBlock::LocalImage { .. } => None,
// Server-tool block types are DeepSeek/internal concepts with no
// Anthropic client-side wire equivalent.
ContentBlock::ServerToolUse { .. }
Expand Down Expand Up @@ -1141,6 +1163,47 @@ mod tests {
assert_eq!(message, "upstream blew up");
}

#[test]
fn image_block_with_data_url_uses_native_base64_source() {
// Contract: Anthropic Messages rejects a `url` source carrying a
// `data:` URL — inline images must be split into the native base64
// source shape.
let block = ContentBlock::ImageUrl {
image_url: crate::models::ImageUrlContent {
url: "data:image/jpeg;base64,/9j/4AAQ".to_string(),
},
};

let value = content_block_to_anthropic(&block).expect("image block converts");

assert_eq!(value["type"], json!("image"));
assert_eq!(
value["source"],
json!({
"type": "base64",
"media_type": "image/jpeg",
"data": "/9j/4AAQ",
}),
"data: URL must be split into Anthropic's native base64 source; got {value}"
);
}

#[test]
fn image_block_with_remote_url_keeps_url_source() {
let block = ContentBlock::ImageUrl {
image_url: crate::models::ImageUrlContent {
url: "https://example.com/cat.png".to_string(),
},
};

let value = content_block_to_anthropic(&block).expect("image block converts");

assert_eq!(
value["source"],
json!({ "type": "url", "url": "https://example.com/cat.png" })
);
}

#[test]
fn messages_url_tolerates_v1_suffix() {
assert_eq!(
Expand Down
62 changes: 62 additions & 0 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,10 @@ fn build_chat_messages_with_reasoning(
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => {}
// Local image references are materialized into `ImageUrl`
// before a request reaches the client; they have no wire
// equivalent of their own.
ContentBlock::LocalImage { .. } => {}
}
}

Expand Down Expand Up @@ -3928,6 +3932,64 @@ mod stream_decoder_tests {
assert_eq!(built[0]["content"], "internal runtime event");
}

#[test]
fn request_builder_emits_openai_image_url_parts_for_inline_images() {
// Contract: OpenAI Chat / vLLM-compatible wire shape keeps inline
// images as `image_url` parts carrying the data URL verbatim.
let data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
let messages = vec![Message {
role: "user".to_string(),
content: vec![
ContentBlock::Text {
text: "what is in this picture?".to_string(),
cache_control: None,
},
ContentBlock::ImageUrl {
image_url: crate::models::ImageUrlContent {
url: data_url.to_string(),
},
},
],
}];

let built = build_chat_messages(None, &messages, "deepseek-v4-flash");

assert_eq!(built.len(), 1);
assert_eq!(built[0]["role"], "user");
assert_eq!(
built[0]["content"],
json!([
{ "type": "text", "text": "what is in this picture?" },
{ "type": "image_url", "image_url": { "url": data_url } },
]),
"chat wire must carry image_url parts with the data URL; got {}",
built[0]["content"]
);
}

#[test]
fn request_builder_emits_pure_image_user_message_without_text_part() {
let data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::ImageUrl {
image_url: crate::models::ImageUrlContent {
url: data_url.to_string(),
},
}],
}];

let built = build_chat_messages(None, &messages, "deepseek-v4-flash");

assert_eq!(built.len(), 1);
assert_eq!(
built[0]["content"],
json!([{ "type": "image_url", "image_url": { "url": data_url } }]),
"pure-image message must not fabricate a text part; got {}",
built[0]["content"]
);
}

fn tool_use_message(id: &str, name: &str, input: Value) -> Message {
Message {
role: "assistant".to_string(),
Expand Down
41 changes: 41 additions & 0 deletions crates/tui/src/client/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,10 @@ fn convert_messages_to_responses_input(request: &MessageRequest) -> Vec<Value> {
"image_url": image_url.url,
}));
}
// Local image references are materialized into
// `ImageUrl` before a request reaches the client;
// they have no wire equivalent of their own.
ContentBlock::LocalImage { .. } => {}
ContentBlock::ToolResult {
tool_use_id,
content,
Expand Down Expand Up @@ -789,6 +793,43 @@ mod tests {
}
}

#[test]
fn responses_input_emits_input_image_for_inline_images() {
// Contract: OpenAI Responses wire shape carries inline images as
// `input_image` items with the data URL in `image_url`.
let data_url = "data:image/webp;base64,UklGRhIAAABXRUJQ";
let mut request = minimal_responses_request();
request.messages = vec![Message {
role: "user".to_string(),
content: vec![
ContentBlock::Text {
text: "describe".to_string(),
cache_control: None,
},
ContentBlock::ImageUrl {
image_url: crate::models::ImageUrlContent {
url: data_url.to_string(),
},
},
],
}];

let items = convert_messages_to_responses_input(&request);

assert_eq!(
items,
vec![json!({
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "describe" },
{ "type": "input_image", "image_url": data_url },
],
})],
"responses wire must carry input_image items; got {items:?}"
);
}

fn test_codex_config(server: &MockServer) -> Config {
Config {
provider: Some("openai-codex".to_string()),
Expand Down
11 changes: 7 additions & 4 deletions crates/tui/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ fn message_text(msg: &Message) -> String {
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => {}
| ContentBlock::ImageUrl { .. }
| ContentBlock::LocalImage { .. } => {}
}
}
text
Expand All @@ -342,7 +343,8 @@ fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Ve
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => Vec::new(),
| ContentBlock::ImageUrl { .. }
| ContentBlock::LocalImage { .. } => Vec::new(),
};
paths.extend(candidates);
}
Expand Down Expand Up @@ -627,7 +629,7 @@ fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usi
// sessions. Use a conservative flat per-image estimate (vision
// tiles are typically ~1k tokens); erring high compacts slightly
// early rather than overflowing.
ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
ContentBlock::ImageUrl { .. } | ContentBlock::LocalImage { .. } => IMAGE_TOKEN_ESTIMATE,
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => 0,
Expand Down Expand Up @@ -1497,7 +1499,8 @@ fn build_formatted_summary_request(
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
| ContentBlock::ImageUrl { .. } => {}
| ContentBlock::ImageUrl { .. }
| ContentBlock::LocalImage { .. } => {}
}
}
}
Expand Down
17 changes: 15 additions & 2 deletions crates/tui/src/context_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,14 @@ fn add_message_entries(builder: &mut ReportBuilder, messages: &[Message]) {

for (index, message) in messages.iter().enumerate() {
for block in &message.content {
let tokens = estimate_text_tokens_conservative(&content_block_text(block));
// Images never expand to their URL/payload here: a flat per-image
// estimate keeps the report honest without cloning Base64.
let tokens = match block {
ContentBlock::ImageUrl { .. } | ContentBlock::LocalImage { .. } => {
IMAGE_TOKEN_ESTIMATE
}
_ => estimate_text_tokens_conservative(&content_block_text(block)),
};
match block {
ContentBlock::ToolResult { .. }
| ContentBlock::ToolSearchToolResult { .. }
Expand Down Expand Up @@ -687,10 +694,16 @@ fn content_block_text(block: &ContentBlock) -> String {
ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
input.to_string()
}
ContentBlock::ImageUrl { image_url } => image_url.url.clone(),
// Image payloads are never inlined for estimation; the caller applies
// `IMAGE_TOKEN_ESTIMATE` instead.
ContentBlock::ImageUrl { .. } | ContentBlock::LocalImage { .. } => String::new(),
}
}

/// Flat per-image token estimate, mirroring compaction's image heuristic
/// (vision tiles are typically ~1k tokens) without reading the image payload.
const IMAGE_TOKEN_ESTIMATE: usize = 1000;

fn pressure_label(percent: Option<f64>) -> &'static str {
// Delegate to the unified pressure thresholds so this diagnostic label can't
// drift from `context_budget::PressureLevel`. `None` (unknown window) keeps
Expand Down
Loading