1use std::cell::RefCell;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10
11use indexmap::IndexMap;
12use serde_json::{Map, Value, json};
13use tokio_util::sync::CancellationToken;
14use yapi_types::message::{
15 AssistantMessage, Content, ContentBlock, Message, StopReason, ThinkingLevel, ToolDeclaration,
16 blocks_text,
17};
18use yapi_types::model::Model;
19
20use crate::cost::calculate_cost;
21use crate::hash::short_hash;
22use crate::http::{self, Headers};
23use crate::json_parse::parse_streaming_json;
24use crate::schema;
25use crate::stream::{
26 CacheRetention, EventSender, Request, StreamEvent, StreamOptions, check_complete, new_output,
27 now_ms, send_error,
28};
29use crate::thinking::{effort, requested_max_tokens};
30use crate::transcript::{current_tools, resolve_transcript, transform_messages};
31
32const TOOL_CALL_ID_LENGTH: usize = 9;
33const MAX_ERROR_BODY_CHARS: usize = 4000;
34const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
36const TIMEOUT_MESSAGE: &str = "The operation was aborted due to timeout";
37
38fn derive_tool_call_id(id: &str, attempt: usize) -> String {
41 let normalized: String = id.chars().filter(char::is_ascii_alphanumeric).collect();
42 if attempt == 0 && normalized.chars().count() == TOOL_CALL_ID_LENGTH {
43 return normalized;
44 }
45 let base = if normalized.is_empty() {
46 id
47 } else {
48 &normalized
49 };
50 let seed = if attempt == 0 {
51 base.to_owned()
52 } else {
53 format!("{base}:{attempt}")
54 };
55 short_hash(&seed)
56 .chars()
57 .filter(char::is_ascii_alphanumeric)
58 .take(TOOL_CALL_ID_LENGTH)
59 .collect()
60}
61
62#[derive(Default)]
64struct IdNormalizer {
65 ids: HashMap<String, String>,
66 owners: HashMap<String, String>,
67}
68
69impl IdNormalizer {
70 fn normalize(&mut self, id: &str) -> String {
71 if let Some(existing) = self.ids.get(id) {
72 return existing.clone();
73 }
74 let mut attempt = 0;
75 loop {
76 let candidate = derive_tool_call_id(id, attempt);
77 if self.owners.get(&candidate).is_none_or(|owner| owner == id) {
78 self.ids.insert(id.to_owned(), candidate.clone());
79 self.owners.insert(candidate.clone(), id.to_owned());
80 return candidate;
81 }
82 attempt += 1;
83 }
84 }
85}
86
87fn image_url(mime_type: &str, data: &str) -> String {
88 format!("data:{mime_type};base64,{data}")
89}
90
91fn tool_result_text(text: &str, has_images: bool, supports_images: bool, is_error: bool) -> String {
93 let trimmed = text.trim();
94 let prefix = if is_error { "[tool error] " } else { "" };
95 if !trimmed.is_empty() {
96 let suffix = if has_images && !supports_images {
97 "\n[tool image omitted: model does not support images]"
98 } else {
99 ""
100 };
101 return format!("{prefix}{trimmed}{suffix}");
102 }
103 if has_images {
104 let body = if supports_images {
105 "(see attached image)"
106 } else {
107 "(image omitted: model does not support images)"
108 };
109 return format!("{prefix}{body}");
110 }
111 format!("{prefix}(no tool output)")
112}
113
114fn chat_messages(messages: &[Message], supports_images: bool) -> Vec<Value> {
117 let mut out = Vec::new();
118 for (index, message) in messages.iter().enumerate() {
119 match message {
120 Message::System(system) => {
121 let text = if index == 0 {
122 system.text()
123 } else {
124 system.render_update()
125 };
126 if !text.is_empty() {
127 out.push(json!({"role": "system", "content": text}));
128 }
129 }
130 Message::User(user) => match &user.content {
131 Content::Text(text) => out.push(json!({"role": "user", "content": text})),
132 Content::Blocks(blocks) => {
133 let had_images = blocks
134 .iter()
135 .any(|block| matches!(block, ContentBlock::Image(_)));
136 let content: Vec<Value> = blocks
137 .iter()
138 .filter_map(|block| match block {
139 ContentBlock::Text(text) => {
140 Some(json!({"type": "text", "text": text.text}))
141 }
142 ContentBlock::Image(image) if supports_images => Some(json!({
143 "type": "image_url",
144 "image_url": image_url(&image.mime_type, &image.data),
145 })),
146 _ => None,
147 })
148 .collect();
149 if !content.is_empty() {
150 out.push(json!({"role": "user", "content": content}));
151 } else if had_images && !supports_images {
152 out.push(json!({
153 "role": "user",
154 "content": "(image omitted: model does not support images)",
155 }));
156 }
157 }
158 },
159 Message::Assistant(assistant) => {
160 let mut content = Vec::new();
161 let mut calls = Vec::new();
162 for block in &assistant.content {
163 match block {
164 ContentBlock::Text(text) if !text.text.trim().is_empty() => {
165 content.push(json!({"type": "text", "text": text.text}));
166 }
167 ContentBlock::Thinking(thinking)
168 if !thinking.thinking.trim().is_empty() =>
169 {
170 content.push(json!({
171 "type": "thinking",
172 "thinking": [{"type": "text", "text": thinking.thinking}],
173 }));
174 }
175 ContentBlock::ToolCall(call) => calls.push(json!({
176 "id": call.id,
177 "type": "function",
178 "function": {
179 "name": call.name,
180 "arguments": yapi_types::json::stringify(&call.arguments),
181 },
182 "index": 0,
183 })),
184 _ => {}
185 }
186 }
187 if content.is_empty() && calls.is_empty() {
188 continue;
189 }
190 let mut item = Map::new();
191 item.insert("role".into(), json!("assistant"));
192 item.insert("prefix".into(), json!(false));
193 if !content.is_empty() {
194 item.insert("content".into(), Value::Array(content));
195 }
196 if !calls.is_empty() {
197 item.insert("tool_calls".into(), Value::Array(calls));
198 }
199 out.push(Value::Object(item));
200 }
201 Message::ToolResult(result) => {
202 let text = blocks_text(&result.content, "\n");
203 let has_images = result
204 .content
205 .iter()
206 .any(|block| matches!(block, ContentBlock::Image(_)));
207 let mut content = vec![json!({
208 "type": "text",
209 "text": tool_result_text(&text, has_images, supports_images, result.is_error),
210 })];
211 if supports_images {
212 for block in &result.content {
213 if let ContentBlock::Image(image) = block {
214 content.push(json!({
215 "type": "image_url",
216 "image_url": image_url(&image.mime_type, &image.data),
217 }));
218 }
219 }
220 }
221 out.push(json!({
222 "role": "tool",
223 "name": result.tool_name,
224 "content": content,
225 "tool_call_id": result.tool_call_id,
226 }));
227 }
228 _ => {}
229 }
230 }
231 out
232}
233
234fn function_tools(tools: &[ToolDeclaration]) -> Result<Vec<Value>, String> {
235 tools
236 .iter()
237 .map(|tool| {
238 let strict = schema::strict_sampling(tool, true, None)?;
239 Ok(json!({
240 "type": "function",
241 "function": {
242 "name": tool.name,
243 "description": tool.description,
244 "parameters": schema::tool_parameters(tool, strict),
245 "strict": strict,
246 },
247 }))
248 })
249 .collect()
250}
251
252fn reasoning(
255 model: &Model,
256 level: Option<ThinkingLevel>,
257) -> (Option<String>, Option<&'static str>) {
258 if !model.reasoning {
259 return (None, None);
260 }
261 if model.thinking_level_map.is_some() {
262 let effort = match level {
263 Some(level) => match model.thinking_level_value(level) {
264 Some(Some(value)) => Some(value.to_owned()),
265 _ => Some("high".to_owned()),
266 },
267 None => model
268 .thinking_level_value(ThinkingLevel::Off)
269 .flatten()
270 .map(str::to_owned),
271 };
272 return (effort, None);
273 }
274 (None, level.map(|_| "reasoning"))
275}
276
277fn uses_prompt_cache(options: &StreamOptions) -> Option<&str> {
278 options
279 .session_id
280 .as_deref()
281 .filter(|_| options.resolved_cache_retention() != CacheRetention::None)
282 .filter(|session| !session.is_empty())
283}
284
285fn build_payload(
286 model: &Model,
287 normalized: &[Message],
288 transformed: &[Message],
289 options: &StreamOptions,
290 max_tokens: u64,
291 level: Option<ThinkingLevel>,
292) -> Result<Value, String> {
293 let mut payload = Map::new();
294 payload.insert("model".into(), json!(model.id));
295 payload.insert("stream".into(), json!(true));
296 payload.insert(
297 "messages".into(),
298 Value::Array(chat_messages(transformed, model.accepts_images())),
299 );
300 let tools = current_tools(normalized);
301 if !tools.is_empty() {
302 payload.insert("tools".into(), Value::Array(function_tools(&tools)?));
303 }
304 if let Some(temperature) = options.temperature {
305 payload.insert("temperature".into(), json!(temperature));
306 }
307 payload.insert("max_tokens".into(), json!(max_tokens));
308 let (effort, mode) = reasoning(model, level);
309 if let Some(effort) = effort {
310 payload.insert("reasoning_effort".into(), json!(effort));
311 }
312 if let Some(mode) = mode {
313 payload.insert("prompt_mode".into(), json!(mode));
314 }
315 if let Some(session) = uses_prompt_cache(options) {
316 payload.insert("prompt_cache_key".into(), json!(session));
317 }
318 Ok(Value::Object(payload))
319}
320
321#[derive(Default)]
323struct EventParser {
324 buffer: String,
325}
326
327enum Parsed {
329 Event(Value),
330 Done,
331}
332
333fn boundary(buffer: &str) -> Option<(usize, usize)> {
334 const SEPARATORS: [&str; 8] = [
335 "\r\n\r\n", "\r\n\r", "\r\n\n", "\r\r\n", "\n\r\n", "\r\r", "\n\r", "\n\n",
336 ];
337 let bytes = buffer.as_bytes();
339 (0..bytes.len()).find_map(|index| {
340 SEPARATORS
341 .iter()
342 .find(|separator| bytes[index..].starts_with(separator.as_bytes()))
343 .map(|separator| (index, separator.len()))
344 })
345}
346
347fn parse_event(raw: &str) -> Result<Option<Parsed>, String> {
348 let data = raw
349 .split(['\r', '\n'])
350 .filter_map(|line| line.strip_prefix("data:"))
351 .map(str::trim_start)
352 .collect::<Vec<_>>()
353 .join("\n");
354 let data = data.trim();
355 if data.is_empty() {
356 return Ok(None);
357 }
358 if data == "[DONE]" {
359 return Ok(Some(Parsed::Done));
360 }
361 let parsed: Value = serde_json::from_str(data).map_err(|err| err.to_string())?;
362 if !parsed["choices"].is_array() {
363 return Err("Invalid Mistral streaming event".into());
364 }
365 Ok(Some(Parsed::Event(parsed)))
366}
367
368impl EventParser {
369 fn push(&mut self, text: &str) -> Result<Vec<Parsed>, String> {
371 self.buffer.push_str(text);
372 let mut events = Vec::new();
373 while let Some((index, length)) = boundary(&self.buffer) {
374 let raw = self.buffer[..index].to_owned();
375 self.buffer.drain(..index + length);
376 if let Some(event) = parse_event(&raw)? {
377 let done = matches!(event, Parsed::Done);
378 events.push(event);
379 if done {
380 break;
381 }
382 }
383 }
384 Ok(events)
385 }
386
387 fn finish(&mut self) -> Result<Option<Parsed>, String> {
388 if self.buffer.trim().is_empty() {
389 return Ok(None);
390 }
391 parse_event(&std::mem::take(&mut self.buffer))
392 }
393}
394
395fn map_stop_reason(reason: &str) -> (StopReason, Option<String>) {
397 match reason {
398 "stop" => (StopReason::Stop, None),
399 "length" | "model_length" => (StopReason::Length, None),
400 "tool_calls" => (StopReason::ToolUse, None),
401 other => (
402 StopReason::Error,
403 Some(format!("Provider stopped with: {other}")),
404 ),
405 }
406}
407
408#[derive(Clone, Copy, PartialEq, Eq)]
410enum Current {
411 Text,
412 Thinking,
413}
414
415struct State {
416 output: AssistantMessage,
417 current: Option<Current>,
418 tools: IndexMap<String, usize>,
420 partial_args: HashMap<usize, String>,
421}
422
423impl State {
424 fn last(&self) -> usize {
425 self.output.content.len().saturating_sub(1)
426 }
427
428 fn finish_current(&mut self, sender: &EventSender) {
429 if self.current.take().is_some() {
430 sender.end(&self.output, self.last());
431 }
432 }
433
434 fn append(&mut self, kind: Current, delta: &str, sender: &EventSender) {
436 if delta.is_empty() {
438 return;
439 }
440 if self.current != Some(kind) {
441 self.finish_current(sender);
442 self.current = Some(kind);
443 let block = match kind {
444 Current::Text => ContentBlock::text(""),
445 Current::Thinking => ContentBlock::thinking("", None),
446 };
447 sender.start(&mut self.output, block);
448 }
449 let index = self.last();
450 sender.delta(&mut self.output, index, delta);
451 }
452
453 fn tool_call(&mut self, call: &Value, sender: &EventSender) {
454 self.finish_current(sender);
455 let stream_index = call["index"].as_u64();
456 let id = call["id"]
457 .as_str()
458 .filter(|id| !id.is_empty() && *id != "null")
459 .map_or_else(
460 || derive_tool_call_id(&format!("toolcall:{}", stream_index.unwrap_or(0)), 0),
461 str::to_owned,
462 );
463 let key = stream_index.map_or_else(|| format!("id:{id}"), |index| format!("index:{index}"));
464 let position = match self.tools.get(&key) {
465 Some(position) => *position,
466 None => {
467 let name = call["function"]["name"]
468 .as_str()
469 .unwrap_or_default()
470 .to_owned();
471 let block = ContentBlock::tool_call(id, name, Map::new());
472 let position = sender.start(&mut self.output, block);
473 self.tools.insert(key, position);
474 self.partial_args.insert(position, String::new());
475 position
476 }
477 };
478 let arguments = &call["function"]["arguments"];
479 let delta = match arguments {
480 Value::String(text) => text.clone(),
481 Value::Null => "{}".to_owned(),
482 other => yapi_types::json::stringify(other),
483 };
484 self.partial_args
485 .entry(position)
486 .or_default()
487 .push_str(&delta);
488 sender.delta(&mut self.output, position, &delta);
489 }
490
491 fn handle(&mut self, chunk: &Value, model: &Model, sender: &EventSender) {
492 if self.output.response_id.as_deref().is_none_or(str::is_empty)
493 && let Some(id) = chunk["id"].as_str()
494 {
495 self.output.response_id = Some(id.to_owned());
496 }
497 let usage = &chunk["usage"];
498 if usage.is_object() {
499 let prompt = usage["prompt_tokens"].as_u64().unwrap_or(0);
500 let cached = [
501 &usage["promptTokensDetails"]["cachedTokens"],
502 &usage["prompt_tokens_details"]["cached_tokens"],
503 &usage["promptTokenDetails"]["cachedTokens"],
504 &usage["prompt_token_details"]["cached_tokens"],
505 &usage["numCachedTokens"],
506 &usage["num_cached_tokens"],
507 ]
508 .into_iter()
509 .find(|value| !value.is_null())
510 .and_then(Value::as_f64)
511 .filter(|value| value.is_finite())
512 .map_or(0, |value| (value.max(0.0) as u64).min(prompt));
513 let usage_out = &mut self.output.usage;
514 usage_out.input = prompt - cached;
515 usage_out.output = usage["completion_tokens"].as_u64().unwrap_or(0);
516 usage_out.cache_read = cached;
517 usage_out.cache_write = 0;
518 usage_out.total_tokens = Some(
519 usage["total_tokens"]
520 .as_u64()
521 .filter(|total| *total > 0)
522 .unwrap_or(usage_out.input + usage_out.output + usage_out.cache_read),
523 );
524 calculate_cost(model, &mut self.output.usage);
525 }
526 let Some(choice) = chunk["choices"].get(0) else {
527 return;
528 };
529 if let Some(reason) = choice["finish_reason"]
530 .as_str()
531 .filter(|reason| !reason.is_empty())
532 {
533 self.output.raw_stop_reason = Some(reason.to_owned());
534 let (stop, message) = map_stop_reason(reason);
535 self.output.stop_reason = stop;
536 if message.is_some() {
537 self.output.error_message = message;
538 }
539 }
540 let delta = &choice["delta"];
541 match &delta["content"] {
542 Value::String(text) => self.append(Current::Text, text, sender),
543 Value::Array(items) => {
544 for item in items {
545 match item {
546 Value::String(text) => self.append(Current::Text, text, sender),
547 _ if item["type"] == "thinking" => {
548 let text: String = item["thinking"]
549 .as_array()
550 .into_iter()
551 .flatten()
552 .filter_map(|part| part["text"].as_str())
553 .collect();
554 self.append(Current::Thinking, &text, sender);
555 }
556 _ if item["type"] == "text" => {
557 let text = item["text"].as_str().unwrap_or_default();
558 self.append(Current::Text, text, sender);
559 }
560 _ => {}
561 }
562 }
563 }
564 _ => {}
565 }
566 for call in delta["tool_calls"].as_array().into_iter().flatten() {
567 self.tool_call(call, sender);
568 }
569 }
570
571 fn parse_arguments(&mut self) {
573 for (position, partial) in &self.partial_args {
574 if let Some(ContentBlock::ToolCall(block)) = self.output.content.get_mut(*position) {
575 block.arguments = parse_streaming_json(partial);
576 }
577 }
578 }
579
580 fn finish(&mut self, sender: &EventSender) {
581 self.finish_current(sender);
582 for position in self.tools.values() {
583 sender.end(&self.output, *position);
584 }
585 }
586}
587
588fn start_timeout(token: &CancellationToken, timeout_ms: Option<f64>) -> Arc<AtomicBool> {
591 let limit = timeout_ms
592 .filter(|ms| ms.is_finite() && *ms >= 0.0)
593 .map_or(DEFAULT_TIMEOUT, |ms| Duration::from_millis(ms as u64));
594 let fired = Arc::new(AtomicBool::new(false));
595 let (token, flag) = (token.clone(), Arc::clone(&fired));
596 tokio::spawn(async move {
597 if token
598 .run_until_cancelled(tokio::time::sleep(limit))
599 .await
600 .is_some()
601 {
602 flag.store(true, Ordering::SeqCst);
603 token.cancel();
604 }
605 });
606 fired
607}
608
609pub(super) async fn run(request: Request, sender: EventSender) {
610 let Request {
611 model,
612 messages,
613 options,
614 } = request;
615 let output = new_output(&model, now_ms());
616 let cancel = options.cancel.child_token();
617 let timed_out = start_timeout(&cancel, options.timeout_ms);
618 let aborted = || {
619 if timed_out.load(Ordering::SeqCst) {
620 TIMEOUT_MESSAGE.to_owned()
621 } else {
622 http::ABORTED_READ.to_owned()
623 }
624 };
625 let response = async {
626 let compat: yapi_types::model::OpenAiResponsesCompat = model.compat();
627 let normalized = resolve_transcript(
628 &messages,
629 compat.supports_mid_convo_system_messages.unwrap_or(false),
630 );
631 let Some(api_key) = options.api_key.clone().filter(|key| !key.is_empty()) else {
632 return Err(format!("No API key for provider: {}", model.provider));
633 };
634 let transformed = {
635 let ids = RefCell::new(IdNormalizer::default());
636 let normalize = |id: &str, _: &AssistantMessage| ids.borrow_mut().normalize(id);
637 transform_messages(&normalized, &model, Some(&normalize), now_ms())
638 };
639 let max_tokens = requested_max_tokens(&model, &messages, &options);
640 let level = effort(&model, &options);
641 let payload = build_payload(
642 &model,
643 &normalized,
644 &transformed,
645 &options,
646 max_tokens,
647 level,
648 )?;
649 let payload = options.hooks.payload(payload).await;
650 let payload = yapi_types::json::stringify(&payload);
651
652 let mut headers = Headers::default();
653 headers.set("accept", Some("text/event-stream"));
654 headers.set("authorization", Some(format!("Bearer {api_key}")));
655 headers.set("content-type", Some("application/json"));
656 headers.extend_model(model.headers.as_ref());
657 headers.extend(&options.headers);
658 let explicit_affinity = model
659 .headers
660 .iter()
661 .flatten()
662 .map(|(name, _)| name)
663 .chain(options.headers.keys())
664 .any(|name| name.eq_ignore_ascii_case("x-affinity"));
665 if let Some(session) = uses_prompt_cache(&options)
666 && !explicit_affinity
667 {
668 headers.set("x-affinity", Some(session));
669 }
670
671 let url = format!(
672 "{}/v1/chat/completions",
673 model.base_url.trim_end_matches('/')
674 );
675 let request = headers.apply(http::client().post(&url).body(payload));
676 let Some(response) = cancel.run_until_cancelled(request.send()).await else {
677 return Err(aborted());
678 };
679 let response = response.map_err(|_| "fetch failed".to_owned())?;
680 let status = response.status();
681 if status.is_success() {
682 return Ok(response);
683 }
684 let Some(body) = cancel.run_until_cancelled(response.text()).await else {
685 return Err(aborted());
686 };
687 let body = body.unwrap_or_default();
688 let body = body.trim();
689 let text = if body.is_empty() {
690 status.canonical_reason().map_or_else(
691 || format!("Request failed with status {}", status.as_u16()),
692 str::to_owned,
693 )
694 } else {
695 http::truncate_chars(body, MAX_ERROR_BODY_CHARS)
696 };
697 Err(format!("Mistral API error ({}): {text}", status.as_u16()))
698 }
699 .await;
700 let mut response = match response {
701 Ok(response) => response,
702 Err(message) => {
703 cancel.cancel();
704 return send_error(&sender, output, &options.cancel, message);
705 }
706 };
707 options.hooks.response(&response).await;
708
709 sender.send(StreamEvent::Start(output.clone()));
710 let mut state = State {
711 output,
712 current: None,
713 tools: IndexMap::new(),
714 partial_args: HashMap::new(),
715 };
716 let mut parser = EventParser::default();
717 let mut pending = Vec::new();
718 let result: Result<(), String> = 'stream: loop {
719 let chunk = match http::read_chunk(&mut response, &cancel, http::ABORTED_READ).await {
720 Ok(Some(chunk)) => chunk,
721 Ok(None) => match parser.finish() {
722 Ok(Some(Parsed::Event(event))) => {
723 options.hooks.stream_event(&event).await;
724 state.handle(&event, &model, &sender);
725 break 'stream Ok(());
726 }
727 Ok(_) => break 'stream Ok(()),
728 Err(message) => break 'stream Err(message),
729 },
730 Err(_) if cancel.is_cancelled() => break 'stream Err(aborted()),
731 Err(message) => break 'stream Err(message),
732 };
733 let text = yapi_types::js::decode_utf8_stream(&mut pending, &chunk);
734 match parser.push(&text) {
735 Ok(events) => {
736 for event in events {
737 match event {
738 Parsed::Event(event) => {
739 options.hooks.stream_event(&event).await;
740 state.handle(&event, &model, &sender);
741 }
742 Parsed::Done => break 'stream Ok(()),
743 }
744 }
745 }
746 Err(message) => break 'stream Err(message),
747 }
748 };
749 cancel.cancel();
750 state.parse_arguments();
751 let result = result.map(|()| state.finish(&sender)).and_then(|()| {
752 check_complete(
753 &state.output,
754 &options.cancel,
755 "Mistral stream ended without a finish reason",
756 )
757 });
758 match result {
759 Ok(()) => sender.send(StreamEvent::Done(state.output)),
760 Err(message) => send_error(&sender, state.output, &options.cancel, message),
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn derives_tool_call_ids() {
770 assert_eq!(derive_tool_call_id("abcDEF123", 0), "abcDEF123");
771 let mut ids = IdNormalizer::default();
772 let first = ids.normalize("call_1|fc_long_item");
773 assert_eq!(first.len(), 9);
774 assert_eq!(ids.normalize("call_1|fc_long_item"), first);
775 assert_ne!(ids.normalize("call_2|fc_other"), first);
776 }
777
778 #[test]
779 fn splits_on_blank_lines() {
780 let mut parser = EventParser::default();
781 let events = parser
782 .push("data: {\"choices\":[]}\r\n\r\ndata: {\"choices\":[1]}\n\ndata: [DONE]\n\n")
783 .unwrap();
784 assert_eq!(events.len(), 3);
785 assert!(matches!(events[2], Parsed::Done));
786 assert!(parser.push("data: {\"x\":1}\n\n").is_err());
787 }
788
789 #[test]
790 fn non_ascii_events() {
791 let mut parser = EventParser::default();
792 let events = parser
793 .push("data: {\"choices\":[{\"delta\":{\"content\":\"café\"}}]}\n\n")
794 .unwrap();
795 let [Parsed::Event(event)] = events.as_slice() else {
796 panic!("expected one event");
797 };
798 assert_eq!(event["choices"][0]["delta"]["content"], "café");
799 }
800}