1use crate::openai_wire::{
4 WireMessage, WireRequest, WireResponse, WireStreamChunk, WireStreamError,
5};
6use el_core::{
7 ChatRequest, ChatResponse, ChatRole, ChatToken, DomainEvent, EdgeError, LlmProvider, Result,
8};
9
10fn 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 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 ("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
49const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
51const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
57const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
59
60pub struct CloudProvider {
72 client: reqwest::blocking::Client,
73 event_sink: Option<Box<dyn Fn(DomainEvent) + Send + Sync>>,
74}
75
76impl CloudProvider {
77 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 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 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 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 on_token(ChatToken {
196 text: String::new(),
197 is_final: true,
198 });
199 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 .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 self.pump_sse(std::io::BufReader::new(resp), &req.model, on_token)
327 }
328}
329
330pub 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 let provider = CloudProvider::new().with_event_sink(move |ev| {
387 events_clone.lock().unwrap().push(ev);
388 });
389
390 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 assert_eq!(
413 provider_hash("openai/gpt-4o"),
414 provider_hash("openai/gpt-4o-mini"),
415 );
416 }
417
418 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 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}