mod requests;
use requests::build_vision_request;
use requests::encode_image_base64;
use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{VIEW_IMAGE_RESPONSE_MAX_BYTES, ViewImageArgs},
contract::{metadata_key as meta, tool_name},
fs::ExistingPathPolicy,
};
use crate::{
auth::{OPENAI_CODEX_RELOGIN_GUIDANCE, ProviderCredential},
cancellation::AgentCancellation,
config::ViewImageVisionModelSettings,
output::redact_sensitive_text,
providers::{
ANTHROPIC_PROVIDER, CODEX_RESPONSES_URL, OPENAI_CODEX_PROVIDER, ProviderEvent,
StreamParser, codex_account_id_for, codex_sse_headers_with_routing_hint,
normalize_codex_model,
},
};
use anyhow::Context;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs,
io::{BufReader, Read},
path::Path,
sync::{OnceLock, mpsc},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
const SYSTEM_PROMPT: &str = include_str!("../../prompts/view_image_system.md");
const ANTHROPIC_MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_RESPONSES_URL: &str = "https://api.openai.com/v1/responses";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const USER_AGENT: &str = concat!("magi-code/", env!("CARGO_PKG_VERSION"));
const VISION_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const VISION_HTTP_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const VISION_ERROR_BODY_MAX_BYTES: usize = 4 * 1024;
const VISION_SUCCESS_BODY_MAX_BYTES: usize = 1024 * 1024;
const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(25);
const VISION_WORKER_JOIN_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug, Clone)]
struct VisionHttpRequest {
url: String,
headers: BTreeMap<String, String>,
body: String,
}
#[derive(Debug, Clone)]
enum VisionProvider {
OpenAiCompatible {
base_url: String,
api_key: Option<String>,
use_responses_endpoint: bool,
},
OpenAiCodex {
access_token: String,
account_id: String,
},
OpenAiResponses {
responses_url: String,
api_key: String,
},
Anthropic {
api_key: String,
},
}
#[derive(Debug, Clone)]
struct VisionConfig {
provider: String,
model: String,
provider_kind: VisionProvider,
text_verbosity: Option<crate::config::TextVerbosity>,
}
pub(crate) fn is_local_image_reference(path: &str) -> bool {
!path.is_empty()
&& path.len() <= 4096
&& !path.contains(':')
&& !path.starts_with("//")
&& !path.starts_with("\\\\")
&& !path.chars().any(char::is_control)
}
pub(crate) fn read_local_preview_image(
cwd: &Path,
path: &str,
allow_absolute_paths: bool,
max_bytes: u64,
) -> anyhow::Result<image::DynamicImage> {
anyhow::ensure!(
is_local_image_reference(path),
"preview requires a local file path"
);
let canonical_cwd = cwd.canonicalize()?;
let path = super::fs::resolve_existing_path_with_policy(
cwd,
&canonical_cwd,
path,
ExistingPathPolicy::view_image(allow_absolute_paths),
)?;
anyhow::ensure!(
path.metadata()?.is_file(),
"preview requires a regular file"
);
let format = image_format_for_path(&path)?;
let bytes = read_bounded_image_bytes(&path, max_bytes.min(8 * 1024 * 1024))?;
validate_image_magic_bytes_for_path(&path, &bytes, format)?;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
Ok(reader.decode()?)
}
impl ToolRuntime {
pub(super) fn view_image(
&self,
args: ViewImageArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
self.view_image_with_http(args, cancellation, &ReqwestVisionHttpClient)
}
fn view_image_with_http(
&self,
args: ViewImageArgs,
cancellation: &AgentCancellation,
http: &dyn VisionHttpClient,
) -> anyhow::Result<ToolResult> {
cancellation.check()?;
let max_image_bytes =
crate::config::validate_view_image_max_image_bytes(self.view_image_max_image_bytes)?;
let path = self.resolve_existing_path(
&args.path,
ExistingPathPolicy::view_image(self.view_image_absolute_paths),
)?;
let config = self.resolve_vision_config()?;
let image_format = image_format_for_path(&path)?;
let media_type = image_format.media_type();
let bytes = read_bounded_image_bytes(&path, max_image_bytes)?;
let image_bytes = bytes.len();
validate_image_magic_bytes_for_path(&path, &bytes, image_format)?;
let base64 = encode_image_base64(bytes);
let request = build_vision_request(
&config,
SYSTEM_PROMPT.trim(),
&args.prompt,
media_type,
base64,
);
let text = http.post_json(request, cancellation)?;
let response = match &config.provider_kind {
VisionProvider::OpenAiCompatible {
use_responses_endpoint,
..
} => {
let value = serde_json::from_str(&text)?;
if *use_responses_endpoint {
parse_responses_response(&value)?
} else {
parse_openai_compatible_response(&value)?
}
}
VisionProvider::OpenAiCodex { .. } => parse_codex_sse_response(&text)?,
VisionProvider::OpenAiResponses { .. } => {
let value = serde_json::from_str(&text)?;
parse_responses_response(&value)?
}
VisionProvider::Anthropic { .. } => {
let value = serde_json::from_str(&text)?;
parse_anthropic_response(&value)?
}
};
let sanitized_response = redact_sensitive_text(response.trim());
let (content, truncated) = truncate_utf8_response(&sanitized_response);
Ok(ToolResult {
tool_name: tool_name::VIEW_IMAGE.to_string(),
success: true,
content,
metadata: json!({
(meta::PATH): path.to_string_lossy(),
(meta::PROVIDER): config.provider,
(meta::MODEL): config.model,
(meta::MEDIA_TYPE): media_type,
(meta::IMAGE_BYTES): image_bytes,
(meta::PROMPT_CHARS): args.prompt.chars().count(),
(meta::TRUNCATED): truncated,
}),
display: ToolResultDisplay::default(),
})
}
fn resolve_vision_config(&self) -> anyhow::Result<VisionConfig> {
let ViewImageVisionModelSettings { provider, model } =
self.view_image_vision_model.clone().ok_or_else(|| {
anyhow::anyhow!("missing tools.view_image.vision_model configuration")
})?;
let provider = crate::config::validate_view_image_identifier("provider", &provider)?;
let model = crate::config::validate_view_image_identifier("model", &model)?;
if provider == ANTHROPIC_PROVIDER {
let paths = self
.view_image_paths
.as_ref()
.ok_or_else(|| anyhow::anyhow!("view_image auth paths are not configured"))?;
let auth = crate::auth::read_auth(paths)?;
let credential = crate::auth::resolve_provider_credential(
ANTHROPIC_PROVIDER,
&auth,
None,
&self.view_image_custom_providers,
)?
.ok_or_else(|| {
crate::config::ConfigError::missing_auth_for_custom_providers(
ANTHROPIC_PROVIDER,
&self.view_image_custom_providers,
&paths.auth_file,
)
})?;
let ProviderCredential::ApiKey { key } = credential else {
anyhow::bail!("view_image provider 'anthropic' requires API key auth");
};
return Ok(VisionConfig {
provider,
model,
provider_kind: VisionProvider::Anthropic { api_key: key },
text_verbosity: None,
});
}
if provider == OPENAI_CODEX_PROVIDER {
let paths = self
.view_image_paths
.as_ref()
.ok_or_else(|| anyhow::anyhow!("view_image auth paths are not configured"))?;
let credential = resolve_openai_codex_view_image_credential(paths)?;
let ProviderCredential::OAuth { access, account_id } = credential else {
anyhow::bail!("view_image provider 'openai-codex' requires OAuth auth");
};
let account_id = codex_account_id_for(&access, &account_id)?;
return Ok(VisionConfig {
provider,
model,
provider_kind: VisionProvider::OpenAiCodex {
access_token: access,
account_id,
},
text_verbosity: Some(self.view_image_codex_text_verbosity),
});
}
if provider == "openai" {
let api_key = self.resolve_openai_responses_api_key()?;
return Ok(VisionConfig {
provider,
model,
provider_kind: VisionProvider::OpenAiResponses {
responses_url: OPENAI_RESPONSES_URL.to_string(),
api_key,
},
text_verbosity: self.view_image_text_verbosity,
});
}
let Some(custom) = self.view_image_custom_providers.get(&provider) else {
anyhow::bail!("view_image provider '{provider}' is not a configured custom provider");
};
let credential = match &custom.api_key_env_var {
Some(env_var) => std::env::var(env_var)
.ok()
.filter(|value| !value.is_empty())
.map(|key| ProviderCredential::ApiKey { key })
.ok_or_else(|| {
crate::config::ConfigError::missing_auth_for_custom_providers(
&provider,
&self.view_image_custom_providers,
self.view_image_paths
.as_ref()
.map(|paths| paths.auth_file.as_path())
.unwrap_or_else(|| std::path::Path::new("~/.magi-code/auth.json")),
)
})?,
None => ProviderCredential::NoAuth,
};
let api_key = match credential {
ProviderCredential::ApiKey { key } => Some(key),
ProviderCredential::NoAuth => None,
_ => {
anyhow::bail!("view_image custom provider '{provider}' requires API key or no-auth")
}
};
Ok(VisionConfig {
provider,
model,
provider_kind: VisionProvider::OpenAiCompatible {
base_url: custom.base_url.trim_end_matches('/').to_string(),
api_key,
use_responses_endpoint: custom.use_responses_endpoint,
},
text_verbosity: custom
.use_responses_endpoint
.then_some(self.view_image_text_verbosity)
.flatten()
.filter(|_| custom.supports_text_verbosity),
})
}
fn resolve_openai_responses_api_key(&self) -> anyhow::Result<String> {
if let Ok(key) = std::env::var("OPENAI_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
if let Ok(key) = std::env::var("MC_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
let paths = self
.view_image_paths
.as_ref()
.ok_or_else(|| anyhow::anyhow!("view_image auth paths are not configured"))?;
let auth = crate::auth::read_auth(paths)?;
if let Some(crate::auth::AuthProviderRecord::ApiKey { key }) = auth.providers.get("openai")
&& !key.is_empty()
{
return Ok(key.clone());
}
if let Some(key) = auth.api_key.filter(|key| !key.is_empty()) {
return Ok(key);
}
Err(
crate::config::ConfigError::missing_auth_for_custom_providers(
"openai",
&self.view_image_custom_providers,
self.view_image_paths
.as_ref()
.map(|paths| paths.auth_file.as_path())
.unwrap_or_else(|| std::path::Path::new("~/.magi-code/auth.json")),
)
.into(),
)
}
}
fn resolve_openai_codex_view_image_credential(
paths: &crate::config::McPaths,
) -> anyhow::Result<ProviderCredential> {
resolve_openai_codex_view_image_credential_with_exchange(
paths,
crate::auth::refresh_codex_token,
)
}
fn resolve_openai_codex_view_image_credential_with_exchange(
paths: &crate::config::McPaths,
exchange: impl FnOnce(&str) -> anyhow::Result<crate::auth::NormalizedToken>,
) -> anyhow::Result<ProviderCredential> {
let auth = crate::auth::read_auth(paths)?;
let Some(record) = auth.providers.get(OPENAI_CODEX_PROVIDER).cloned() else {
anyhow::bail!("missing OAuth auth for provider 'openai-codex'; run /login openai-codex")
};
let crate::auth::AuthProviderRecord::OAuth {
access,
refresh: _,
expires,
account_id,
} = record
else {
anyhow::bail!("view_image provider 'openai-codex' requires OAuth auth")
};
let near_expiry = expires.is_none_or(|expires| expires <= chrono::Utc::now().timestamp() + 300);
if !near_expiry && !access.is_empty() {
return Ok(ProviderCredential::OAuth { access, account_id });
}
crate::auth::codex_credential_from_store_with_exchange(paths, exchange).map_err(|error| {
anyhow::anyhow!(
"view_image provider 'openai-codex' OAuth refresh failed; {OPENAI_CODEX_RELOGIN_GUIDANCE}: {error}"
)
})
}
trait VisionHttpClient {
fn post_json(
&self,
request: VisionHttpRequest,
cancellation: &AgentCancellation,
) -> anyhow::Result<String>;
}
#[derive(Debug, Clone, Copy)]
struct ReqwestVisionHttpClient;
impl VisionHttpClient for ReqwestVisionHttpClient {
fn post_json(
&self,
request: VisionHttpRequest,
cancellation: &AgentCancellation,
) -> anyhow::Result<String> {
post_json(request, cancellation)
}
}
fn post_json(
request: VisionHttpRequest,
cancellation: &AgentCancellation,
) -> anyhow::Result<String> {
cancellation.check()?;
let client = vision_http_client()?;
let mut builder = client.post(&request.url);
for (name, value) in request.headers {
builder = builder.header(name, value);
}
let url = request.url.clone();
let response = run_cancellable(
"vision provider response header timeout",
VISION_HTTP_IDLE_TIMEOUT,
cancellation,
move || {
builder
.body(request.body)
.send()
.map_err(anyhow::Error::from)
},
)?;
let status = response.status();
let body = read_bounded_body_cancellable(
response,
if status.is_success() {
VISION_SUCCESS_BODY_MAX_BYTES
} else {
VISION_ERROR_BODY_MAX_BYTES
},
VISION_HTTP_IDLE_TIMEOUT,
cancellation,
)?;
let text = String::from_utf8_lossy(&body).into_owned();
if !status.is_success() {
anyhow::bail!(
"vision provider request failed for {} with status {status}: {}",
sanitize_url(&url),
redact_sensitive_text(&text)
);
}
Ok(text)
}
fn vision_http_client() -> anyhow::Result<&'static reqwest::blocking::Client> {
static CLIENT: OnceLock<anyhow::Result<reqwest::blocking::Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
reqwest::blocking::Client::builder()
.connect_timeout(VISION_CONNECT_TIMEOUT)
.timeout(VISION_HTTP_IDLE_TIMEOUT)
.build()
.map_err(|error| error.to_string())
})
.as_ref()
.map_err(|error| anyhow::anyhow!(error.clone()))
}
struct VisionWorkerHandle {
name: &'static str,
done_receiver: mpsc::Receiver<()>,
join_handle: JoinHandle<()>,
}
impl VisionWorkerHandle {
fn join_or_warn(self) {
match self.done_receiver.recv_timeout(VISION_WORKER_JOIN_TIMEOUT) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
if self.join_handle.join().is_err() {
eprintln!("magi-code warning: {} worker panicked", self.name);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
eprintln!(
"magi-code warning: {} worker did not exit within cleanup grace period; detaching worker",
self.name
);
}
}
}
}
fn spawn_vision_worker<T, F>(
name: &'static str,
operation: F,
) -> anyhow::Result<(mpsc::Receiver<anyhow::Result<T>>, VisionWorkerHandle)>
where
T: Send + 'static,
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{
let (sender, receiver) = mpsc::sync_channel(1);
let (done_sender, done_receiver) = mpsc::sync_channel(1);
let join_handle = thread::Builder::new()
.name(name.to_string())
.spawn(move || {
let _ = sender.send(operation());
let _ = done_sender.send(());
})?;
Ok((
receiver,
VisionWorkerHandle {
name,
done_receiver,
join_handle,
},
))
}
fn run_cancellable<T, F>(
timeout_label: &'static str,
idle_timeout: Duration,
cancellation: &AgentCancellation,
operation: F,
) -> anyhow::Result<T>
where
T: Send + 'static,
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{
let (receiver, worker) = spawn_vision_worker("view-image-http", operation)?;
let result = recv_cancellable(&receiver, timeout_label, idle_timeout, cancellation);
if result.is_err() {
drop(receiver);
}
worker.join_or_warn();
result?
}
fn recv_cancellable<T>(
receiver: &mpsc::Receiver<T>,
timeout_label: &str,
idle_timeout: Duration,
cancellation: &AgentCancellation,
) -> anyhow::Result<T> {
let start = Instant::now();
loop {
cancellation.check()?;
if start.elapsed() >= idle_timeout {
anyhow::bail!("{timeout_label} after {}s", idle_timeout.as_secs());
}
let remaining = idle_timeout.saturating_sub(start.elapsed());
match receiver.recv_timeout(remaining.min(CANCEL_POLL_INTERVAL)) {
Ok(value) => return Ok(value),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => {
cancellation.check()?;
anyhow::bail!("vision provider HTTP worker disconnected");
}
}
}
}
fn read_bounded_body_cancellable<R>(
mut reader: R,
max_bytes: usize,
idle_timeout: Duration,
cancellation: &AgentCancellation,
) -> anyhow::Result<Vec<u8>>
where
R: Read + Send + 'static,
{
let (sender, receiver) = mpsc::sync_channel::<std::io::Result<Vec<u8>>>(1);
let (done_sender, done_receiver) = mpsc::sync_channel(1);
let join_handle = thread::Builder::new()
.name("view-image-body".to_string())
.spawn(move || {
let mut buffer = [0_u8; 8192];
loop {
let message = match reader.read(&mut buffer) {
Ok(0) => Ok(Vec::new()),
Ok(read) => Ok(buffer[..read].to_vec()),
Err(error) => Err(error),
};
let done = matches!(&message, Ok(bytes) if bytes.is_empty()) || message.is_err();
if sender.send(message).is_err() || done {
break;
}
}
let _ = done_sender.send(());
})?;
let worker = VisionWorkerHandle {
name: "view-image-body",
done_receiver,
join_handle,
};
let mut body = Vec::with_capacity(max_bytes);
loop {
let message = match recv_cancellable(
&receiver,
"vision provider response body timeout",
idle_timeout,
cancellation,
) {
Ok(message) => message,
Err(error) => {
drop(receiver);
worker.join_or_warn();
return Err(error);
}
};
match message {
Ok(bytes) if bytes.is_empty() => {
worker.join_or_warn();
return Ok(body);
}
Ok(bytes) => {
let remaining = max_bytes.saturating_sub(body.len());
if bytes.len() > remaining {
body.extend_from_slice(&bytes[..remaining]);
drop(receiver);
worker.join_or_warn();
return Ok(body);
}
body.extend_from_slice(&bytes);
}
Err(error) => {
worker.join_or_warn();
return Err(error.into());
}
}
}
}
fn sanitize_url(url: &str) -> String {
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return redact_sensitive_text(url);
};
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
parsed.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImageFormat {
Png,
Jpeg,
Gif,
Webp,
}
impl ImageFormat {
fn media_type(&self) -> &'static str {
match self {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Gif => "image/gif",
ImageFormat::Webp => "image/webp",
}
}
}
fn image_format_for_path(path: &Path) -> anyhow::Result<ImageFormat> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match extension.as_str() {
"png" => Ok(ImageFormat::Png),
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
"gif" => Ok(ImageFormat::Gif),
"webp" => Ok(ImageFormat::Webp),
_ => anyhow::bail!("unsupported image type; expected png, jpg, jpeg, gif, or webp"),
}
}
fn read_bounded_image_bytes(path: &Path, max_bytes: u64) -> anyhow::Result<Vec<u8>> {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("failed to open image file '{}'", path.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("failed to read image metadata for '{}'", path.display()))?;
if !metadata.is_file() {
anyhow::bail!("image path must be a regular file: {}", path.display());
}
let read_limit = max_bytes.saturating_add(1);
if metadata.len() > read_limit {
anyhow::bail!(
"image file is {} bytes, exceeding configured maximum of {} bytes",
metadata.len(),
max_bytes
);
}
let mut reader = BufReader::new(file).take(read_limit);
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.with_context(|| format!("failed to read image file '{}'", path.display()))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
anyhow::bail!(
"image file is {} bytes, exceeding configured maximum of {} bytes",
bytes.len(),
max_bytes
);
}
Ok(bytes)
}
fn validate_image_magic_bytes_for_path(
path: &Path,
bytes: &[u8],
expected: ImageFormat,
) -> anyhow::Result<()> {
let matches = match expected {
ImageFormat::Png => bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
ImageFormat::Jpeg => bytes.starts_with(&[0xFF, 0xD8, 0xFF]),
ImageFormat::Gif => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
ImageFormat::Webp => bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP"),
};
if !matches {
anyhow::bail!(
"file extension '{}' does not match file content (expected {} image data)",
path.extension().and_then(|e| e.to_str()).unwrap_or("?"),
expected.media_type()
);
}
Ok(())
}
fn parse_openai_compatible_response(value: &Value) -> anyhow::Result<String> {
let text = value
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("vision provider response missing choices[0].message.content")
})?;
Ok(text.to_string())
}
fn parse_responses_response(value: &Value) -> anyhow::Result<String> {
let output = value
.get("output")
.and_then(Value::as_array)
.ok_or_else(|| anyhow::anyhow!("responses vision response missing output array"))?;
let text = output
.iter()
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("output_text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
let text = text.trim();
if text.is_empty() {
anyhow::bail!("responses vision response contained no output_text content");
}
Ok(text.to_string())
}
fn parse_codex_sse_response(text: &str) -> anyhow::Result<String> {
let mut parser = StreamParser::default();
let mut output = String::new();
for event in parser.push_chunk_outcome(text)?.events {
if let ProviderEvent::TextDelta(delta) = event {
output.push_str(&delta);
}
}
for event in parser.finish()? {
if let ProviderEvent::TextDelta(delta) = event {
output.push_str(&delta);
}
}
let output = output.trim();
if output.is_empty() {
anyhow::bail!("codex vision SSE response contained no output text");
}
Ok(output.to_string())
}
fn parse_anthropic_response(value: &Value) -> anyhow::Result<String> {
let content = value
.get("content")
.and_then(Value::as_array)
.ok_or_else(|| anyhow::anyhow!("anthropic vision response missing content array"))?;
let text = content
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
let text = text.trim();
if text.is_empty() {
anyhow::bail!("anthropic vision response contained no text");
}
Ok(text.to_string())
}
fn truncate_utf8_response(text: &str) -> (String, bool) {
if text.len() <= VIEW_IMAGE_RESPONSE_MAX_BYTES {
return (text.to_string(), false);
}
const MARKER: &str = "\n\n[view_image response truncated at 65536 bytes]";
let cap = VIEW_IMAGE_RESPONSE_MAX_BYTES.saturating_sub(MARKER.len());
let mut end = cap;
while !text.is_char_boundary(end) {
end -= 1;
}
let mut truncated = text[..end].to_string();
truncated.push_str(MARKER);
(truncated, true)
}