1use std::convert::Infallible;
8
9use axum::response::sse::{Event, KeepAlive, Sse};
10use axum::response::{IntoResponse, Response};
11use embacle::types::{ChatStream, RunnerError, StreamChunk};
12use futures::StreamExt;
13use futures::{future, stream};
14
15use crate::completions::{generate_id, generate_tool_call_id, unix_timestamp};
16use crate::openai_types::{
17 ChatCompletionChunk, ChunkChoice, Delta, ResponseMessage, ToolCall, ToolCallFunction,
18};
19
20pub fn sse_response(stream: ChatStream, model: &str) -> Response {
28 let completion_id = generate_id();
29 let created = unix_timestamp();
30 let model = model.to_owned();
31
32 let sse_stream = {
33 let mut sent_role = false;
34
35 stream.map(move |chunk_result| {
36 match chunk_result {
37 Ok(chunk) => {
38 let (role, content, finish_reason) = if !sent_role {
39 sent_role = true;
40 if chunk.delta.is_empty() && !chunk.is_final {
41 (Some("assistant"), None, None)
43 } else {
44 (Some("assistant"), Some(chunk.delta), chunk.finish_reason)
46 }
47 } else if chunk.is_final {
48 (
49 None,
50 if chunk.delta.is_empty() {
51 None
52 } else {
53 Some(chunk.delta)
54 },
55 Some(chunk.finish_reason.unwrap_or_else(|| "stop".to_owned())),
56 )
57 } else {
58 (None, Some(chunk.delta), None)
59 };
60
61 let content = content.map(|c| {
64 if !c.is_empty() && !c.ends_with('\n') {
65 let mut normalized = c;
66 normalized.push('\n');
67 normalized
68 } else {
69 c
70 }
71 });
72
73 let data = ChatCompletionChunk {
74 id: completion_id.clone(),
75 object: "chat.completion.chunk",
76 created,
77 model: model.clone(),
78 choices: vec![ChunkChoice {
79 index: 0,
80 delta: Delta {
81 role,
82 content,
83 tool_calls: None,
84 },
85 finish_reason,
86 }],
87 };
88
89 let json = serde_json::to_string(&data).unwrap_or_default();
90 Ok::<_, Infallible>(Event::default().data(json))
91 }
92 Err(e) => {
93 let error_json = serde_json::json!({
94 "error": {
95 "message": e.message,
96 "type": "stream_error"
97 }
98 });
99 Ok(Event::default().data(error_json.to_string()))
100 }
101 }
102 })
103 };
104
105 let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
107
108 let combined = sse_stream.chain(done_stream);
109
110 Sse::new(combined)
111 .keep_alive(KeepAlive::default())
112 .into_response()
113}
114
115pub fn sse_response_strip_fences(stream: ChatStream, model: &str) -> Response {
121 let filtered = strip_fence_chunks(stream);
122 sse_response(filtered, model)
123}
124
125fn strip_fence_chunks(stream: ChatStream) -> ChatStream {
131 use embacle::types::StreamChunk;
132
133 Box::pin(stream.filter_map(|result| async move {
134 match result {
135 Ok(chunk) => {
136 if is_markdown_fence(&chunk.delta) {
137 if chunk.is_final {
138 Some(Ok(StreamChunk {
140 delta: String::new(),
141 is_final: true,
142 finish_reason: chunk.finish_reason,
143 }))
144 } else {
145 None
146 }
147 } else {
148 Some(Ok(chunk))
149 }
150 }
151 Err(e) => Some(Err(e)),
152 }
153 }))
154}
155
156fn is_markdown_fence(text: &str) -> bool {
158 let trimmed = text.trim();
159 trimmed.starts_with("```") && trimmed.bytes().skip(3).all(|b| b.is_ascii_alphanumeric())
160}
161
162pub fn sse_single_response(message: ResponseMessage, finish_reason: &str, model: &str) -> Response {
170 let completion_id = generate_id();
171 let created = unix_timestamp();
172
173 let content_chunk = ChatCompletionChunk {
174 id: completion_id.clone(),
175 object: "chat.completion.chunk",
176 created,
177 model: model.to_owned(),
178 choices: vec![ChunkChoice {
179 index: 0,
180 delta: Delta {
181 role: Some("assistant"),
182 content: message.content,
183 tool_calls: message.tool_calls,
184 },
185 finish_reason: None,
186 }],
187 };
188
189 let final_chunk = ChatCompletionChunk {
190 id: completion_id,
191 object: "chat.completion.chunk",
192 created,
193 model: model.to_owned(),
194 choices: vec![ChunkChoice {
195 index: 0,
196 delta: Delta {
197 role: None,
198 content: None,
199 tool_calls: None,
200 },
201 finish_reason: Some(finish_reason.to_owned()),
202 }],
203 };
204
205 let events = vec![
206 serde_json::to_string(&content_chunk).unwrap_or_default(),
207 serde_json::to_string(&final_chunk).unwrap_or_default(),
208 ];
209
210 let event_stream = stream::iter(
211 events
212 .into_iter()
213 .map(|json| Ok::<_, Infallible>(Event::default().data(json))),
214 );
215 let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
216
217 let combined = event_stream.chain(done_stream);
218
219 Sse::new(combined)
220 .keep_alive(KeepAlive::default())
221 .into_response()
222}
223
224const TOOL_OPEN: &str = "<tool_call>";
226const TOOL_CLOSE: &str = "</tool_call>";
228
229enum Emit {
231 Content(String),
233 Tool(ToolCall),
235}
236
237#[derive(Clone, Copy, PartialEq, Eq)]
239enum ScanMode {
240 Text,
242 Tool,
244}
245
246struct ToolStreamState {
253 buffer: String,
255 mode: ScanMode,
257 tool_index: usize,
259 emitted_tool: bool,
261 sent_role: bool,
263 finished: bool,
265}
266
267impl ToolStreamState {
268 fn new() -> Self {
269 Self {
270 buffer: String::new(),
271 mode: ScanMode::Text,
272 tool_index: 0,
273 emitted_tool: false,
274 sent_role: false,
275 finished: false,
276 }
277 }
278
279 fn process(&mut self, incoming: &str) -> Vec<Emit> {
281 self.buffer.push_str(incoming);
282 let mut out = Vec::new();
283
284 loop {
285 if self.mode == ScanMode::Tool {
286 if let Some(idx) = self.buffer.find(TOOL_CLOSE) {
287 let inner = self.buffer[..idx].to_owned();
288 self.buffer.drain(..idx + TOOL_CLOSE.len());
289 self.mode = ScanMode::Text;
290 if let Some(tool) = self.parse_tool(&inner) {
291 out.push(Emit::Tool(tool));
292 }
293 continue;
294 }
295 break;
296 }
297
298 if let Some(idx) = self.buffer.find(TOOL_OPEN) {
299 if idx > 0 {
300 out.push(Emit::Content(self.buffer[..idx].to_owned()));
301 }
302 self.buffer.drain(..idx + TOOL_OPEN.len());
303 self.mode = ScanMode::Tool;
304 continue;
305 }
306
307 let safe = safe_prefix_len(&self.buffer);
310 if safe > 0 {
311 out.push(Emit::Content(self.buffer[..safe].to_owned()));
312 self.buffer.drain(..safe);
313 }
314 break;
315 }
316
317 out
318 }
319
320 fn finalize(&mut self) -> Vec<Emit> {
325 let mut out = Vec::new();
326 if !self.buffer.is_empty() {
327 let mut remainder = String::new();
328 if self.mode == ScanMode::Tool {
329 remainder.push_str(TOOL_OPEN);
330 }
331 remainder.push_str(&self.buffer);
332 self.buffer.clear();
333 out.push(Emit::Content(remainder));
334 }
335 self.mode = ScanMode::Text;
336 out
337 }
338
339 fn parse_tool(&mut self, inner: &str) -> Option<ToolCall> {
341 let block = format!("{TOOL_OPEN}{inner}{TOOL_CLOSE}");
342 let call = embacle::parse_tool_call_blocks(&block).into_iter().next()?;
343 let index = self.tool_index;
344 self.tool_index += 1;
345 self.emitted_tool = true;
346 Some(ToolCall {
347 index,
348 id: generate_tool_call_id(&call.name, index),
349 tool_type: "function".to_owned(),
350 function: ToolCallFunction {
351 name: call.name,
352 arguments: serde_json::to_string(&call.args).unwrap_or_else(|_| "{}".to_owned()),
353 },
354 })
355 }
356
357 fn finish_reason(&self, provider: Option<String>) -> String {
359 if self.emitted_tool {
360 "tool_calls".to_owned()
361 } else {
362 provider.unwrap_or_else(|| "stop".to_owned())
363 }
364 }
365
366 fn take_role(&mut self) -> Option<&'static str> {
368 if self.sent_role {
369 None
370 } else {
371 self.sent_role = true;
372 Some("assistant")
373 }
374 }
375}
376
377fn safe_prefix_len(buffer: &str) -> usize {
384 let max = (TOOL_OPEN.len() - 1).min(buffer.len());
385 for k in (1..=max).rev() {
386 if buffer.as_bytes().ends_with(&TOOL_OPEN.as_bytes()[..k]) {
387 return buffer.len() - k;
388 }
389 }
390 buffer.len()
391}
392
393pub fn sse_response_with_tool_calls(stream: ChatStream, model: &str) -> Response {
400 let id = generate_id();
401 let created = unix_timestamp();
402 let model = model.to_owned();
403
404 let mapped = stream
405 .scan(ToolStreamState::new(), move |state, chunk_result| {
406 let events = handle_tool_chunk(state, chunk_result, &id, created, &model);
407 future::ready(Some(stream::iter(events)))
408 })
409 .flatten();
410
411 let done_stream = stream::once(async { Ok::<_, Infallible>(Event::default().data("[DONE]")) });
412 let combined = mapped.chain(done_stream);
413
414 Sse::new(combined)
415 .keep_alive(KeepAlive::default())
416 .into_response()
417}
418
419fn handle_tool_chunk(
421 state: &mut ToolStreamState,
422 chunk_result: Result<StreamChunk, RunnerError>,
423 id: &str,
424 created: u64,
425 model: &str,
426) -> Vec<Result<Event, Infallible>> {
427 match chunk_result {
428 Ok(chunk) => {
429 let mut emits = state.process(&chunk.delta);
430 if chunk.is_final {
431 emits.extend(state.finalize());
432 }
433
434 let mut events: Vec<Result<Event, Infallible>> = emits
435 .into_iter()
436 .map(|emit| Ok(emit_to_event(state, emit, id, created, model)))
437 .collect();
438
439 if chunk.is_final && !state.finished {
440 state.finished = true;
441 let reason = state.finish_reason(chunk.finish_reason);
442 events.push(Ok(final_tool_event(id, created, model, &reason)));
443 }
444
445 events
446 }
447 Err(e) => {
448 let error_json = serde_json::json!({
449 "error": { "message": e.message, "type": "stream_error" }
450 });
451 vec![Ok(Event::default().data(error_json.to_string()))]
452 }
453 }
454}
455
456fn emit_to_event(
458 state: &mut ToolStreamState,
459 emit: Emit,
460 id: &str,
461 created: u64,
462 model: &str,
463) -> Event {
464 let role = state.take_role();
465 let delta = match emit {
466 Emit::Content(text) => {
467 let content = if !text.is_empty() && !text.ends_with('\n') {
470 format!("{text}\n")
471 } else {
472 text
473 };
474 Delta {
475 role,
476 content: Some(content),
477 tool_calls: None,
478 }
479 }
480 Emit::Tool(tool_call) => Delta {
481 role,
482 content: None,
483 tool_calls: Some(vec![tool_call]),
484 },
485 };
486
487 let chunk = ChatCompletionChunk {
488 id: id.to_owned(),
489 object: "chat.completion.chunk",
490 created,
491 model: model.to_owned(),
492 choices: vec![ChunkChoice {
493 index: 0,
494 delta,
495 finish_reason: None,
496 }],
497 };
498 Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
499}
500
501fn final_tool_event(id: &str, created: u64, model: &str, reason: &str) -> Event {
503 let chunk = ChatCompletionChunk {
504 id: id.to_owned(),
505 object: "chat.completion.chunk",
506 created,
507 model: model.to_owned(),
508 choices: vec![ChunkChoice {
509 index: 0,
510 delta: Delta {
511 role: None,
512 content: None,
513 tool_calls: None,
514 },
515 finish_reason: Some(reason.to_owned()),
516 }],
517 };
518 Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn is_markdown_fence_detects_fences() {
527 assert!(is_markdown_fence("```json\n"));
528 assert!(is_markdown_fence("```\n"));
529 assert!(is_markdown_fence("```json"));
530 assert!(is_markdown_fence("```"));
531 assert!(is_markdown_fence(" ```json "));
532 }
533
534 #[test]
535 fn is_markdown_fence_rejects_non_fences() {
536 assert!(!is_markdown_fence("{\"key\": \"value\"}"));
537 assert!(!is_markdown_fence("some text"));
538 assert!(!is_markdown_fence(""));
539 assert!(!is_markdown_fence("```json is cool```"));
540 assert!(!is_markdown_fence("``` code here"));
541 }
542
543 fn collect_content(emits: &[Emit]) -> String {
545 emits
546 .iter()
547 .filter_map(|e| match e {
548 Emit::Content(c) => Some(c.as_str()),
549 Emit::Tool(_) => None,
550 })
551 .collect()
552 }
553
554 fn collect_tools(emits: &[Emit]) -> Vec<&ToolCall> {
556 emits
557 .iter()
558 .filter_map(|e| match e {
559 Emit::Tool(t) => Some(t),
560 Emit::Content(_) => None,
561 })
562 .collect()
563 }
564
565 #[test]
566 fn safe_prefix_holds_back_partial_marker() {
567 assert_eq!(safe_prefix_len("abc<tool"), 3);
569 assert_eq!(safe_prefix_len("hello world"), 11);
571 assert_eq!(safe_prefix_len("done<"), 4);
573 assert_eq!(safe_prefix_len("a < b"), 5);
575 }
576
577 #[test]
578 fn process_passes_through_prose() {
579 let mut state = ToolStreamState::new();
580 let emits = state.process("Hello, world!");
581 assert_eq!(collect_content(&emits), "Hello, world!");
582 assert!(collect_tools(&emits).is_empty());
583 }
584
585 #[test]
586 fn process_extracts_text_then_tool_call() {
587 let mut state = ToolStreamState::new();
588 let input =
589 "Let me check.<tool_call>{\"name\":\"get_weather\",\"arguments\":{\"city\":\"Paris\"}}</tool_call>";
590 let emits = state.process(input);
591 assert_eq!(collect_content(&emits), "Let me check.");
592 let tools = collect_tools(&emits);
593 assert_eq!(tools.len(), 1);
594 assert_eq!(tools[0].function.name, "get_weather");
595 assert!(tools[0].function.arguments.contains("Paris"));
596 assert_eq!(tools[0].index, 0);
597 assert_eq!(tools[0].id, "call_get_weather_0");
598 assert!(state.emitted_tool);
599 }
600
601 #[test]
602 fn process_handles_marker_split_across_chunks() {
603 let mut state = ToolStreamState::new();
604 let mut all = Vec::new();
606 all.extend(state.process("answer<tool"));
607 all.extend(state.process("_call>{\"name\":\"ping\","));
608 all.extend(state.process("\"arguments\":{}}</tool_call> done"));
609 assert_eq!(collect_content(&all), "answer done");
612 let tools = collect_tools(&all);
613 assert_eq!(tools.len(), 1);
614 assert_eq!(tools[0].function.name, "ping");
615 }
616
617 #[test]
618 fn process_handles_multiple_tool_calls() {
619 let mut state = ToolStreamState::new();
620 let input = "<tool_call>{\"name\":\"a\",\"arguments\":{}}</tool_call><tool_call>{\"name\":\"b\",\"arguments\":{}}</tool_call>";
621 let emits = state.process(input);
622 let tools = collect_tools(&emits);
623 assert_eq!(tools.len(), 2);
624 assert_eq!(tools[0].index, 0);
625 assert_eq!(tools[1].index, 1);
626 assert_eq!(tools[1].id, "call_b_1");
627 }
628
629 #[test]
630 fn finalize_flushes_held_back_partial_as_prose() {
631 let mut state = ToolStreamState::new();
632 let mut all = state.process("almost done<");
634 all.extend(state.finalize());
635 assert_eq!(collect_content(&all), "almost done<");
636 }
637
638 #[test]
639 fn finalize_flushes_unterminated_tool_block_as_prose() {
640 let mut state = ToolStreamState::new();
641 let mut all = state.process("<tool_call>{\"name\":\"x\"");
642 all.extend(state.finalize());
643 assert!(collect_content(&all).contains("<tool_call>"));
645 assert!(collect_content(&all).contains("\"name\":\"x\""));
646 }
647
648 #[test]
649 fn finish_reason_reflects_tool_emission() {
650 let mut state = ToolStreamState::new();
651 assert_eq!(state.finish_reason(None), "stop");
652 assert_eq!(state.finish_reason(Some("length".to_owned())), "length");
653 state.emitted_tool = true;
654 assert_eq!(state.finish_reason(None), "tool_calls");
655 }
656
657 #[test]
658 fn take_role_emits_assistant_once() {
659 let mut state = ToolStreamState::new();
660 assert_eq!(state.take_role(), Some("assistant"));
661 assert_eq!(state.take_role(), None);
662 }
663
664 #[tokio::test]
665 async fn sse_with_tool_calls_emits_done_and_finish() {
666 use axum::body::to_bytes;
667
668 let chunks = vec![
669 Ok(StreamChunk {
670 delta: "Working<tool_call>{\"name\":\"ping\",\"arguments\":{}}</tool_call>"
671 .to_owned(),
672 is_final: false,
673 finish_reason: None,
674 }),
675 Ok(StreamChunk {
676 delta: String::new(),
677 is_final: true,
678 finish_reason: Some("stop".to_owned()),
679 }),
680 ];
681 let input: ChatStream = Box::pin(stream::iter(chunks));
682 let response = sse_response_with_tool_calls(input, "copilot:gpt-5.4");
683 let body = to_bytes(response.into_body(), usize::MAX)
684 .await
685 .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("\"tool_calls\""));
689 assert!(text.contains("ping"));
690 assert!(text.contains("\"finish_reason\":\"tool_calls\""));
692 assert!(text.contains("[DONE]"));
693 }
694
695 #[tokio::test]
696 async fn strip_fence_chunks_removes_fences() {
697 use embacle::types::StreamChunk;
698
699 let chunks = vec![
700 Ok(StreamChunk {
701 delta: "```json\n".to_owned(),
702 is_final: false,
703 finish_reason: None,
704 }),
705 Ok(StreamChunk {
706 delta: "{\"key\":\"value\"}\n".to_owned(),
707 is_final: false,
708 finish_reason: None,
709 }),
710 Ok(StreamChunk {
711 delta: "```\n".to_owned(),
712 is_final: true,
713 finish_reason: Some("stop".to_owned()),
714 }),
715 ];
716
717 let input: ChatStream = Box::pin(stream::iter(chunks));
718 let filtered = strip_fence_chunks(input);
719
720 let results: Vec<_> = filtered.collect().await;
721 assert_eq!(results.len(), 2);
722
723 let first = results[0].as_ref().unwrap(); assert_eq!(first.delta, "{\"key\":\"value\"}\n");
726 assert!(!first.is_final);
727
728 let second = results[1].as_ref().unwrap(); assert!(second.delta.is_empty());
731 assert!(second.is_final);
732 assert_eq!(second.finish_reason.as_deref(), Some("stop"));
733 }
734}