ryu_image/lib.rs
1//! Image-generation modality primitive: `generate(prompt) -> image` behind a
2//! swappable engine seam.
3//!
4//! One dispatch ([`generate`]) over two engines:
5//! - **local stable-diffusion.cpp** (default): forwarded to the resident
6//! sd-server's OpenAI-compatible `/v1/images/generations` (a thin HTTP proxy),
7//! lazily started via the host's [`ImageHost::start_local_engine`].
8//! - **cloud** (`openrouter` / `replicate` / `fal`, selected by a `"provider"`
9//! field in the body): routed through the Gateway's `/v1/images/generations`
10//! with the per-attribute `x-ryu-slot-image-provider` header, so the full
11//! firewall/budget/metering pipeline governs the call.
12//!
13//! Per the Core-vs-Gateway rule the *dispatch* is a Core concern (it decides
14//! *what runs* — which media engine renders the pixels); this crate owns the
15//! reusable image-gen abstraction + routing, while the host couplings it cannot
16//! own — the local sd-server base-url, the Gateway url/token, and lazy-starting
17//! the sd.cpp sidecar — are injected via the narrow [`ImageHost`] trait. The
18//! crate has ZERO dependency on `apps/core` (mirrors the `ryu-stt` seam).
19//!
20//! The generic media proxy/gateway-forward helpers ([`proxy`],
21//! [`forward_to_gateway`], [`cloud_provider`], [`media_client`]) are `pub` so the
22//! sibling *video* data path (which stays in Core, out of this crate's image
23//! scope) reuses the same routing mechanics rather than duplicating them.
24
25use std::future::Future;
26use std::pin::Pin;
27use std::time::Duration;
28
29use serde_json::{json, Value};
30
31/// A media data-path response: the HTTP status code and the JSON body. Core maps
32/// the `u16` back to an `axum` `StatusCode` and wraps the body in `Json`, so the
33/// wire behavior is byte-identical to the pre-extraction handlers.
34pub type MediaResponse = (u16, Value);
35
36/// Narrow host seam for image generation: the couplings the crate cannot own
37/// because they read Core config/sidecar state (the local sd-server base-url, the
38/// Gateway url + token, and lazy-starting the off-by-default sd.cpp sidecar). Core
39/// implements this in `apps/core/src/image_host.rs`.
40pub trait ImageHost: Send + Sync {
41 /// Base URL the local sd-server media engine serves on (`{base}/v1/...`).
42 fn sd_base_url(&self) -> String;
43 /// Base URL of the Gateway (`{base}/v1/images/generations`).
44 fn gateway_url(&self) -> String;
45 /// The Gateway bearer token slot (never a raw provider API key). `None` when
46 /// unset — the request is still sent, unauthenticated.
47 fn gateway_token(&self) -> Option<String>;
48 /// Lazily start the (off-by-default) local media engine so text-to-image works
49 /// out of the box once the sd-server binary + model are installed. Best-effort:
50 /// on failure the subsequent [`proxy`] returns a clear "install from the Store
51 /// first" error. Returns a boxed `Send` future so the crate's dispatch future
52 /// stays `Send` for the axum handler.
53 fn start_local_engine(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>>;
54}
55
56/// Cloud media providers routed through the Gateway (governed, metered) rather
57/// than the local stable-diffusion.cpp engine. A request selects one via a
58/// `"provider"` field in the body; anything else (or absent) uses the local
59/// engine, so the default local path is unchanged.
60pub const CLOUD_PROVIDERS: [&str; 3] = ["openrouter", "replicate", "fal"];
61
62/// Returns the normalized cloud provider id when the body selects one, else
63/// `None` (⇒ the local sd-server path).
64pub fn cloud_provider(body: &Value) -> Option<String> {
65 body.get("provider")
66 .and_then(Value::as_str)
67 .map(|s| s.trim().to_lowercase())
68 .filter(|s| CLOUD_PROVIDERS.contains(&s.as_str()))
69}
70
71/// Diffusion on CPU can take minutes; use a generous client timeout independent
72/// of the short-lived shared `ServerState` client.
73pub fn media_client() -> reqwest::Client {
74 reqwest::Client::builder()
75 .user_agent("ryu-core/0.1")
76 .timeout(Duration::from_secs(600))
77 .build()
78 .expect("reqwest client")
79}
80
81/// Forward a media request to the Gateway, routing to `provider` via the
82/// per-request slot header for `modality` (image/video). The Gateway runs the
83/// full firewall/budget/metering pipeline and returns a normalized body.
84pub async fn forward_to_gateway(
85 host: &impl ImageHost,
86 modality: &str,
87 endpoint: &str,
88 provider: &str,
89 body: Value,
90) -> MediaResponse {
91 let base = host.gateway_url();
92 let url = format!("{}{endpoint}", base.trim_end_matches('/'));
93 let slot_header = format!("x-ryu-slot-{modality}-provider");
94
95 let mut req = media_client()
96 .post(&url)
97 .header(slot_header, provider)
98 .json(&body);
99 if let Some(t) = host.gateway_token() {
100 req = req.bearer_auth(t);
101 }
102 let resp = match req.send().await {
103 Ok(r) => r,
104 Err(e) => {
105 return (
106 502,
107 json!({
108 "error": format!("cloud media gateway not reachable at {url}: {e}")
109 }),
110 );
111 }
112 };
113 let status = resp.status();
114 let bytes = resp.bytes().await.unwrap_or_default();
115 let value: Value = serde_json::from_slice(&bytes)
116 .unwrap_or_else(|_| json!({ "raw": String::from_utf8_lossy(&bytes) }));
117 if !status.is_success() {
118 // Preserve 202 Accepted (video job submitted) as success; treat other
119 // non-2xx as an error with the upstream detail.
120 return (
121 502,
122 json!({ "error": format!("cloud media provider returned {status}"), "detail": value }),
123 );
124 }
125 (200, value)
126}
127
128/// Forward a JSON body to a media-engine endpoint (`{base_url}{endpoint}`) and
129/// pass the response through. `base_url` is the local sd-server base
130/// ([`ImageHost::sd_base_url`]).
131pub async fn proxy(base_url: &str, endpoint: &str, body: Value) -> MediaResponse {
132 let url = format!("{base_url}{endpoint}");
133 let resp = match media_client().post(&url).json(&body).send().await {
134 Ok(r) => r,
135 Err(e) => {
136 return (
137 502,
138 json!({
139 "error": format!(
140 "stable-diffusion.cpp media engine not reachable at {url}: {e}. \
141 Install + start `sdcpp` from the Store first."
142 )
143 }),
144 );
145 }
146 };
147
148 let status = resp.status();
149 let bytes = resp.bytes().await.unwrap_or_default();
150 // Pass the upstream body through verbatim when it is JSON; otherwise wrap it.
151 let value: Value = serde_json::from_slice(&bytes)
152 .unwrap_or_else(|_| json!({ "raw": String::from_utf8_lossy(&bytes) }));
153
154 if !status.is_success() {
155 return (
156 502,
157 json!({ "error": format!("media engine returned {status}"), "detail": value }),
158 );
159 }
160 (200, value)
161}
162
163/// Text-to-image dispatch: validate `prompt`, default a single-image count, then
164/// route to the Gateway (cloud provider selected) or the local sd-server engine.
165/// This is the reusable image-gen entry; Core's `POST /api/images/generate`
166/// handler is a thin wrapper over it, injecting [`ImageHost`].
167pub async fn generate(host: &impl ImageHost, mut body: Value) -> MediaResponse {
168 if body
169 .get("prompt")
170 .and_then(Value::as_str)
171 .unwrap_or("")
172 .trim()
173 .is_empty()
174 {
175 return (
176 400,
177 json!({ "error": "missing `prompt` (the text to render)" }),
178 );
179 }
180 // Default to a single image when the caller doesn't specify a count.
181 if let Some(obj) = body.as_object_mut() {
182 obj.entry("n").or_insert(json!(1));
183 }
184 // Cloud provider selected → route through the Gateway; else the local engine.
185 if let Some(provider) = cloud_provider(&body) {
186 return forward_to_gateway(host, "image", "/v1/images/generations", &provider, body).await;
187 }
188 // Lazily start the (off-by-default) image engine so text-to-image works
189 // out of the box once onboarding has installed the sd-server binary + model.
190 // `start_local_engine` adopts an already-running server (fast) or spawns it
191 // and waits for the port; on failure we fall through and `proxy` returns a
192 // clear "install from the Store first" error.
193 if let Err(e) = host.start_local_engine().await {
194 tracing::debug!("sdcpp lazy start skipped: {e:#}");
195 }
196 proxy(&host.sd_base_url(), "/v1/images/generations", body).await
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 struct FakeHost;
204 impl ImageHost for FakeHost {
205 fn sd_base_url(&self) -> String {
206 "http://127.0.0.1:8083".into()
207 }
208 fn gateway_url(&self) -> String {
209 "http://127.0.0.1:7981".into()
210 }
211 fn gateway_token(&self) -> Option<String> {
212 None
213 }
214 fn start_local_engine(
215 &self,
216 ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
217 Box::pin(async { Ok(()) })
218 }
219 }
220
221 #[test]
222 fn cloud_provider_selects_known_and_normalizes() {
223 assert_eq!(
224 cloud_provider(&json!({ "provider": " Replicate " })),
225 Some("replicate".into())
226 );
227 assert_eq!(
228 cloud_provider(&json!({ "provider": "fal" })),
229 Some("fal".into())
230 );
231 }
232
233 #[test]
234 fn cloud_provider_rejects_unknown_or_absent() {
235 assert_eq!(cloud_provider(&json!({ "provider": "midjourney" })), None);
236 assert_eq!(cloud_provider(&json!({ "prompt": "a cat" })), None);
237 }
238
239 #[tokio::test]
240 async fn generate_rejects_empty_prompt() {
241 let (code, body) = generate(&FakeHost, json!({ "prompt": " " })).await;
242 assert_eq!(code, 400);
243 assert!(body.get("error").is_some());
244 }
245
246 #[tokio::test]
247 async fn generate_local_unreachable_engine_is_bad_gateway() {
248 // No sd-server on :8083 in tests → proxy surfaces a 502 with the
249 // "install from the Store first" hint (and lazy-start is a no-op here).
250 let (code, body) = generate(&FakeHost, json!({ "prompt": "a corgi" })).await;
251 assert_eq!(code, 502);
252 assert!(body["error"]
253 .as_str()
254 .unwrap_or("")
255 .contains("not reachable"));
256 }
257}