Skip to main content

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}