From 6c6f51dbeb33e6e3b59d9b3944a660949177ade1 Mon Sep 17 00:00:00 2001 From: "Jing.Li" Date: Wed, 26 Aug 2026 06:24:12 +0800 Subject: [PATCH 1/3] feat: image editing capability (I2I + inpainting) on openai_compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Capability::ImageEdit and InferenceRequest.input_mask. The backend posts multipart to the OpenAI-compatible /images/edits endpoint: the input image is resolved from messages[0].images[0] (data: URL, local path, or http(s) download) and passed through with the mask untouched — a PNG whose fully transparent areas mark where to regenerate (the OpenAI convention). No mask = whole-image I2I edit. Co-Authored-By: Claude --- config.example.toml | 16 + .../src/backends/openai_compat.rs | 443 +++++++++++++++++- mofa-kernel/src/types.rs | 15 + 3 files changed, 448 insertions(+), 26 deletions(-) diff --git a/config.example.toml b/config.example.toml index 52a9882..4036c00 100644 --- a/config.example.toml +++ b/config.example.toml @@ -141,6 +141,22 @@ price_output_per_1k = 0.0011 # name = "openai/dall-e-3" # capability = "image_gen" +# Image editing (I2I / inpainting) over any OpenAI-compatible `/images/edits` +# endpoint (e.g. gpt-image-1). The input image rides on `messages[0].images[0]` +# as a data:/http(s) URL or local path; an optional `input_mask` PNG marks the +# regions to regenerate with its transparent (alpha 0) areas — no mask means a +# whole-image edit. +# [[providers]] +# name = "openai-edits" +# kind = "openai_compatible" +# base_url = "https://api.openai.com/v1" +# api_key = "${OPENAI_API_KEY}" +# priority = 10 +# cost_tier = "high" +# [[providers.models]] +# name = "gpt-image-1" +# capability = "image_edit" + # Built-in zero-config voice: when this config declares NO TTS backend, the engine # automatically registers the operating system's native voice (macOS `say`, Linux # `espeak-ng`/`espeak`) as a low-priority local fallback — so local audio scenarios diff --git a/mofa-engine-core/src/backends/openai_compat.rs b/mofa-engine-core/src/backends/openai_compat.rs index 1349e39..86f5794 100644 --- a/mofa-engine-core/src/backends/openai_compat.rs +++ b/mofa-engine-core/src/backends/openai_compat.rs @@ -419,6 +419,7 @@ impl Provider for OpenAiCompatProvider { Capability::Tts => self.invoke_tts(model_name, request, start).await, Capability::Asr => self.invoke_asr(model_name, request, start).await, Capability::ImageGen => self.invoke_image_gen(model_name, request, start).await, + Capability::ImageEdit => self.invoke_image_edit(model_name, request, start).await, Capability::Embedding => self.invoke_embedding(model_name, request, start).await, other => Err(EngineError::UnsupportedOperation(format!( "provider '{}' does not support {other}", @@ -871,40 +872,226 @@ impl OpenAiCompatProvider { detail: "image_gen returned no data".into(), })?; - // Resolve to bytes: decode inline base64, or download the returned URL. - let bytes = - if let Some(b64) = image.b64_json { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD - .decode(b64.as_bytes()) + let bytes = self.image_bytes_from_response(image).await?; + + let path = self + .output_dir + .join(format!("mofa_image_{}.png", uuid::Uuid::new_v4())); + tokio::fs::write(&path, &bytes) + .await + .map_err(|e| EngineError::Internal(format!("image write error: {e}")))?; + + Ok(InferenceResponse { + text: None, + file: Some(path.to_string_lossy().to_string()), + model_used: model_name.to_string(), + provider: self.name.clone(), + duration_ms: start.elapsed().as_millis() as u64, + request_id: request.request_id.clone(), + tokens_used: None, + fallback_used: false, + routing_reason: None, + ..Default::default() + }) + } + + /// Resolve one provider image result to bytes: decode inline base64, or + /// download the returned URL. Shared by generation and editing. + async fn image_bytes_from_response(&self, image: ImageGenData) -> Result, EngineError> { + if let Some(b64) = image.b64_json { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|e| EngineError::ProviderError { + provider: self.name.clone(), + detail: format!("image base64 decode error: {e}"), + }) + } else if let Some(remote) = image.url { + let dl = + self.client + .get(&remote) + .send() + .await .map_err(|e| EngineError::ProviderError { - provider: self.name.clone(), - detail: format!("image base64 decode error: {e}"), - })? - } else if let Some(remote) = image.url { - let dl = self.client.get(&remote).send().await.map_err(|e| { - EngineError::ProviderError { provider: self.name.clone(), detail: format!("image download error: {e}"), - } + })?; + dl.bytes() + .await + .map_err(|e| EngineError::ProviderError { + provider: self.name.clone(), + detail: format!("image download read error: {e}"), + }) + .map(|b| b.to_vec()) + } else { + Err(EngineError::ProviderError { + provider: self.name.clone(), + detail: "image result had neither b64_json nor url".into(), + }) + } + } + + /// Fetch an image reference (an HTTP(S) URL, `data:` URL, or local path) + /// to raw bytes, plus its MIME type when derivable. + async fn fetch_image_ref( + &self, + reference: &str, + role: &str, + ) -> Result<(Vec, String), EngineError> { + if let Some((mime, is_base64, payload)) = parse_data_url(reference) { + if !is_base64 { + return Err(EngineError::InvalidRequest(format!( + "{role} data: URL must be base64-encoded" + ))); + } + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload.as_bytes()) + .map_err(|e| { + EngineError::InvalidRequest(format!( + "{role} data: URL payload is not valid base64: {e}" + )) })?; - dl.bytes() - .await - .map_err(|e| EngineError::ProviderError { - provider: self.name.clone(), - detail: format!("image download read error: {e}"), - })? - .to_vec() - } else { - return Err(EngineError::ProviderError { + Ok((bytes, mime)) + } else if reference.starts_with("http://") || reference.starts_with("https://") { + let resp = self.client.get(reference).send().await.map_err(|e| { + EngineError::ProviderError { provider: self.name.clone(), - detail: "image_gen returned neither b64_json nor url".into(), - }); - }; + detail: format!("{role} download error: {e}"), + } + })?; + let mime = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .unwrap_or_else(|| "image/png".into()); + let bytes = resp.bytes().await.map_err(|e| EngineError::ProviderError { + provider: self.name.clone(), + detail: format!("{role} download read error: {e}"), + })?; + Ok((bytes.to_vec(), mime)) + } else { + // Local file path on the engine host. + let bytes = tokio::fs::read(reference).await.map_err(|e| { + EngineError::InvalidRequest(format!("cannot read {role} file '{reference}': {e}")) + })?; + let mime = match std::path::Path::new(reference) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_lowercase) + .as_deref() + { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => "image/png", + } + .to_string(); + Ok((bytes, mime)) + } + } + + /// Edit an image via the OpenAI-compatible `/images/edits` endpoint: + /// whole-image (I2I) when no mask is supplied, or inpainting restricted + /// to the mask's transparent areas. The input image rides on + /// `messages[0].images[0]`; the mask on `request.input_mask`. + async fn invoke_image_edit( + &self, + model_name: &str, + request: &InferenceRequest, + start: std::time::Instant, + ) -> Result { + let prompt = request + .messages + .first() + .map(|m| m.content.clone()) + .or_else(|| { + request + .params + .get("prompt") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .filter(|p| !p.trim().is_empty()) + .ok_or_else(|| EngineError::InvalidRequest("image editing requires a prompt".into()))?; + + let image_ref = request + .messages + .first() + .and_then(|m| m.images.first()) + .ok_or_else(|| { + EngineError::InvalidRequest( + "image editing requires an input image (messages[0].images)".into(), + ) + })?; + let (image_bytes, image_mime) = self.fetch_image_ref(image_ref, "input image").await?; + + let mut form = reqwest::multipart::Form::new() + .text("model", model_name.to_string()) + .text("prompt", prompt) + .text("n", "1") + .part( + "image", + reqwest::multipart::Part::bytes(image_bytes) + .file_name("input.png") + .mime_str(&image_mime) + .map_err(|e| EngineError::Internal(format!("mime error: {e}")))?, + ); + if let Some(mask_ref) = request.input_mask.as_deref() { + let (mask_bytes, _) = self.fetch_image_ref(mask_ref, "mask").await?; + form = form.part( + "mask", + reqwest::multipart::Part::bytes(mask_bytes) + .file_name("mask.png") + .mime_str("image/png") + .map_err(|e| EngineError::Internal(format!("mime error: {e}")))?, + ); + } + if let Some(size) = request.params.get("size").and_then(|v| v.as_str()) { + form = form.text("size", size.to_string()); + } + + let url = format!("{}/images/edits", self.base_url); + let resp = self + .client + .post(&url) + .bearer_auth(&self.api_key) + .multipart(form) + .send() + .await + .map_err(|e| EngineError::ProviderError { + provider: self.name.clone(), + detail: e.to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(EngineError::ProviderError { + provider: self.name.clone(), + detail: format!("image_edit HTTP {status}: {text}"), + }); + } + + let parsed: ImageGenResponse = + resp.json().await.map_err(|e| EngineError::ProviderError { + provider: self.name.clone(), + detail: format!("image_edit decode error: {e}"), + })?; + let image = parsed + .data + .into_iter() + .next() + .ok_or_else(|| EngineError::ProviderError { + provider: self.name.clone(), + detail: "image_edit returned no data".into(), + })?; + let bytes = self.image_bytes_from_response(image).await?; let path = self .output_dir - .join(format!("mofa_image_{}.png", uuid::Uuid::new_v4())); + .join(format!("mofa_image_edit_{}.png", uuid::Uuid::new_v4())); tokio::fs::write(&path, &bytes) .await .map_err(|e| EngineError::Internal(format!("image write error: {e}")))?; @@ -1084,6 +1271,24 @@ impl OpenAiCompatProvider { } } +/// Split a `data:` URL into `(mime, is_base64, payload)`; `None` when the +/// reference is not a data URL. No percent-decoding: non-base64 payloads are +/// rejected by the caller (every image producer we support emits base64). +fn parse_data_url(reference: &str) -> Option<(String, bool, &str)> { + let rest = reference.strip_prefix("data:")?; + let (header, payload) = rest.split_once(',')?; + let (mime, is_base64) = match header.split_once(';') { + Some((m, tail)) => (m.to_string(), tail.eq_ignore_ascii_case("base64")), + None => (header.to_string(), false), + }; + let mime = if mime.is_empty() { + "image/png".to_string() + } else { + mime + }; + Some((mime, is_base64, payload)) +} + #[cfg(test)] mod tests { use super::*; @@ -1258,4 +1463,190 @@ mod tests { Some("hi".to_string()) ); } + + #[test] + fn parse_data_url_splits_header_and_payload() { + let (mime, is_base64, payload) = parse_data_url("data:image/png;base64,aGVsbG8=").unwrap(); + assert_eq!(mime, "image/png"); + assert!(is_base64); + assert_eq!(payload, "aGVsbG8="); + + // No mime → image/png default; no `;base64` marker → not base64. + let (mime, is_base64, payload) = parse_data_url("data:,abc").unwrap(); + assert_eq!(mime, "image/png"); + assert!(!is_base64); + assert_eq!(payload, "abc"); + + assert!(parse_data_url("https://example.com/x.png").is_none()); + assert!(parse_data_url("data:nocomma").is_none()); + } + + #[tokio::test] + async fn image_edit_sends_multipart_and_writes_artifact() { + use base64::Engine as _; + let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + + let image_data_url = format!("data:image/png;base64,{}", b64(b"fake-input-png")); + let mask_data_url = format!("data:image/png;base64,{}", b64(b"fake-mask-png")); + let edited = b"edited-png-bytes".to_vec(); + let edited_b64 = b64(&edited); + + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen_handler = seen.clone(); + let app = axum::Router::new().route( + "/images/edits", + axum::routing::post(move |body: axum::body::Bytes| { + let seen = seen_handler.clone(); + async move { + *seen.lock().unwrap() = body.to_vec(); + axum::Json(serde_json::json!({ "data": [{ "b64_json": edited_b64 }] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let provider = OpenAiCompatProvider::new( + "edit-mock", + format!("http://{addr}"), + "sk-test", + vec![ModelDef { + name: "edit-model".into(), + capability: "image_edit".into(), + context_window: None, + memory_mb: None, + ..Default::default() + }], + CostTier::Medium, + ) + .unwrap(); + + let request = InferenceRequest { + capability: Some(Capability::ImageEdit), + messages: vec![mofa_kernel::Message { + role: "user".into(), + content: "把天空换成夜景".into(), + images: vec![image_data_url], + }], + input_mask: Some(mask_data_url), + params: serde_json::json!({ "size": "1024x1024" }), + ..Default::default() + }; + let resp = provider + .invoke("edit-mock/edit-model", &request) + .await + .unwrap(); + assert_eq!(resp.model_used, "edit-model"); + assert_eq!(resp.provider, "edit-mock"); + + let body = String::from_utf8_lossy(&seen.lock().unwrap().clone()).into_owned(); + assert!( + body.contains("name=\"image\""), + "image part missing: {body}" + ); + assert!(body.contains("filename=\"input.png\"")); + assert!(body.contains("name=\"mask\""), "mask part missing: {body}"); + assert!(body.contains("filename=\"mask.png\"")); + assert!(body.contains("name=\"prompt\"")); + assert!(body.contains("把天空换成夜景")); + assert!(body.contains("name=\"size\"")); + assert!(body.contains("1024x1024")); + // The referenced image and mask bytes ride inline in the multipart body. + assert!(body.contains("fake-input-png")); + assert!(body.contains("fake-mask-png")); + + let path = resp.file.expect("artifact path"); + assert_eq!(std::fs::read(&path).unwrap(), edited); + } + + #[tokio::test] + async fn image_edit_without_mask_edits_whole_image() { + use base64::Engine as _; + let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + + let image_data_url = format!("data:image/jpeg;base64,{}", b64(b"jpeg-input")); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen_handler = seen.clone(); + let app = axum::Router::new().route( + "/images/edits", + axum::routing::post(move |body: axum::body::Bytes| { + let seen = seen_handler.clone(); + async move { + *seen.lock().unwrap() = body.to_vec(); + axum::Json(serde_json::json!({ "data": [{ "b64_json": b64(b"i2i-out") }] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let provider = OpenAiCompatProvider::new( + "i2i-mock", + format!("http://{addr}"), + "sk-test", + vec![ModelDef { + name: "edit-model".into(), + capability: "image_edit".into(), + ..Default::default() + }], + CostTier::Medium, + ) + .unwrap(); + + let request = InferenceRequest { + capability: Some(Capability::ImageEdit), + messages: vec![mofa_kernel::Message { + role: "user".into(), + content: "整体改成水彩风格".into(), + images: vec![image_data_url], + }], + ..Default::default() + }; + let resp = provider + .invoke("i2i-mock/edit-model", &request) + .await + .unwrap(); + + let body = String::from_utf8_lossy(&seen.lock().unwrap().clone()).into_owned(); + // Whole-image edit: an image part with the reference's JPEG mime, and no mask. + assert!(body.contains("name=\"image\"")); + assert!(body.contains("image/jpeg")); + assert!( + !body.contains("name=\"mask\""), + "unexpected mask part: {body}" + ); + assert_eq!( + std::fs::read(resp.file.expect("artifact path")).unwrap(), + b"i2i-out" + ); + } + + #[tokio::test] + async fn image_edit_requires_an_input_image() { + let provider = OpenAiCompatProvider::new( + "x", + "https://example.com", + "k", + vec![ModelDef { + name: "m".into(), + capability: "image_edit".into(), + ..Default::default() + }], + CostTier::Low, + ) + .unwrap(); + let request = InferenceRequest { + capability: Some(Capability::ImageEdit), + messages: vec![mofa_kernel::Message { + role: "user".into(), + content: "no image attached".into(), + images: vec![], + }], + ..Default::default() + }; + let err = provider.invoke("x/m", &request).await.unwrap_err(); + assert!(err.to_string().contains("input image"), "got: {err}"); + } } diff --git a/mofa-kernel/src/types.rs b/mofa-kernel/src/types.rs index 51a4c0a..bff5c30 100644 --- a/mofa-kernel/src/types.rs +++ b/mofa-kernel/src/types.rs @@ -18,6 +18,9 @@ pub enum Capability { Asr, /// Image generation. ImageGen, + /// Image editing (I2I / inpainting): regenerate an input image, either + /// whole or in the regions marked by an optional mask. + ImageEdit, /// Video generation. VideoGen, /// Vision-language model. @@ -33,6 +36,7 @@ impl std::fmt::Display for Capability { Self::Tts => "tts", Self::Asr => "asr", Self::ImageGen => "image_gen", + Self::ImageEdit => "image_edit", Self::VideoGen => "video_gen", Self::Vlm => "vlm", Self::Embedding => "embedding", @@ -49,6 +53,9 @@ impl Capability { "tts" => Some(Self::Tts), "asr" | "stt" => Some(Self::Asr), "imagegen" | "image_gen" | "image-gen" => Some(Self::ImageGen), + "imageedit" | "image_edit" | "image-edit" | "inpaint" | "inpainting" => { + Some(Self::ImageEdit) + } "videogen" | "video_gen" | "video-gen" => Some(Self::VideoGen), "vlm" | "vision" => Some(Self::Vlm), "embedding" | "embeddings" => Some(Self::Embedding), @@ -518,6 +525,13 @@ pub struct InferenceRequest { pub messages: Vec, /// Path to an input file on the engine host. pub input_file: Option, + /// Mask for the `image_edit` capability: an HTTP(S) URL, `data:` URL, or + /// local file path pointing at a PNG whose **fully transparent areas** + /// (alpha 0) mark where the input image should be regenerated — the + /// OpenAI `/images/edits` convention, passed through to the provider + /// untouched. `None` means whole-image editing (I2I). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_mask: Option, /// Extra parameters passed through to the provider. #[serde(default)] pub params: serde_json::Value, @@ -542,6 +556,7 @@ impl Default for InferenceRequest { max_cost_usd: None, messages: Vec::new(), input_file: None, + input_mask: None, params: serde_json::Value::Null, hint_next: None, request_id: Self::generate_request_id(), From 7608bd395bcb848a878a3d1f61c61b7b47af7c48 Mon Sep 17 00:00:00 2001 From: "Jing.Li" Date: Wed, 26 Aug 2026 06:33:01 +0800 Subject: [PATCH 2/3] feat(image_edit): one image part per reference (multi-reference consistency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt-image-1 style: every messages[0].images entry rides as its own multipart image part — first is the edited base, the rest are consistency anchors. Co-Authored-By: Claude --- .../src/backends/openai_compat.rs | 90 +++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/mofa-engine-core/src/backends/openai_compat.rs b/mofa-engine-core/src/backends/openai_compat.rs index 86f5794..ddd60fb 100644 --- a/mofa-engine-core/src/backends/openai_compat.rs +++ b/mofa-engine-core/src/backends/openai_compat.rs @@ -1016,28 +1016,37 @@ impl OpenAiCompatProvider { .filter(|p| !p.trim().is_empty()) .ok_or_else(|| EngineError::InvalidRequest("image editing requires a prompt".into()))?; - let image_ref = request + let image_refs = request .messages .first() - .and_then(|m| m.images.first()) + .map(|m| m.images.clone()) + .filter(|v| !v.is_empty()) .ok_or_else(|| { EngineError::InvalidRequest( "image editing requires an input image (messages[0].images)".into(), ) })?; - let (image_bytes, image_mime) = self.fetch_image_ref(image_ref, "input image").await?; - + // Every reference rides as its own `image` part (gpt-image-1 style + // multi-reference consistency); the first is the image being edited. let mut form = reqwest::multipart::Form::new() .text("model", model_name.to_string()) .text("prompt", prompt) - .text("n", "1") - .part( + .text("n", "1"); + for (index, image_ref) in image_refs.iter().enumerate() { + let (image_bytes, image_mime) = self.fetch_image_ref(image_ref, "input image").await?; + let file_name = if index == 0 { + "input.png".to_string() + } else { + format!("reference-{index}.png") + }; + form = form.part( "image", reqwest::multipart::Part::bytes(image_bytes) - .file_name("input.png") + .file_name(file_name) .mime_str(&image_mime) .map_err(|e| EngineError::Internal(format!("mime error: {e}")))?, ); + } if let Some(mask_ref) = request.input_mask.as_deref() { let (mask_bytes, _) = self.fetch_image_ref(mask_ref, "mask").await?; form = form.part( @@ -1649,4 +1658,71 @@ mod tests { let err = provider.invoke("x/m", &request).await.unwrap_err(); assert!(err.to_string().contains("input image"), "got: {err}"); } + + #[tokio::test] + async fn image_edit_sends_one_part_per_reference() { + use base64::Engine as _; + let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen_handler = seen.clone(); + let app = axum::Router::new().route( + "/images/edits", + axum::routing::post(move |body: axum::body::Bytes| { + let seen = seen_handler.clone(); + async move { + *seen.lock().unwrap() = body.to_vec(); + axum::Json(serde_json::json!({ "data": [{ "b64_json": b64(b"multi-out") }] })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let provider = OpenAiCompatProvider::new( + "multi-mock", + format!("http://{addr}"), + "sk-test", + vec![ModelDef { + name: "edit-model".into(), + capability: "image_edit".into(), + ..Default::default() + }], + CostTier::Medium, + ) + .unwrap(); + + // Three references: the base image plus two consistency anchors. + let request = InferenceRequest { + capability: Some(Capability::ImageEdit), + messages: vec![mofa_kernel::Message { + role: "user".into(), + content: "保持角色一致,换成赛博朋克街道".into(), + images: vec![ + format!("data:image/png;base64,{}", b64(b"base-img")), + format!("data:image/png;base64,{}", b64(b"ref-1")), + format!("data:image/png;base64,{}", b64(b"ref-2")), + ], + }], + ..Default::default() + }; + provider + .invoke("multi-mock/edit-model", &request) + .await + .unwrap(); + + let body = String::from_utf8_lossy(&seen.lock().unwrap().clone()).into_owned(); + assert_eq!( + body.matches("name=\"image\"").count(), + 3, + "one image part per reference: {body}" + ); + assert!(body.contains("filename=\"input.png\"")); + assert!(body.contains("filename=\"reference-1.png\"")); + assert!(body.contains("filename=\"reference-2.png\"")); + assert!(body.contains("base-img")); + assert!(body.contains("ref-1")); + assert!(body.contains("ref-2")); + } } From dd8a20cceddb0ffe617606b86c3d3b01bc27a901 Mon Sep 17 00:00:00 2001 From: "Jing.Li" Date: Wed, 26 Aug 2026 12:53:22 +0800 Subject: [PATCH 3/3] test: declare the axum dev-dependency the mock-server tests need The tests were introduced with the capability but leaned on the dev-dep from the stacked branch; make it explicit so this branch stands alone. Co-Authored-By: Claude --- Cargo.lock | 1 + mofa-engine-core/Cargo.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d494900..509b589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum", "base64 0.23.0", "chrono", "dashmap", diff --git a/mofa-engine-core/Cargo.toml b/mofa-engine-core/Cargo.toml index 6cec2e7..e4a5518 100644 --- a/mofa-engine-core/Cargo.toml +++ b/mofa-engine-core/Cargo.toml @@ -29,3 +29,6 @@ tokio-stream = { workspace = true } # Vetted 1.11.0 for the initial engine landing. liter-llm = "=1.11.0" base64 = "0.23.0" + +[dev-dependencies] +axum = { workspace = true }