Skip to main content

el_cloud/
routing.rs

1//! Provider routing and `LlmProvider` implementation for cloud backends.
2
3use crate::openai_wire::{
4    WireMessage, WireRequest, WireResponse, WireStreamChunk, WireStreamError,
5};
6use el_core::{
7    ChatRequest, ChatResponse, ChatRole, ChatToken, DomainEvent, EdgeError, LlmProvider, Result,
8};
9
10/// Resolves provider base URL and strips the prefix from the model name.
11fn resolve(model: &str) -> (&str, String) {
12    if let Some(m) = model.strip_prefix("openai/") {
13        return ("https://api.openai.com/v1", m.to_owned());
14    }
15    if let Some(m) = model.strip_prefix("anthropic/") {
16        return ("https://api.anthropic.com/v1", m.to_owned());
17    }
18    if let Some(m) = model.strip_prefix("gemini/") {
19        return (
20            "https://generativelanguage.googleapis.com/v1beta/openai",
21            m.to_owned(),
22        );
23    }
24    if let Some(m) = model.strip_prefix("ollama/") {
25        return ("http://localhost:11434/v1", m.to_owned());
26    }
27    // Custom base URL: "https://my.server/v1/llama3" → split on last "/"
28    if model.starts_with("http://") || model.starts_with("https://") {
29        if let Some(pos) = model.rfind('/') {
30            return (&model[..pos], model[pos + 1..].to_owned());
31        }
32    }
33    // Fallback: treat as an OpenAI model name
34    ("https://api.openai.com/v1", model.to_owned())
35}
36
37fn wire_role(role: ChatRole) -> &'static str {
38    match role {
39        ChatRole::System => "system",
40        ChatRole::User => "user",
41        ChatRole::Assistant => "assistant",
42    }
43}
44
45fn make_err(msg: impl Into<String>) -> EdgeError {
46    EdgeError::CloudRequest(msg.into().into_boxed_str())
47}
48
49/// Cap on the TCP + TLS connect phase.
50const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
51/// Blocking-client operation timeout. It bounds the wait for response headers,
52/// and — because `reqwest::blocking` applies it to **every body `read()`** —
53/// acts as an *idle* timeout between SSE chunks while streaming: a long
54/// generation streams indefinitely as long as chunks keep arriving, but a
55/// stalled provider unblocks the caller within this window.
56const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
57/// Total wall-clock cap for non-streaming `chat()` requests (headers + body).
58const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
59
60/// Frontier LLM cloud backend. Construct one per session; it owns a
61/// `reqwest::blocking::Client` that reuses the TCP connection pool.
62///
63/// # Air-gap guarantee (ADR-004)
64/// A [`CloudProvider`] is only ever created if the host app explicitly opts
65/// in. Apps that never construct this type have zero outbound network surface.
66///
67/// # Event emission (ADR-010)
68/// After each successful `chat()` or `chat_stream()` call, emits
69/// [`DomainEvent::FrontierLlmConsulted`] via the optional sink registered
70/// with [`CloudProvider::with_event_sink`].
71pub struct CloudProvider {
72    client: reqwest::blocking::Client,
73    event_sink: Option<Box<dyn Fn(DomainEvent) + Send + Sync>>,
74}
75
76impl CloudProvider {
77    /// Builds the provider with explicit network timeouts: [`CONNECT_TIMEOUT`]
78    /// for the handshake, [`IDLE_TIMEOUT`] per read (stream-friendly), and a
79    /// [`REQUEST_TIMEOUT`] total applied per non-streaming request. A stalled
80    /// provider can therefore never block an FFI caller indefinitely.
81    pub fn new() -> Self {
82        let client = reqwest::blocking::Client::builder()
83            .connect_timeout(CONNECT_TIMEOUT)
84            .timeout(IDLE_TIMEOUT)
85            .build()
86            .expect("static client configuration is valid");
87        Self {
88            client,
89            event_sink: None,
90        }
91    }
92
93    /// Register a callback that receives [`DomainEvent`]s (e.g. to feed a
94    /// [`el_telemetry::MetricsCollector`]).
95    pub fn with_event_sink(mut self, sink: impl Fn(DomainEvent) + Send + Sync + 'static) -> Self {
96        self.event_sink = Some(Box::new(sink));
97        self
98    }
99
100    fn emit(&self, event: DomainEvent) {
101        if let Some(sink) = &self.event_sink {
102            sink(event);
103        }
104    }
105
106    /// Consumes an SSE response body line-by-line, forwarding text deltas to
107    /// `on_token`. Factored out of [`LlmProvider::chat_stream`] so the
108    /// protocol handling is testable without a live HTTP connection.
109    ///
110    /// # Error contract
111    /// Every `data:` payload that is neither `[DONE]` nor a well-formed
112    /// [`WireStreamChunk`] fails the call with [`EdgeError::CloudRequest`] —
113    /// provider error objects (`{"error":{…}}`) and protocol corruption must
114    /// never be mistaken for a clean (possibly empty) completion. Malformed
115    /// payloads are reported by parse category/position/size only — never
116    /// echoed — per the el-core rule that errors carry no content. Non-`data`
117    /// SSE lines (comments/keepalives starting with `:`, `event:`/`id:`/
118    /// `retry:` fields, blank separators) are skipped per the SSE spec.
119    fn pump_sse(
120        &self,
121        reader: impl std::io::BufRead,
122        model: &str,
123        on_token: &mut dyn FnMut(ChatToken),
124    ) -> Result<()> {
125        for line in reader.lines() {
126            let line = line.map_err(|e| make_err(format!("cloud stream read: {e}")))?;
127            let line = line.trim();
128            if line.is_empty() || !line.starts_with("data:") {
129                continue;
130            }
131            let data = line["data:".len()..].trim();
132            if data == "[DONE]" {
133                self.emit(DomainEvent::FrontierLlmConsulted {
134                    provider_hash: provider_hash(model),
135                    prompt_tokens: 0,
136                    completion_tokens: 0,
137                });
138                on_token(ChatToken {
139                    text: String::new(),
140                    is_final: true,
141                });
142                return Ok(());
143            }
144            let chunk = match serde_json::from_str::<WireStreamChunk>(data) {
145                Ok(chunk) => chunk,
146                Err(e) => {
147                    if let Ok(err) = serde_json::from_str::<WireStreamError>(data) {
148                        return Err(make_err(format!(
149                            "cloud stream provider error: {}",
150                            err.error.message.as_deref().unwrap_or("unknown")
151                        )));
152                    }
153                    // EdgeError must never carry payload content (el-core
154                    // error contract) — a malformed chunk may embed generated
155                    // text. Report only parse category, position, and size.
156                    let category = match e.classify() {
157                        serde_json::error::Category::Io => "io",
158                        serde_json::error::Category::Syntax => "syntax",
159                        serde_json::error::Category::Data => "data",
160                        serde_json::error::Category::Eof => "eof",
161                    };
162                    return Err(make_err(format!(
163                        "cloud stream decode: {category} error at line {} column {} \
164                         in {}-byte payload (content withheld)",
165                        e.line(),
166                        e.column(),
167                        data.len()
168                    )));
169                }
170            };
171            for choice in chunk.choices {
172                if let Some(text) = choice.delta.content {
173                    if !text.is_empty() {
174                        on_token(ChatToken {
175                            text,
176                            is_final: false,
177                        });
178                    }
179                }
180                if choice.finish_reason.is_some() {
181                    self.emit(DomainEvent::FrontierLlmConsulted {
182                        provider_hash: provider_hash(model),
183                        prompt_tokens: 0,
184                        completion_tokens: 0,
185                    });
186                    on_token(ChatToken {
187                        text: String::new(),
188                        is_final: true,
189                    });
190                    return Ok(());
191                }
192            }
193        }
194        // Stream ended without [DONE] — emit final anyway.
195        on_token(ChatToken {
196            text: String::new(),
197            is_final: true,
198        });
199        // provider_hash is available but token counts aren't in streaming path;
200        // emit with zeros so the audit trail still fires.
201        self.emit(DomainEvent::FrontierLlmConsulted {
202            provider_hash: provider_hash(model),
203            prompt_tokens: 0,
204            completion_tokens: 0,
205        });
206        Ok(())
207    }
208}
209
210impl Default for CloudProvider {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl LlmProvider for CloudProvider {
217    fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
218        let (base_url, model_name) = resolve(&req.model);
219        let api_key = req
220            .credential
221            .as_ref()
222            .map(|c| c.as_str().to_owned())
223            .unwrap_or_default();
224
225        let messages: Vec<WireMessage> = req
226            .messages
227            .iter()
228            .map(|m| WireMessage {
229                role: wire_role(m.role),
230                content: &m.content,
231            })
232            .collect();
233
234        let body = WireRequest {
235            model: &model_name,
236            messages,
237            max_tokens: req.max_tokens,
238            temperature: req.temperature_milli as f32 / 1000.0,
239            stream: false,
240        };
241
242        let mut builder = self
243            .client
244            .post(format!("{base_url}/chat/completions"))
245            // Non-streaming: the body arrives in one read, so a total
246            // request deadline is appropriate (overrides the idle default).
247            .timeout(REQUEST_TIMEOUT)
248            .json(&body);
249
250        if !api_key.is_empty() {
251            builder = builder.bearer_auth(&api_key);
252        }
253
254        let resp = builder
255            .send()
256            .map_err(|e| make_err(format!("cloud send: {e}")))?
257            .error_for_status()
258            .map_err(|e| make_err(format!("cloud status: {e}")))?
259            .json::<WireResponse>()
260            .map_err(|e| make_err(format!("cloud decode: {e}")))?;
261
262        let content = resp
263            .choices
264            .into_iter()
265            .next()
266            .and_then(|c| c.message.content)
267            .unwrap_or_default();
268
269        let usage = resp.usage.unwrap_or_default();
270
271        self.emit(DomainEvent::FrontierLlmConsulted {
272            provider_hash: provider_hash(&req.model),
273            prompt_tokens: usage.prompt_tokens,
274            completion_tokens: usage.completion_tokens,
275        });
276
277        Ok(ChatResponse {
278            content,
279            model: resp.model,
280            prompt_tokens: usage.prompt_tokens,
281            completion_tokens: usage.completion_tokens,
282        })
283    }
284
285    fn chat_stream(&self, req: &ChatRequest, on_token: &mut dyn FnMut(ChatToken)) -> Result<()> {
286        let (base_url, model_name) = resolve(&req.model);
287        let api_key = req
288            .credential
289            .as_ref()
290            .map(|c| c.as_str().to_owned())
291            .unwrap_or_default();
292
293        let messages: Vec<WireMessage> = req
294            .messages
295            .iter()
296            .map(|m| WireMessage {
297                role: wire_role(m.role),
298                content: &m.content,
299            })
300            .collect();
301
302        let body = WireRequest {
303            model: &model_name,
304            messages,
305            max_tokens: req.max_tokens,
306            temperature: req.temperature_milli as f32 / 1000.0,
307            stream: true,
308        };
309
310        let mut builder = self
311            .client
312            .post(format!("{base_url}/chat/completions"))
313            .json(&body);
314
315        if !api_key.is_empty() {
316            builder = builder.bearer_auth(&api_key);
317        }
318
319        let resp = builder
320            .send()
321            .map_err(|e| make_err(format!("cloud stream send: {e}")))?
322            .error_for_status()
323            .map_err(|e| make_err(format!("cloud stream status: {e}")))?;
324
325        // SSE body: "data: {json}", "data: [DONE]", ":keepalive", blank separators.
326        self.pump_sse(std::io::BufReader::new(resp), &req.model, on_token)
327    }
328}
329
330/// Simple CRC32-style hash for provider name (for the `FrontierLlmConsulted`
331/// domain event — content-free audit trail).
332pub fn provider_hash(model: &str) -> u32 {
333    let (base, _) = resolve(model);
334    base.bytes()
335        .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn routing_resolves_known_prefixes() {
344        assert_eq!(resolve("openai/gpt-4o").0, "https://api.openai.com/v1");
345        assert_eq!(resolve("openai/gpt-4o").1, "gpt-4o");
346        assert_eq!(resolve("ollama/llama3").0, "http://localhost:11434/v1");
347        assert_eq!(resolve("ollama/llama3").1, "llama3");
348        assert_eq!(
349            resolve("anthropic/claude-sonnet-4-6").1,
350            "claude-sonnet-4-6"
351        );
352        assert_eq!(resolve("gemini/gemini-2.0-flash").1, "gemini-2.0-flash");
353    }
354
355    #[test]
356    fn routing_custom_url() {
357        let (base, model) = resolve("https://my.llm.server/v1/mistral-7b");
358        assert_eq!(base, "https://my.llm.server/v1");
359        assert_eq!(model, "mistral-7b");
360    }
361
362    #[test]
363    fn provider_hash_is_deterministic() {
364        let h1 = provider_hash("openai/gpt-4o");
365        let h2 = provider_hash("openai/gpt-4o-mini");
366        assert_eq!(h1, h2, "same provider, different model → same hash");
367        let h3 = provider_hash("anthropic/claude-3-5-sonnet");
368        assert_ne!(h1, h3, "different providers → different hashes");
369    }
370
371    #[test]
372    fn cloud_provider_constructs() {
373        let _ = CloudProvider::new();
374    }
375
376    #[test]
377    fn event_sink_receives_frontier_consulted_on_successful_chat() {
378        use el_core::DomainEvent;
379        use std::sync::{Arc, Mutex};
380
381        let events: Arc<Mutex<Vec<DomainEvent>>> = Arc::new(Mutex::new(Vec::new()));
382        let events_clone = Arc::clone(&events);
383
384        // Build a provider with an event sink but don't make a real HTTP call —
385        // call emit() directly to verify the sink wiring is correct.
386        let provider = CloudProvider::new().with_event_sink(move |ev| {
387            events_clone.lock().unwrap().push(ev);
388        });
389
390        // Simulate the event that chat() would emit after a real HTTP response.
391        provider.emit(DomainEvent::FrontierLlmConsulted {
392            provider_hash: provider_hash("openai/gpt-4o"),
393            prompt_tokens: 10,
394            completion_tokens: 5,
395        });
396
397        let captured = events.lock().unwrap();
398        assert_eq!(captured.len(), 1);
399        assert!(matches!(
400            captured[0],
401            DomainEvent::FrontierLlmConsulted {
402                prompt_tokens: 10,
403                completion_tokens: 5,
404                ..
405            }
406        ));
407    }
408
409    #[test]
410    fn provider_hash_same_for_same_provider_different_models() {
411        // Proves the hash is content-free: same base URL regardless of model suffix.
412        assert_eq!(
413            provider_hash("openai/gpt-4o"),
414            provider_hash("openai/gpt-4o-mini"),
415        );
416    }
417
418    // ── SSE protocol handling (pump_sse) ──────────────────────────────────
419
420    fn run_sse(body: &str) -> (Result<()>, Vec<ChatToken>) {
421        let provider = CloudProvider::new();
422        let mut tokens = Vec::new();
423        let result = provider.pump_sse(
424            std::io::Cursor::new(body.to_owned()),
425            "openai/gpt-4o",
426            &mut |t| tokens.push(t),
427        );
428        (result, tokens)
429    }
430
431    #[test]
432    fn stream_chunks_until_done_yield_tokens_and_final() {
433        let body = "data: {\"choices\":[{\"delta\":{\"content\":\"He\"},\"finish_reason\":null}]}\n\n\
434                    data: {\"choices\":[{\"delta\":{\"content\":\"llo\"},\"finish_reason\":null}]}\n\n\
435                    data: [DONE]\n\n";
436        let (result, tokens) = run_sse(body);
437        result.expect("well-formed stream must succeed");
438        let text: String = tokens.iter().map(|t| t.text.as_str()).collect();
439        assert_eq!(text, "Hello");
440        assert!(
441            tokens.last().unwrap().is_final,
442            "stream must end with a final token"
443        );
444    }
445
446    #[test]
447    fn stream_provider_error_payload_fails_the_call() {
448        let body = "data: {\"choices\":[{\"delta\":{\"content\":\"He\"},\"finish_reason\":null}]}\n\n\
449                    data: {\"error\":{\"message\":\"insufficient quota\",\"type\":\"insufficient_quota\"}}\n\n";
450        let (result, tokens) = run_sse(body);
451        let err = result.expect_err("provider error payload must fail the stream");
452        match err {
453            EdgeError::CloudRequest(msg) => {
454                assert!(
455                    msg.contains("insufficient quota"),
456                    "error must carry the provider message, got: {msg}"
457                );
458            }
459            other => panic!("expected CloudRequest, got {other:?}"),
460        }
461        assert!(
462            tokens.iter().all(|t| !t.is_final),
463            "a failed stream must not signal a clean completion"
464        );
465    }
466
467    #[test]
468    fn stream_malformed_payload_fails_the_call_without_echoing_content() {
469        let body = "data: {this is not json\n\n";
470        let (result, tokens) = run_sse(body);
471        let err = result.expect_err("malformed payload must fail the stream");
472        match err {
473            EdgeError::CloudRequest(msg) => {
474                assert!(
475                    msg.contains("decode"),
476                    "error must identify a decode failure, got: {msg}"
477                );
478                assert!(
479                    !msg.contains("this is not json"),
480                    "raw payload must never leak into EdgeError (el-core contract), got: {msg}"
481                );
482            }
483            other => panic!("expected CloudRequest, got {other:?}"),
484        }
485        assert!(tokens.iter().all(|t| !t.is_final));
486    }
487
488    #[test]
489    fn stream_keepalives_comments_and_events_are_skipped() {
490        let body = ": keep-alive\n\
491                    event: ping\n\
492                    id: 42\n\
493                    \n\
494                    data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n\
495                    data: [DONE]\n\n";
496        let (result, tokens) = run_sse(body);
497        result.expect("SSE comments/keepalives/fields must not fail the stream");
498        let text: String = tokens.iter().map(|t| t.text.as_str()).collect();
499        assert_eq!(text, "ok");
500    }
501
502    #[test]
503    fn stream_eof_without_done_still_finalizes() {
504        // Some OpenAI-compat servers close the connection after finish_reason
505        // without sending [DONE]; that is a complete (non-error) stream.
506        let body =
507            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n";
508        let (result, tokens) = run_sse(body);
509        result.expect("EOF without [DONE] is not a protocol error");
510        assert!(tokens.last().unwrap().is_final);
511    }
512
513    #[test]
514    fn stream_finish_reason_completes_without_done() {
515        let body =
516            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n";
517        let (result, tokens) = run_sse(body);
518        result.expect("finish_reason terminates the stream cleanly");
519        assert!(tokens.last().unwrap().is_final);
520    }
521}