-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(claude-code): send pasted images to the model as native image blocks #5653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,13 +11,21 @@ | |
| //! - On a `--resume` of an existing CC session: claude already has prior | ||
| //! turns server-side; we only send the last user turn. | ||
|
|
||
| use base64::Engine as _; | ||
| use serde_json::{json, Value}; | ||
|
|
||
| use crate::openhuman::agent::messages::ChatMessage; | ||
| use crate::openhuman::agent::multimodal::{parse_image_markers, rehydrate_image_placeholders}; | ||
|
|
||
| /// Build the bytes to write to claude's stdin. Returns an empty `Vec` | ||
| /// when there is nothing to send (caller should abort). | ||
| pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> { | ||
| // Resolve any `[Image: … #att:<id>]` placeholders to on-disk `[IMAGE:<path>]` | ||
| // markers so pasted images can be inlined below. No-op for messages that | ||
| // carry no image placeholder, so plain text turns are unaffected. | ||
| let rehydrated = rehydrate_image_placeholders(messages); | ||
| let messages: &[ChatMessage] = &rehydrated; | ||
|
|
||
| let mut out = String::new(); | ||
| let to_emit: Vec<&ChatMessage> = if is_new_session { | ||
| messages.iter().filter(|m| m.role != "system").collect() | ||
|
|
@@ -44,7 +52,7 @@ pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> { | |
| "type": "user", | ||
| "message": { | ||
| "role": role, | ||
| "content": [{"type": "text", "text": msg.content}], | ||
| "content": content_blocks(&msg.content), | ||
| }, | ||
| }); | ||
| push_json_line(&mut out, &line); | ||
|
|
@@ -53,6 +61,67 @@ pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> { | |
| out.into_bytes() | ||
| } | ||
|
|
||
| /// Split a message's text into stream-json content blocks: the prose as a | ||
| /// `text` block, plus one native `image` block per `[IMAGE:<ref>]` marker (the | ||
| /// `claude` CLI + Opus are vision-capable). An image that cannot be read | ||
| /// degrades to a short text note rather than being silently dropped. | ||
| fn content_blocks(raw: &str) -> Vec<Value> { | ||
| let (text, image_refs) = parse_image_markers(raw); | ||
| let mut blocks: Vec<Value> = Vec::new(); | ||
| if !text.is_empty() { | ||
| blocks.push(json!({"type": "text", "text": text})); | ||
| } | ||
| for reference in &image_refs { | ||
| match image_block(reference) { | ||
| Some(block) => blocks.push(block), | ||
| None => blocks.push(json!({ | ||
| "type": "text", | ||
| "text": "[an attached image could not be read]" | ||
| })), | ||
| } | ||
| } | ||
| if blocks.is_empty() { | ||
| // Preserve prior behaviour for a genuinely empty message. | ||
| blocks.push(json!({"type": "text", "text": raw})); | ||
| } | ||
| blocks | ||
| } | ||
|
|
||
| /// Build an Anthropic `image` content block from an `[IMAGE:<ref>]` reference. | ||
| /// `<ref>` is either a `data:` URI (inline base64) or an on-disk file path (a | ||
| /// rehydrated attachment). Returns `None` when the ref cannot be resolved. | ||
| fn image_block(reference: &str) -> Option<Value> { | ||
| let (media_type, data_b64) = if let Some(rest) = reference.strip_prefix("data:") { | ||
| let (mime, data) = rest.split_once(";base64,")?; | ||
| (mime.to_string(), data.to_string()) | ||
| } else { | ||
| let bytes = std::fs::read(reference).ok()?; | ||
| ( | ||
| media_type_from_path(reference), | ||
| base64::engine::general_purpose::STANDARD.encode(bytes), | ||
|
Comment on lines
+98
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- input_builder.rs outline ---'
ast-grep outline src/openhuman/inference/provider/claude_code/input_builder.rs
printf '%s\n' '--- input_builder.rs relevant sections ---'
cat -n src/openhuman/inference/provider/claude_code/input_builder.rs | sed -n '1,220p'
printf '%s\n' '--- image marker and provider call sites ---'
rg -n -C 3 'parse_image_markers|image_block|IMAGE_MARKER|data:|rehydrate_image_placeholders|build_stdin' src
printf '%s\n' '--- size-limit and attachment-path references ---'
rg -n -C 3 'max.?bytes|max.?size|byte.?limit|attachment|image|read_to_end|metadata\(' src/openhuman app/src-tauri Cargo.toml 2>/dev/null | head -n 500Repository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- provider builder ---'
cat -n src/openhuman/inference/provider/claude_code/input_builder.rs | sed -n '1,215p'
printf '%s\n' '--- multimodal configuration and normalization ---'
cat -n src/openhuman/agent/multimodal.rs | sed -n '520,820p'
cat -n src/openhuman/agent/multimodal.rs | sed -n '960,1245p'
printf '%s\n' '--- provider preparation callers ---'
rg -n -C 8 'prepare_messages_for_provider|rehydrate_image_placeholders|normalize_image_reference|build_stdin\(' src/openhuman --glob '*.rs'Repository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- preparation function ---'
rg -n -C 20 'pub async fn prepare_messages_for_provider|fn prepare_messages_for_provider|prepare_messages_for_provider' src/openhuman/agent/multimodal.rs src/openhuman --glob '*.rs'
printf '%s\n' '--- driver invocation context ---'
cat -n src/openhuman/inference/provider/claude_code/driver.rs | sed -n '300,365p'
rg -n -C 15 'ClaudeCode|claude_code|run\(|invoke|ProviderContext|ctx\.messages' src/openhuman/inference/provider/claude_code --glob '*.rs'
printf '%s\n' '--- multimodal limits and preparation implementation ---'
rg -n -C 12 'effective_limits|max_images|max_image_size_mb|image_config|MultimodalConfig' src/openhuman/agent/multimodal.rs src/openhuman/config/schema/tools/multimodal.rs --glob '*.rs'Repository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- exact preparation body ---'
cat -n src/openhuman/agent/multimodal.rs | sed -n '338,455p'
printf '%s\n' '--- all preparation call sites ---'
rg -l 'prepare_messages_for_provider' src --glob '*.rs' | while IFS= read -r f; do
echo "--- $f"
rg -n -C 6 'prepare_messages_for_provider' "$f"
done
printf '%s\n' '--- Claude driver context construction and callers ---'
rg -n -C 8 'ClaudeCodeContext|ProviderContext|messages:' src/openhuman/inference/provider/claude_code src/openhuman --glob '*.rs' | rg -v 'tests|multimodal_tests' | head -n 300Repository: tinyhumansai/openhuman Length of output: 50378 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- Claude provider public request path ---'
cat -n src/openhuman/inference/provider/claude_code/mod.rs | sed -n '150,270p'
cat -n src/openhuman/inference/provider/claude_code/types.rs | sed -n '1,100p'
printf '%s\n' '--- direct run_chat callers ---'
rg -n -C 10 '\.run_chat\(|run_chat\(' src --glob '*.rs' | head -n 400
printf '%s\n' '--- preparation-to-provider flow ---'
cat -n src/openhuman/agent/harness/session/turn/core.rs | sed -n '1225,1285p'
cat -n src/openhuman/agent/harness/graph.rs | sed -n '75,120p'
printf '%s\n' '--- image configuration defaults and limits ---'
cat -n src/openhuman/config/schema/tools/multimodal.rs | sed -n '1,110p'Repository: tinyhumansai/openhuman Length of output: 22585 Denial of Service (CWE-400): Uncontrolled Resource Consumption Reachability: External Prevent the fallback path from bypassing image-size limits. When preparation rejects an oversized image, the callers fall back to the original message. 🤖 Prompt for AI Agents |
||
| ) | ||
|
Comment on lines
+93
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Trace external population of ChatMessage content and attachment-path controls.
rg -n -C 4 --type rust 'ChatMessage::user\(|ChatMessage\s*\{' src app 2>/dev/null || true
rg -n -C 5 --type rust 'IMAGE_MARKER_PREFIX|parse_image_markers|rehydrate_image_placeholders|build_attachment_index' src/openhumanRepository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Claude input builder ---'
sed -n '1,125p' src/openhuman/inference/provider/claude_code/input_builder.rs
printf '%s\n' '--- multimodal marker and attachment helpers ---'
rg -n -C 5 --type rust \
'IMAGE_MARKER_PREFIX|IMAGE_STASH_REF|IMAGE_PLACEHOLDER_PREFIX|build_attachment_index|rehydrate_placeholders_in_text|parse_image_markers|image_block' \
src/openhuman/agent/multimodal.rs src/openhuman/inference/provider/claude_code/input_builder.rs
printf '%s\n' '--- production dispatch path ---'
sed -n '270,325p' src/openhuman/channels/runtime/dispatch/processor.rs
sed -n '560,610p' src/openhuman/channels/runtime/dispatch/processor.rs
printf '%s\n' '--- message conversion path ---'
sed -n '285,335p' src/openhuman/agent/message_convert.rs
sed -n '500,570p' src/openhuman/agent/message_convert.rsRepository: tinyhumansai/openhuman Length of output: 37425 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- multimodal ingress normalization ---'
sed -n '350,455p' src/openhuman/agent/multimodal.rs
sed -n '530,550p' src/openhuman/agent/multimodal.rs
sed -n '640,675p' src/openhuman/agent/multimodal.rs
sed -n '740,815p' src/openhuman/agent/multimodal.rs
sed -n '871,910p' src/openhuman/agent/multimodal.rs
printf '%s\n' '--- stash callers and Claude builder callers ---'
rg -n -C 6 --type rust \
'stash_image_attachments|normalize_local_image|build_stdin\(' \
src/openhumanRepository: tinyhumansai/openhuman Length of output: 31115 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- image normalization and size enforcement ---'
sed -n '970,1020p' src/openhuman/agent/multimodal.rs
sed -n '1188,1235p' src/openhuman/agent/multimodal.rs
printf '%s\n' '--- multimodal configuration and untrusted-channel policy ---'
rg -n -C 8 --type rust \
'for_untrusted_channel_input|MultimodalFileConfig|MultimodalConfig|effective_limits|max_image_size|max_images' \
src/openhuman/agent/multimodal.rs src/openhuman/web_chat/ops.rs src/openhuman/channels
printf '%s\n' '--- web-chat message persistence and provider dispatch ---'
sed -n '420,485p' src/openhuman/web_chat/ops.rs
rg -n -C 6 --type rust \
'prepare_messages_for_provider|PreparedMessages|build_stdin|ChatMessage::user' \
src/openhuman/web_chat src/openhuman/channels src/openhuman/agentRepository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- channel dispatch configuration and request construction ---'
rg -n -C 12 --type rust \
'for_untrusted_channel_input|multimodal_files:|multimodal:|AgentTurnRequest|process_channel_message' \
src/openhuman/channels/runtime/dispatch/processor.rs src/openhuman/agent src/openhuman/channels \
| head -n 320
printf '%s\n' '--- provider preparation and image handling call sites ---'
rg -n -C 10 --type rust \
'prepare_messages_for_provider|contains_images|rehydrate_image_placeholders|stash_image_attachments' \
src/openhuman | head -n 320
printf '%s\n' '--- exact local-image MIME and byte handling ---'
sed -n '1191,1235p' src/openhuman/agent/multimodal.rs
sed -n '1235,1275p' src/openhuman/agent/multimodal.rsRepository: tinyhumansai/openhuman Length of output: 50378 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- all production preparation call sites ---'
rg -n --type rust 'prepare_messages_for_provider' src/openhuman
printf '%s\n' '--- AgentTurnRequest handling ---'
rg -n -C 15 --type rust \
'AGENT_RUN_TURN_METHOD|AgentTurnRequest|multimodal_files|prepare_messages' \
src/openhuman/agent/bus.rs src/openhuman/agent | head -n 360
printf '%s\n' '--- channel history and model request handoff ---'
sed -n '450,525p' src/openhuman/channels/runtime/dispatch/processor.rs
rg -n -C 12 --type rust \
'req\.history|request\.history|history.*multimodal|multimodal_files' \
src/openhuman/agent src/openhuman/inference | head -n 260Repository: tinyhumansai/openhuman Length of output: 50380 Path Traversal (CWE-22): Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') Reachability: External Restrict file references to managed attachments.
🤖 Prompt for AI Agents |
||
| }; | ||
| Some(json!({ | ||
| "type": "image", | ||
| "source": {"type": "base64", "media_type": media_type, "data": data_b64}, | ||
| })) | ||
| } | ||
|
|
||
| /// Best-effort media type from a file extension. Claude accepts jpeg/png/gif/webp. | ||
| fn media_type_from_path(path: &str) -> String { | ||
| let lower = path.to_ascii_lowercase(); | ||
| if lower.ends_with(".png") { | ||
| "image/png" | ||
| } else if lower.ends_with(".gif") { | ||
| "image/gif" | ||
| } else if lower.ends_with(".webp") { | ||
| "image/webp" | ||
| } else { | ||
| "image/jpeg" | ||
| } | ||
| .to_string() | ||
| } | ||
|
|
||
| fn push_json_line(buf: &mut String, v: &Value) { | ||
| buf.push_str(&serde_json::to_string(v).unwrap_or_default()); | ||
| buf.push('\n'); | ||
|
|
@@ -107,4 +176,27 @@ mod tests { | |
| let bytes = build_stdin(&[], true); | ||
| assert!(bytes.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn user_message_with_image_marker_emits_native_image_block() { | ||
| // A rehydrated / inline data-URI marker becomes a real image block, and | ||
| // the surrounding prose stays a text block. Regression for pasted images | ||
| // being dropped on the way to the claude-code brain. | ||
| let m = ChatMessage::user("look at this [IMAGE:data:image/png;base64,QUJD]"); | ||
| let s = String::from_utf8(build_stdin(&[m], true)).unwrap(); | ||
| assert!(s.contains("\"type\":\"image\""), "image block emitted: {s}"); | ||
| assert!(s.contains("\"media_type\":\"image/png\""), "{s}"); | ||
| assert!(s.contains("\"data\":\"QUJD\""), "base64 payload preserved: {s}"); | ||
| assert!(s.contains("\"text\":\"look at this\""), "prose kept: {s}"); | ||
| assert!(!s.contains("[IMAGE:"), "raw marker stripped: {s}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn plain_text_still_single_text_block() { | ||
| // serde_json sorts object keys, so the block serializes as | ||
| // {"text":"hi","type":"text"} — a single text block, no image blocks. | ||
| let s = String::from_utf8(build_stdin(&[ChatMessage::user("hi")], true)).unwrap(); | ||
| assert!(s.contains("\"content\":[{\"text\":\"hi\",\"type\":\"text\"}]"), "{s}"); | ||
| assert!(!s.contains("\"type\":\"image\""), "no image block for plain text: {s}"); | ||
|
Comment on lines
+195
to
+200
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check whether serde_json map-order features can change serialization order.
rg -n -C 3 'serde_json|preserve_order' --glob 'Cargo.toml' --glob 'Cargo.lock'Repository: tinyhumansai/openhuman Length of output: 160 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- Cargo manifests ---'
git ls-files '*Cargo.toml' '*Cargo.lock'
printf '%s\n' '--- serde_json declarations and features ---'
rg -n -C 4 'serde_json|preserve_order' --glob 'Cargo.toml' --glob 'Cargo.lock' . || true
printf '%s\n' '--- relevant source ---'
sed -n '1,240p' src/openhuman/inference/provider/claude_code/input_builder.rsRepository: tinyhumansai/openhuman Length of output: 50378 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- preserve_order references ---'
rg -n -C 2 'preserve_order' . || true
printf '%s\n' '--- exact test and serializer helper ---'
sed -n '175,215p' src/openhuman/inference/provider/claude_code/input_builder.rs
rg -n -C 5 'fn push_json_line|fn plain_text_still_single_text_block|serde_json::from_str|serde_json::from_slice' src/openhuman/inference/provider/claude_code/input_builder.rs
printf '%s\n' '--- serde_json package metadata ---'
sed -n '5685,5705p' Cargo.lockRepository: tinyhumansai/openhuman Length of output: 4205 Parse the emitted JSON before asserting content.
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Preserve text and image block order.
parse_image_markersreturns one combined prose string and separate image references.content_blocksemits all prose before every image. A message such asbefore [IMAGE:A] after [IMAGE:B]therefore loses its text-to-image ordering.Return ordered segments from the parser, or scan
rawinto ordered content blocks. Add a regression test with interleaved text and two images.🤖 Prompt for AI Agents