use std::path::PathBuf;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use eyre::{Result, eyre};
use nanocodex_oai_api::responses::ResponseItem;
use serde_json::{Value, json};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
task::JoinHandle,
};
use super::{
ImageGenerationConfig, ImageGenerationHandler, ImageRequest, ImagegenArgs, Tool, ToolContext,
ToolOutputContent, request_for_args,
};
use crate::ToolOutputBody;
const TINY_PNG: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0,
5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
#[tokio::test]
async fn generation_uses_codex_images_request_and_persists_result() -> Result<()> {
let workspace = temporary_workspace("image-generation")?;
let (api_base_url, server) = spawn_image_server().await?;
let handler = ImageGenerationHandler::new(ImageGenerationConfig {
api_base_url,
auth: nanocodex_oai_api::auth::OpenAiAuth::api_key("test-key"),
save_root: workspace.clone(),
});
let history = Vec::new();
let execution = handler
.run(
r#"{"prompt":"paint a blue whale"}"#,
ToolContext::new(
"gpt-5.6-sol",
"session/one",
"code:1",
&history,
crate::contract::DEFAULT_TOOL_OUTPUT_TOKENS,
),
)
.await;
assert!(execution.success);
let encoded = BASE64_STANDARD.encode(TINY_PNG);
let ToolOutputBody::Content(content) = &execution.output else {
return Err(eyre!("image generation did not return content items"));
};
assert!(matches!(
content.first(),
Some(ToolOutputContent::InputImage { image_url, detail: crate::ImageDetail::High })
if image_url == &format!("data:image/png;base64,{encoded}")
));
assert!(matches!(
content.get(1),
Some(ToolOutputContent::InputText { text })
if text.contains("Generated images are saved")
&& text.contains("already displayed to the user")
));
assert_eq!(
execution.code_mode_value()["image_url"],
format!("data:image/png;base64,{encoded}")
);
let saved_path = workspace
.join("generated_images")
.join("session_one")
.join("code_1.png");
assert_eq!(tokio::fs::read(saved_path).await?, TINY_PNG);
let request = server.await??;
assert_eq!(request.path, "/v1/images/generations");
assert!(
request
.headers
.to_ascii_lowercase()
.contains("authorization: bearer test-key")
);
assert_eq!(
request.body,
json!({
"prompt": "paint a blue whale",
"background": "auto",
"model": "gpt-image-2",
"quality": "auto",
"size": "auto"
})
);
std::fs::remove_dir_all(workspace)?;
Ok(())
}
#[tokio::test]
async fn edit_accepts_original_local_images_and_recent_conversation_images() -> Result<()> {
let workspace = temporary_workspace("image-edit")?;
let local_path = workspace.join("source.png");
tokio::fs::write(&local_path, TINY_PNG).await?;
let local_request = request_for_args(
&ImagegenArgs {
prompt: "add a red hat".to_owned(),
referenced_image_paths: Some(vec![local_path]),
num_last_images_to_include: None,
},
&[],
)
.await
.map_err(|error| eyre!(error))?;
let ImageRequest::Edit(local_request) = local_request else {
return Err(eyre!("local reference should select image editing"));
};
assert_eq!(local_request.images.len(), 1);
assert!(
local_request.images[0]
.image_url
.starts_with("data:image/png;base64,")
);
let history: Vec<ResponseItem> = serde_json::from_value(json!([
json!({
"type": "message",
"role": "user",
"content": [{"type": "input_image", "image_url": "data:image/png;base64,old"}]
}),
json!({"type": "custom_tool_call", "call_id": "call-image", "name": "exec", "input": ""}),
json!({
"type": "custom_tool_call_output",
"call_id": "call-image",
"output": [
{"type": "input_text", "text": "status"},
{"type": "input_image", "image_url": "data:image/png;base64,new"}
]
}),
]))?;
let recent_request = request_for_args(
&ImagegenArgs {
prompt: "combine these".to_owned(),
referenced_image_paths: None,
num_last_images_to_include: Some(2),
},
&history,
)
.await
.map_err(|error| eyre!(error))?;
let ImageRequest::Edit(recent_request) = recent_request else {
return Err(eyre!("conversation references should select image editing"));
};
assert_eq!(
recent_request
.images
.iter()
.map(|image| image.image_url.as_str())
.collect::<Vec<_>>(),
vec!["data:image/png;base64,old", "data:image/png;base64,new"]
);
std::fs::remove_dir_all(workspace)?;
Ok(())
}
#[test]
fn exposes_codex_imagegen_shape() {
let handler = ImageGenerationHandler::new(ImageGenerationConfig {
api_base_url: "http://127.0.0.1:1/v1".to_owned(),
auth: nanocodex_oai_api::auth::OpenAiAuth::api_key("test-key"),
save_root: PathBuf::from("/tmp/nanocodex-imagegen-test"),
});
let spec = serde_json::to_value(handler.definition()).unwrap();
assert_eq!(spec["name"], "image_gen__imagegen");
assert_eq!(spec["strict"], false);
assert_eq!(
spec.pointer("/parameters/properties/referenced_image_paths/maxItems"),
Some(&json!(5))
);
assert!(
spec["description"]
.as_str()
.is_some_and(|description| description.contains("generatedImage(result)"))
);
assert!(spec.get("output_schema").is_none());
}
struct CapturedRequest {
path: String,
headers: String,
body: Value,
}
async fn spawn_image_server() -> Result<(String, JoinHandle<Result<CapturedRequest>>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let api_base_url = format!("http://{}/v1", listener.local_addr()?);
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await?;
let request = read_http_request(&mut stream).await?;
let response = serde_json::to_vec(&json!({
"created": 1,
"data": [{"b64_json": BASE64_STANDARD.encode(TINY_PNG)}],
"background": "opaque",
"quality": "high",
"size": "1024x1024"
}))?;
stream
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
response.len()
)
.as_bytes(),
)
.await?;
stream.write_all(&response).await?;
Ok(request)
});
Ok((api_base_url, server))
}
async fn read_http_request(stream: &mut TcpStream) -> Result<CapturedRequest> {
let mut bytes = Vec::new();
let header_end = loop {
let mut chunk = [0_u8; 1024];
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err(eyre!("HTTP request ended before its headers"));
}
bytes.extend_from_slice(&chunk[..read]);
if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
break index + 4;
}
};
let headers = std::str::from_utf8(&bytes[..header_end])?.to_owned();
let request_line = headers
.lines()
.next()
.ok_or_else(|| eyre!("HTTP request omitted request line"))?;
let path = request_line
.split_ascii_whitespace()
.nth(1)
.ok_or_else(|| eyre!("HTTP request line omitted path"))?
.to_owned();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.ok_or_else(|| eyre!("HTTP request omitted content-length"))?;
while bytes.len() - header_end < content_length {
let mut chunk = [0_u8; 1024];
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err(eyre!("HTTP request body ended early"));
}
bytes.extend_from_slice(&chunk[..read]);
}
Ok(CapturedRequest {
path,
headers,
body: serde_json::from_slice(&bytes[header_end..header_end + content_length])?,
})
}
fn temporary_workspace(label: &str) -> Result<PathBuf> {
let path = std::env::temp_dir().join(format!(
"nanocodex-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
std::fs::create_dir_all(&path)?;
Ok(path)
}