main
rs 1,619 lines 53.9 KB
Raw
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use std::collections::BTreeSet;
5 use std::fmt;
6 use std::sync::Arc;
7 use std::time::Duration;
8
9 use futures::StreamExt as _;
10 use tokio::time::timeout;
11 use vllm_chat::{
12 AssistantBlockKind, AssistantContentBlock, AssistantMessageExt as _, ChatBackend, ChatEvent,
13 ChatLlm, ChatMessage, ChatRenderer, ChatRequest, ChatRole, ChatTextBackend, ChatTool,
14 ChatToolChoice, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer,
15 FinishReason, GenerationPromptMode, NewChatOutputProcessorOptions, ParserSelection,
16 RenderedPrompt, SamplingParams,
17 };
18 use vllm_engine_core_client::protocol::logprobs::{
19 Logprobs, MaybeWireLogprobs, PositionLogprobs, TokenLogprob,
20 };
21 use vllm_engine_core_client::protocol::output::{
22 EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, StopReason,
23 };
24 use vllm_engine_core_client::protocol::request::EngineCoreRequest;
25 use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task};
26 use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig};
27 use vllm_llm::Llm;
28 use vllm_text::tokenizer::DynTokenizer;
29 use vllm_text::{
30 DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTokenLogprob, Prompt,
31 TextBackend,
32 };
33 use vllm_tokenizer::test_utils::TestTokenizer;
34 use zeromq::prelude::{SocketRecv, SocketSend};
35 use zeromq::{DealerSocket, PushSocket, ZmqMessage};
36
37 const SPECIAL_STOP_TOKEN_ID: u32 = 256;
38 const UNKNOWN_DECODE_TOKEN_ID: u32 = 10_000;
39
40 fn request_output(
41 request_id: &str,
42 new_token_ids: Vec<u32>,
43 finish_reason: Option<EngineCoreFinishReason>,
44 stop_reason: Option<StopReason>,
45 ) -> EngineCoreOutput {
46 EngineCoreOutput {
47 request_id: request_id.to_string(),
48 new_token_ids,
49 finish_reason,
50 stop_reason,
51 ..Default::default()
52 }
53 }
54
55 fn request_output_with_logprobs(
56 request_id: &str,
57 new_token_ids: Vec<u32>,
58 finish_reason: Option<EngineCoreFinishReason>,
59 stop_reason: Option<StopReason>,
60 new_logprobs: Option<Logprobs>,
61 new_prompt_logprobs_tensors: Option<Logprobs>,
62 ) -> EngineCoreOutput {
63 EngineCoreOutput {
64 request_id: request_id.to_string(),
65 new_token_ids,
66 new_logprobs: new_logprobs.map(MaybeWireLogprobs::Direct),
67 new_prompt_logprobs_tensors: new_prompt_logprobs_tensors.map(MaybeWireLogprobs::Direct),
68 finish_reason,
69 stop_reason,
70 ..Default::default()
71 }
72 }
73
74 fn sample_logprobs_for_token(token_id: u32, alternate_token_id: u32) -> Logprobs {
75 Logprobs {
76 positions: vec![PositionLogprobs {
77 entries: vec![
78 TokenLogprob {
79 token_id,
80 logprob: -0.1,
81 rank: 1,
82 },
83 TokenLogprob {
84 token_id: alternate_token_id,
85 logprob: -0.2,
86 rank: 2,
87 },
88 ],
89 }],
90 }
91 }
92
93 fn prompt_logprobs_for_hi() -> Logprobs {
94 Logprobs {
95 positions: vec![PositionLogprobs {
96 entries: vec![
97 TokenLogprob {
98 token_id: b'i' as u32,
99 logprob: -0.3,
100 rank: 1,
101 },
102 TokenLogprob {
103 token_id: b'!' as u32,
104 logprob: -0.4,
105 rank: 2,
106 },
107 ],
108 }],
109 }
110 }
111
112 fn bytes_to_token_ids(bytes: &[u8]) -> Vec<u32> {
113 bytes.iter().map(|byte| u32::from(*byte)).collect()
114 }
115
116 fn bytes_with_special_stop_token(bytes: &[u8]) -> Vec<u32> {
117 let mut token_ids = bytes_to_token_ids(bytes);
118 token_ids.push(SPECIAL_STOP_TOKEN_ID);
119 token_ids
120 }
121
122 async fn send_outputs(push: &mut PushSocket, outputs: EngineCoreOutputs) {
123 push.send(ZmqMessage::from(rmp_serde::to_vec_named(&outputs).unwrap()))
124 .await
125 .unwrap();
126 }
127
128 async fn recv_engine_message(dealer: &mut DealerSocket) -> Vec<bytes::Bytes> {
129 dealer.recv().await.unwrap().into_vec()
130 }
131
132 async fn connect_chat_llm_with_ipc(
133 config: EngineCoreClientConfig,
134 ipc: &IpcNamespace,
135 backend: Arc<dyn ChatTextBackend>,
136 ) -> ChatLlm {
137 let client = EngineCoreClient::connect(config.with_local_input_output_addresses(
138 Some(ipc.input_endpoint()),
139 Some(ipc.output_endpoint()),
140 ))
141 .await
142 .unwrap();
143 ChatLlm::from_shared_backend(
144 Llm::new(client).with_request_id_randomization(false),
145 backend,
146 )
147 }
148
149 #[derive(Clone)]
150 struct FakeChatBackend {
151 has_template: bool,
152 model_id: String,
153 tokenizer: DynTokenizer,
154 }
155
156 fn fake_chat_tokenizer() -> TestTokenizer {
157 TestTokenizer::new()
158 .with_special_token("<stop>", SPECIAL_STOP_TOKEN_ID)
159 .with_regular_token("<think>", 0xF001)
160 .with_regular_token("</think>", 0xF002)
161 .with_regular_token("<|START_THINKING|>", 0xF003)
162 .with_regular_token("<|END_THINKING|>", 0xF004)
163 .with_regular_token("◁think▷", 0xF005)
164 .with_regular_token("◁/think▷", 0xF006)
165 }
166
167 impl fmt::Debug for FakeChatBackend {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 f.debug_struct("FakeChatBackend").finish_non_exhaustive()
170 }
171 }
172
173 impl FakeChatBackend {
174 fn new() -> Self {
175 Self {
176 has_template: true,
177 model_id: "test-model".to_string(),
178 tokenizer: Arc::new(fake_chat_tokenizer()),
179 }
180 }
181
182 fn without_template() -> Self {
183 Self {
184 has_template: false,
185 model_id: "test-model".to_string(),
186 tokenizer: Arc::new(fake_chat_tokenizer()),
187 }
188 }
189
190 fn with_model_id(model_id: impl Into<String>) -> Self {
191 Self {
192 has_template: true,
193 model_id: model_id.into(),
194 tokenizer: Arc::new(fake_chat_tokenizer()),
195 }
196 }
197
198 fn with_tokenizer(mut self, tokenizer: DynTokenizer) -> Self {
199 self.tokenizer = tokenizer;
200 self
201 }
202 }
203
204 impl TextBackend for FakeChatBackend {
205 fn tokenizer(&self) -> DynTokenizer {
206 Arc::clone(&self.tokenizer)
207 }
208
209 fn model_id(&self) -> &str {
210 &self.model_id
211 }
212 }
213
214 impl ChatBackend for FakeChatBackend {
215 fn chat_renderer(&self) -> DynChatRenderer {
216 Arc::new(self.clone())
217 }
218
219 fn new_chat_output_processor(
220 &self,
221 request: &mut ChatRequest,
222 options: NewChatOutputProcessorOptions<'_>,
223 ) -> vllm_chat::Result<DynChatOutputProcessor> {
224 Ok(Box::new(DefaultChatOutputProcessor::new(
225 request,
226 &self.model_id,
227 self.tokenizer(),
228 options.tool_call_parser,
229 options.reasoning_parser,
230 options.tool_strict_level,
231 )?))
232 }
233 }
234
235 impl ChatRenderer for FakeChatBackend {
236 fn render(&self, request: &ChatRequest) -> vllm_chat::Result<RenderedPrompt> {
237 if !self.has_template {
238 return Err(vllm_chat::Error::MissingChatTemplate);
239 }
240
241 let mut prompt = String::new();
242 for message in &request.messages {
243 prompt.push_str(message.role().as_str());
244 prompt.push_str(": ");
245 prompt.push_str(&message.text_content()?);
246 prompt.push('\n');
247 }
248 if request.chat_options.add_generation_prompt() {
249 prompt.push_str("assistant:");
250 }
251
252 Ok(RenderedPrompt {
253 prompt: Prompt::Text(prompt),
254 media_order: None,
255 effective_template_kwargs: request.chat_options.template_kwargs.clone(),
256 })
257 }
258 }
259
260 /// Skip `LogprobsDelta` events that carry only token_ids (no logprobs),
261 /// returning the next semantically interesting event.
262 async fn next_semantic<S>(stream: &mut S) -> Option<Result<ChatEvent, vllm_chat::Error>>
263 where
264 S: futures::Stream<Item = Result<ChatEvent, vllm_chat::Error>> + Unpin,
265 {
266 loop {
267 match stream.next().await {
268 Some(Ok(ChatEvent::LogprobsDelta { logprobs: None, .. })) => continue,
269 other => return other,
270 }
271 }
272 }
273
274 fn sample_request(request_id: &str) -> ChatRequest {
275 ChatRequest {
276 messages: vec![
277 ChatMessage::text(ChatRole::System, "You are terse."),
278 ChatMessage::text(ChatRole::User, "Say hi"),
279 ],
280 sampling_params: SamplingParams {
281 max_tokens: Some(8),
282 ..Default::default()
283 },
284 request_id: request_id.to_string(),
285 ..ChatRequest::for_test()
286 }
287 }
288
289 fn sample_tool_request(request_id: &str) -> ChatRequest {
290 let mut request = sample_request(request_id);
291 let tools = vec![ChatTool {
292 name: "get_weather".to_string(),
293 description: Some("Get weather".to_string()),
294 parameters: serde_json::json!({
295 "type": "object",
296 "properties": {"city": {"type": "string"}},
297 "required": ["city"],
298 }),
299 strict: None,
300 }];
301 request.tool_context = vllm_chat::ResolvedToolContext::new(
302 &request.messages,
303 tools,
304 Some(ChatToolChoice::Auto),
305 true,
306 )
307 .expect("tool context should resolve");
308 request
309 }
310
311 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
312 async fn chat_streams_text_events() {
313 let ipc = IpcNamespace::new().unwrap();
314 let handshake_address = ipc.handshake_endpoint();
315 let engine_id = b"engine-chat".to_vec();
316
317 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
318 handshake_address.clone(),
319 engine_id.clone(),
320 |dealer, push| {
321 Box::pin(async move {
322 let add = recv_engine_message(dealer).await;
323 assert_eq!(add[0].as_ref(), &[0x00]);
324 let request: EngineCoreRequest = rmp_serde::from_slice(&add[1]).unwrap();
325 assert_eq!(request.request_id, "chat-1");
326 // more fields here in the future
327 assert_eq!(
328 String::from_utf8(
329 request
330 .prompt_token_ids
331 .clone()
332 .unwrap()
333 .into_iter()
334 .map(|id| id as u8)
335 .collect()
336 )
337 .unwrap(),
338 "system: You are terse.\nuser: Say hi\nassistant:"
339 );
340 send_outputs(
341 push,
342 RequestBatchOutputs {
343 outputs: vec![
344 request_output("chat-1", vec![b'H' as u32], None, None),
345 request_output(
346 "chat-1",
347 vec![b'i' as u32, b'!' as u32],
348 Some(EngineCoreFinishReason::Stop),
349 Some(StopReason::TokenId(b'!' as u32)),
350 ),
351 ],
352 finished_requests: Some(BTreeSet::from(["chat-1".to_string()])),
353 ..Default::default()
354 }
355 .into(),
356 )
357 .await;
358 })
359 },
360 );
361
362 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
363 let chat = connect_chat_llm_with_ipc(
364 EngineCoreClientConfig::new_single(handshake_address).with_model_name("test-model"),
365 &ipc,
366 backend,
367 )
368 .await;
369
370 let mut stream = chat.chat(sample_request("chat-1")).await.unwrap();
371
372 match next_semantic(&mut stream).await.unwrap().unwrap() {
373 ChatEvent::Start {
374 prompt_token_ids,
375 prompt_logprobs: None,
376 } => {
377 assert_eq!(
378 prompt_token_ids.len(),
379 "system: You are terse.\nuser: Say hi\nassistant:".len()
380 );
381 assert!(!prompt_token_ids.is_empty());
382 }
383 other => panic!("expected Start, got {other:?}"),
384 }
385 assert_eq!(
386 next_semantic(&mut stream).await.unwrap().unwrap(),
387 ChatEvent::BlockStart {
388 index: 0,
389 kind: AssistantBlockKind::Text,
390 }
391 );
392 assert_eq!(
393 next_semantic(&mut stream).await.unwrap().unwrap(),
394 ChatEvent::BlockDelta {
395 index: 0,
396 kind: AssistantBlockKind::Text,
397 delta: "H".to_string(),
398 token_count: None,
399 }
400 );
401 assert_eq!(
402 next_semantic(&mut stream).await.unwrap().unwrap(),
403 ChatEvent::BlockDelta {
404 index: 0,
405 kind: AssistantBlockKind::Text,
406 delta: "i".to_string(),
407 token_count: None,
408 }
409 );
410 assert_eq!(
411 next_semantic(&mut stream).await.unwrap().unwrap(),
412 ChatEvent::BlockEnd {
413 index: 0,
414 block: AssistantContentBlock::Text {
415 text: "Hi".to_string(),
416 },
417 }
418 );
419
420 match next_semantic(&mut stream).await {
421 Some(Ok(ChatEvent::Done {
422 message,
423 usage,
424 finish_reason,
425 ..
426 })) => {
427 assert_eq!(message.text(), "Hi");
428 assert_eq!(usage.output_token_count, 3);
429 assert_eq!(
430 finish_reason,
431 FinishReason::Stop(Some(StopReason::TokenId(b'!' as u32)))
432 );
433 }
434 other => panic!("unexpected final event: {other:?}"),
435 }
436 assert!(next_semantic(&mut stream).await.is_none());
437
438 let _ = shutdown_tx.send(());
439 engine_task.await.unwrap();
440 chat.shutdown().await.unwrap();
441 }
442
443 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
444 async fn chat_stream_waits_for_complete_utf8_before_emitting() {
445 let ipc = IpcNamespace::new().unwrap();
446 let handshake_address = ipc.handshake_endpoint();
447 let engine_id = b"engine-chat-utf8".to_vec();
448
449 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
450 handshake_address.clone(),
451 engine_id.clone(),
452 |dealer, push| {
453 Box::pin(async move {
454 let _ = recv_engine_message(dealer).await;
455 send_outputs(
456 push,
457 RequestBatchOutputs {
458 outputs: vec![
459 request_output("chat-utf8", bytes_to_token_ids(&[0xe4]), None, None),
460 request_output(
461 "chat-utf8",
462 bytes_to_token_ids(&[0xbd, 0xa0, b'!']),
463 Some(EngineCoreFinishReason::Stop),
464 Some(StopReason::TokenId(b'!' as u32)),
465 ),
466 ],
467 finished_requests: Some(BTreeSet::from(["chat-utf8".to_string()])),
468 ..Default::default()
469 }
470 .into(),
471 )
472 .await;
473 })
474 },
475 );
476
477 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
478 let chat = connect_chat_llm_with_ipc(
479 EngineCoreClientConfig::new_single(handshake_address),
480 &ipc,
481 backend,
482 )
483 .await;
484
485 let mut stream = chat.chat(sample_request("chat-utf8")).await.unwrap();
486
487 assert!(matches!(
488 next_semantic(&mut stream).await,
489 Some(Ok(ChatEvent::Start {
490 prompt_logprobs: None,
491 ..
492 }))
493 ));
494 assert_eq!(
495 next_semantic(&mut stream).await.unwrap().unwrap(),
496 ChatEvent::BlockStart {
497 index: 0,
498 kind: AssistantBlockKind::Text,
499 }
500 );
501 assert_eq!(
502 next_semantic(&mut stream).await.unwrap().unwrap(),
503 ChatEvent::BlockDelta {
504 index: 0,
505 kind: AssistantBlockKind::Text,
506 delta: "你".to_string(),
507 token_count: None,
508 }
509 );
510 assert_eq!(
511 next_semantic(&mut stream).await.unwrap().unwrap(),
512 ChatEvent::BlockEnd {
513 index: 0,
514 block: AssistantContentBlock::Text {
515 text: "你".to_string(),
516 },
517 }
518 );
519
520 match next_semantic(&mut stream).await {
521 Some(Ok(ChatEvent::Done { message, usage, .. })) => {
522 assert_eq!(message.text(), "你");
523 assert_eq!(usage.output_token_count, 4);
524 }
525 other => panic!("unexpected final event: {other:?}"),
526 }
527
528 let _ = shutdown_tx.send(());
529 engine_task.await.unwrap();
530 chat.shutdown().await.unwrap();
531 }
532
533 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
534 async fn chat_stream_flushes_held_text_on_finish() {
535 let ipc = IpcNamespace::new().unwrap();
536 let handshake_address = ipc.handshake_endpoint();
537 let engine_id = b"engine-chat-final-flush".to_vec();
538
539 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
540 handshake_address.clone(),
541 engine_id.clone(),
542 |dealer, push| {
543 Box::pin(async move {
544 let _ = recv_engine_message(dealer).await;
545 send_outputs(
546 push,
547 RequestBatchOutputs {
548 outputs: vec![request_output(
549 "chat-final-flush",
550 bytes_to_token_ids(b"ok st"),
551 Some(EngineCoreFinishReason::Length),
552 None,
553 )],
554 finished_requests: Some(BTreeSet::from(["chat-final-flush".to_string()])),
555 ..Default::default()
556 }
557 .into(),
558 )
559 .await;
560 })
561 },
562 );
563
564 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
565 let chat = connect_chat_llm_with_ipc(
566 EngineCoreClientConfig::new_single(handshake_address),
567 &ipc,
568 backend,
569 )
570 .await;
571
572 let mut stream = chat.chat(sample_request("chat-final-flush")).await.unwrap();
573
574 assert!(matches!(
575 next_semantic(&mut stream).await,
576 Some(Ok(ChatEvent::Start {
577 prompt_logprobs: None,
578 ..
579 }))
580 ));
581 assert_eq!(
582 next_semantic(&mut stream).await.unwrap().unwrap(),
583 ChatEvent::BlockStart {
584 index: 0,
585 kind: AssistantBlockKind::Text,
586 }
587 );
588 assert_eq!(
589 next_semantic(&mut stream).await.unwrap().unwrap(),
590 ChatEvent::BlockDelta {
591 index: 0,
592 kind: AssistantBlockKind::Text,
593 delta: "ok st".to_string(),
594 token_count: None,
595 }
596 );
597 assert_eq!(
598 next_semantic(&mut stream).await.unwrap().unwrap(),
599 ChatEvent::BlockEnd {
600 index: 0,
601 block: AssistantContentBlock::Text {
602 text: "ok st".to_string(),
603 },
604 }
605 );
606
607 match next_semantic(&mut stream).await {
608 Some(Ok(ChatEvent::Done {
609 message,
610 usage,
611 finish_reason,
612 ..
613 })) => {
614 assert_eq!(message.text(), "ok st");
615 assert_eq!(usage.output_token_count, 5);
616 assert_eq!(finish_reason, FinishReason::Length);
617 }
618 other => panic!("unexpected final event: {other:?}"),
619 }
620
621 let _ = shutdown_tx.send(());
622 engine_task.await.unwrap();
623 chat.shutdown().await.unwrap();
624 }
625
626 #[test]
627 fn chat_request_rejects_conflicting_generation_modes() {
628 let mut request = sample_request("chat-2");
629 request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
630 let error = request.validate().unwrap_err();
631
632 assert!(matches!(
633 error,
634 vllm_chat::Error::ContinueFinalAssistantWithoutFinalAssistant
635 ));
636 }
637
638 #[test]
639 fn chat_request_accepts_continue_final_assistant_mode_with_final_assistant() {
640 let mut request = sample_request("chat-2b");
641 request.messages = vec![ChatMessage::assistant_text("hello")];
642 request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
643
644 request.validate().unwrap();
645 }
646
647 #[test]
648 fn backend_requires_a_template() {
649 let request = sample_request("chat-3");
650 let backend = FakeChatBackend::without_template();
651 let error = backend.chat_renderer().render(&request).unwrap_err();
652 assert!(matches!(error, vllm_chat::Error::MissingChatTemplate));
653 }
654
655 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
656 async fn chat_stream_reports_decode_failure_as_error_event() {
657 let ipc = IpcNamespace::new().unwrap();
658 let handshake_address = ipc.handshake_endpoint();
659 let engine_id = b"engine-chat-decode-fail".to_vec();
660
661 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
662 handshake_address.clone(),
663 engine_id.clone(),
664 |dealer, push| {
665 Box::pin(async move {
666 let _ = recv_engine_message(dealer).await;
667 send_outputs(
668 push,
669 RequestBatchOutputs {
670 outputs: vec![request_output(
671 "chat-4",
672 vec![UNKNOWN_DECODE_TOKEN_ID],
673 None,
674 None,
675 )],
676 ..Default::default()
677 }
678 .into(),
679 )
680 .await;
681 })
682 },
683 );
684
685 let backend: Arc<dyn ChatTextBackend> =
686 Arc::new(FakeChatBackend::new().with_tokenizer(Arc::new(TestTokenizer::new())));
687 let chat = connect_chat_llm_with_ipc(
688 EngineCoreClientConfig::new_single(handshake_address),
689 &ipc,
690 backend,
691 )
692 .await;
693
694 let mut stream = chat.chat(sample_request("chat-4")).await.unwrap();
695 assert_eq!(stream.request_id(), "chat-4");
696 assert!(matches!(
697 next_semantic(&mut stream).await,
698 Some(Ok(ChatEvent::Start {
699 prompt_logprobs: None,
700 ..
701 }))
702 ));
703
704 match timeout(Duration::from_secs(2), stream.next()).await.unwrap() {
705 Some(Err(vllm_chat::Error::Text(vllm_text::Error::Tokenizer(message)))) => {
706 assert_eq!(
707 message,
708 format!("test tokenizer cannot decode unknown token id {UNKNOWN_DECODE_TOKEN_ID}")
709 );
710 }
711 other => panic!("unexpected event after close: {other:?}"),
712 }
713
714 let _ = shutdown_tx.send(());
715 engine_task.await.unwrap();
716 chat.shutdown().await.unwrap();
717 }
718
719 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
720 async fn chat_stream_preserves_terminal_stop_token_when_requested() {
721 let ipc = IpcNamespace::new().unwrap();
722 let handshake_address = ipc.handshake_endpoint();
723 let engine_id = b"engine-chat-include-stop".to_vec();
724
725 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
726 handshake_address.clone(),
727 engine_id.clone(),
728 |dealer, push| {
729 Box::pin(async move {
730 let _ = recv_engine_message(dealer).await;
731 send_outputs(
732 push,
733 RequestBatchOutputs {
734 outputs: vec![request_output(
735 "chat-include-stop",
736 vec![b'H' as u32, b'i' as u32, b'!' as u32],
737 Some(EngineCoreFinishReason::Stop),
738 Some(StopReason::TokenId(b'!' as u32)),
739 )],
740 finished_requests: Some(BTreeSet::from(["chat-include-stop".to_string()])),
741 ..Default::default()
742 }
743 .into(),
744 )
745 .await;
746 })
747 },
748 );
749
750 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
751 let chat = connect_chat_llm_with_ipc(
752 EngineCoreClientConfig::new_single(handshake_address),
753 &ipc,
754 backend,
755 )
756 .await;
757
758 let mut request = sample_request("chat-include-stop");
759 request.decode_options.include_stop_str_in_output = true;
760 let mut stream = chat.chat(request).await.unwrap();
761
762 assert!(matches!(
763 next_semantic(&mut stream).await,
764 Some(Ok(ChatEvent::Start {
765 prompt_logprobs: None,
766 ..
767 }))
768 ));
769 assert_eq!(
770 next_semantic(&mut stream).await.unwrap().unwrap(),
771 ChatEvent::BlockStart {
772 index: 0,
773 kind: AssistantBlockKind::Text,
774 }
775 );
776 assert_eq!(
777 next_semantic(&mut stream).await.unwrap().unwrap(),
778 ChatEvent::BlockDelta {
779 index: 0,
780 kind: AssistantBlockKind::Text,
781 delta: "Hi!".to_string(),
782 token_count: None,
783 }
784 );
785 assert_eq!(
786 next_semantic(&mut stream).await.unwrap().unwrap(),
787 ChatEvent::BlockEnd {
788 index: 0,
789 block: AssistantContentBlock::Text {
790 text: "Hi!".to_string(),
791 },
792 }
793 );
794
795 match next_semantic(&mut stream).await {
796 Some(Ok(ChatEvent::Done { message, usage, .. })) => {
797 assert_eq!(message.text(), "Hi!");
798 assert_eq!(usage.output_token_count, 3);
799 }
800 other => panic!("unexpected final event: {other:?}"),
801 }
802
803 let _ = shutdown_tx.send(());
804 engine_task.await.unwrap();
805 chat.shutdown().await.unwrap();
806 }
807
808 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
809 async fn chat_stream_separates_reasoning_blocks_automatically() {
810 let ipc = IpcNamespace::new().unwrap();
811 let handshake_address = ipc.handshake_endpoint();
812 let engine_id = b"engine-chat-reasoning".to_vec();
813
814 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
815 handshake_address.clone(),
816 engine_id.clone(),
817 |dealer, push| {
818 Box::pin(async move {
819 let _ = recv_engine_message(dealer).await;
820 send_outputs(
821 push,
822 RequestBatchOutputs {
823 outputs: vec![
824 request_output(
825 "chat-reasoning",
826 bytes_to_token_ids(b"<think>"),
827 None,
828 None,
829 ),
830 request_output(
831 "chat-reasoning",
832 bytes_to_token_ids(b"reason "),
833 None,
834 None,
835 ),
836 request_output(
837 "chat-reasoning",
838 bytes_to_token_ids(b"more</think>"),
839 None,
840 None,
841 ),
842 request_output(
843 "chat-reasoning",
844 bytes_to_token_ids(b"answer"),
845 Some(EngineCoreFinishReason::Length),
846 None,
847 ),
848 ],
849 finished_requests: Some(BTreeSet::from(["chat-reasoning".to_string()])),
850 ..Default::default()
851 }
852 .into(),
853 )
854 .await;
855 })
856 },
857 );
858
859 let backend: Arc<dyn ChatTextBackend> =
860 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B"));
861 let chat = connect_chat_llm_with_ipc(
862 EngineCoreClientConfig::new_single(handshake_address),
863 &ipc,
864 backend,
865 )
866 .await;
867
868 let mut stream = chat.chat(sample_request("chat-reasoning")).await.unwrap();
869
870 assert!(matches!(
871 next_semantic(&mut stream).await,
872 Some(Ok(ChatEvent::Start {
873 prompt_logprobs: None,
874 ..
875 }))
876 ));
877 assert_eq!(
878 next_semantic(&mut stream).await.unwrap().unwrap(),
879 ChatEvent::BlockStart {
880 index: 0,
881 kind: AssistantBlockKind::Reasoning,
882 }
883 );
884 assert_eq!(
885 next_semantic(&mut stream).await.unwrap().unwrap(),
886 ChatEvent::BlockDelta {
887 index: 0,
888 kind: AssistantBlockKind::Reasoning,
889 delta: "reason ".to_string(),
890 token_count: Some(7),
891 }
892 );
893 assert_eq!(
894 next_semantic(&mut stream).await.unwrap().unwrap(),
895 ChatEvent::BlockDelta {
896 index: 0,
897 kind: AssistantBlockKind::Reasoning,
898 delta: "more".to_string(),
899 token_count: Some(4),
900 }
901 );
902 assert_eq!(
903 next_semantic(&mut stream).await.unwrap().unwrap(),
904 ChatEvent::BlockEnd {
905 index: 0,
906 block: AssistantContentBlock::Reasoning {
907 text: "reason more".to_string(),
908 },
909 }
910 );
911 assert_eq!(
912 next_semantic(&mut stream).await.unwrap().unwrap(),
913 ChatEvent::BlockStart {
914 index: 1,
915 kind: AssistantBlockKind::Text,
916 }
917 );
918 assert_eq!(
919 next_semantic(&mut stream).await.unwrap().unwrap(),
920 ChatEvent::BlockDelta {
921 index: 1,
922 kind: AssistantBlockKind::Text,
923 delta: "answer".to_string(),
924 token_count: None,
925 }
926 );
927 assert_eq!(
928 next_semantic(&mut stream).await.unwrap().unwrap(),
929 ChatEvent::BlockEnd {
930 index: 1,
931 block: AssistantContentBlock::Text {
932 text: "answer".to_string(),
933 },
934 }
935 );
936
937 match next_semantic(&mut stream).await {
938 Some(Ok(ChatEvent::Done {
939 message,
940 usage,
941 finish_reason,
942 ..
943 })) => {
944 assert_eq!(message.reasoning().unwrap(), "reason more");
945 assert_eq!(message.text(), "answer");
946 assert_eq!(finish_reason, FinishReason::Length);
947 // 7 tokens for "reason " plus 4 for "more"; markers are excluded.
948 assert_eq!(usage.reasoning_tokens, 11);
949 }
950 other => panic!("unexpected final event: {other:?}"),
951 }
952
953 let _ = shutdown_tx.send(());
954 engine_task.await.unwrap();
955 chat.shutdown().await.unwrap();
956 }
957
958 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
959 async fn chat_collectors_return_structured_message_and_visible_text() {
960 let ipc = IpcNamespace::new().unwrap();
961 let handshake_address = ipc.handshake_endpoint();
962 let engine_id = b"engine-chat-collect".to_vec();
963
964 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
965 handshake_address.clone(),
966 engine_id.clone(),
967 |dealer, push| {
968 Box::pin(async move {
969 let _ = recv_engine_message(dealer).await;
970 send_outputs(
971 push,
972 RequestBatchOutputs {
973 outputs: vec![request_output(
974 "chat-collect",
975 bytes_to_token_ids(b"<think>inner</think>outer"),
976 Some(EngineCoreFinishReason::Length),
977 None,
978 )],
979 finished_requests: Some(BTreeSet::from(["chat-collect".to_string()])),
980 ..Default::default()
981 }
982 .into(),
983 )
984 .await;
985 })
986 },
987 );
988
989 let backend: Arc<dyn ChatTextBackend> =
990 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B"));
991 let chat = connect_chat_llm_with_ipc(
992 EngineCoreClientConfig::new_single(handshake_address.clone()),
993 &ipc,
994 backend.clone(),
995 )
996 .await;
997
998 let message = chat
999 .chat(sample_request("chat-collect"))
1000 .await
1001 .unwrap()
1002 .collect_message()
1003 .await
1004 .unwrap();
1005 assert_eq!(message.message.reasoning().unwrap(), "inner");
1006 assert_eq!(message.message.text(), "outer");
1007 assert_eq!(message.finish_reason, FinishReason::Length);
1008 assert_eq!(
1009 message.usage.prompt_token_count,
1010 "system: You are terse.\nuser: Say hi\nassistant:".len()
1011 );
1012 assert_eq!(
1013 message.usage.output_token_count,
1014 "<think>inner</think>outer".len()
1015 );
1016 // 5 tokens for "inner"; markers are excluded.
1017 assert_eq!(message.usage.reasoning_tokens, 5);
1018
1019 let _ = shutdown_tx.send(());
1020 engine_task.await.unwrap();
1021 chat.shutdown().await.unwrap();
1022 }
1023
1024 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1025 async fn chat_explicitly_disables_reasoning_parser() {
1026 let ipc = IpcNamespace::new().unwrap();
1027 let handshake_address = ipc.handshake_endpoint();
1028 let engine_id = b"engine-chat-reasoning-disabled".to_vec();
1029
1030 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1031 handshake_address.clone(),
1032 engine_id.clone(),
1033 |dealer, push| {
1034 Box::pin(async move {
1035 let _ = recv_engine_message(dealer).await;
1036 send_outputs(
1037 push,
1038 RequestBatchOutputs {
1039 outputs: vec![
1040 request_output(
1041 "chat-reasoning-disabled",
1042 bytes_to_token_ids(b"<think>"),
1043 None,
1044 None,
1045 ),
1046 request_output(
1047 "chat-reasoning-disabled",
1048 bytes_to_token_ids(b"reason "),
1049 None,
1050 None,
1051 ),
1052 request_output(
1053 "chat-reasoning-disabled",
1054 bytes_to_token_ids(b"more</think>"),
1055 None,
1056 None,
1057 ),
1058 request_output(
1059 "chat-reasoning-disabled",
1060 bytes_to_token_ids(b"answer"),
1061 Some(EngineCoreFinishReason::Length),
1062 None,
1063 ),
1064 ],
1065 finished_requests: Some(BTreeSet::from([
1066 "chat-reasoning-disabled".to_string()
1067 ])),
1068 ..Default::default()
1069 }
1070 .into(),
1071 )
1072 .await;
1073 })
1074 },
1075 );
1076
1077 let backend: Arc<dyn ChatTextBackend> =
1078 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B"));
1079 let chat = connect_chat_llm_with_ipc(
1080 EngineCoreClientConfig::new_single(handshake_address),
1081 &ipc,
1082 backend,
1083 )
1084 .await
1085 .with_reasoning_parser(ParserSelection::None);
1086
1087 let message = chat
1088 .chat(sample_request("chat-reasoning-disabled"))
1089 .await
1090 .unwrap()
1091 .collect_message()
1092 .await
1093 .unwrap();
1094 assert_eq!(message.message.reasoning(), None);
1095 assert_eq!(message.message.text(), "<think>reason more</think>answer");
1096 assert_eq!(message.finish_reason, FinishReason::Length);
1097 assert_eq!(message.usage.reasoning_tokens, 0);
1098
1099 let _ = shutdown_tx.send(());
1100 engine_task.await.unwrap();
1101 chat.shutdown().await.unwrap();
1102 }
1103
1104 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1105 async fn chat_stream_parses_tool_calls_automatically() {
1106 let ipc = IpcNamespace::new().unwrap();
1107 let handshake_address = ipc.handshake_endpoint();
1108 let engine_id = b"engine-chat-tool".to_vec();
1109
1110 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1111 handshake_address.clone(),
1112 engine_id.clone(),
1113 |dealer, push| {
1114 Box::pin(async move {
1115 let _ = recv_engine_message(dealer).await;
1116 send_outputs(
1117 push,
1118 RequestBatchOutputs {
1119 outputs: vec![
1120 request_output(
1121 "chat-tool",
1122 bytes_to_token_ids(b"<think>Need tool.</think>"),
1123 None,
1124 None,
1125 ),
1126 request_output(
1127 "chat-tool",
1128 bytes_to_token_ids(b"<tool_call>\n{\"name\":\"get_weather\", "),
1129 None,
1130 None,
1131 ),
1132 request_output(
1133 "chat-tool",
1134 bytes_with_special_stop_token(
1135 b"\"arguments\":{\"city\":\"Paris\"}}\n</tool_call>",
1136 ),
1137 Some(EngineCoreFinishReason::Stop),
1138 Some(StopReason::TokenId(SPECIAL_STOP_TOKEN_ID)),
1139 ),
1140 ],
1141 finished_requests: Some(BTreeSet::from(["chat-tool".to_string()])),
1142 ..Default::default()
1143 }
1144 .into(),
1145 )
1146 .await;
1147 })
1148 },
1149 );
1150
1151 let backend: Arc<dyn ChatTextBackend> =
1152 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B"));
1153 let chat = connect_chat_llm_with_ipc(
1154 EngineCoreClientConfig::new_single(handshake_address),
1155 &ipc,
1156 backend,
1157 )
1158 .await;
1159 let mut stream = chat.chat(sample_tool_request("chat-tool")).await.unwrap();
1160
1161 let mut saw_tool_start = false;
1162 let mut saw_tool_args = false;
1163 let mut saw_tool_end = false;
1164
1165 while let Some(event) = stream.next().await {
1166 match event.unwrap() {
1167 ChatEvent::Start { .. } => {}
1168 ChatEvent::LogprobsDelta { .. } => {}
1169 ChatEvent::ToolCallStart { name, .. } => {
1170 saw_tool_start = true;
1171 assert_eq!(name, "get_weather");
1172 }
1173 ChatEvent::ToolCallArgumentsDelta { delta, .. } => {
1174 saw_tool_args = true;
1175 assert!(delta.contains("Paris"), "{delta}");
1176 }
1177 ChatEvent::ToolCallEnd { call, .. } => {
1178 saw_tool_end = true;
1179 assert_eq!(call.name, "get_weather");
1180 assert_eq!(call.arguments, r#"{"city":"Paris"}"#);
1181 }
1182 ChatEvent::Done {
1183 message,
1184 finish_reason,
1185 ..
1186 } => {
1187 assert_eq!(
1188 finish_reason,
1189 FinishReason::Stop(Some(StopReason::TokenId(SPECIAL_STOP_TOKEN_ID)))
1190 );
1191 assert_eq!(message.text(), "");
1192 let tool_calls = message.tool_calls().collect::<Vec<_>>();
1193 assert_eq!(tool_calls.len(), 1);
1194 assert_eq!(tool_calls[0].name, "get_weather");
1195 assert_eq!(tool_calls[0].arguments, r#"{"city":"Paris"}"#);
1196 break;
1197 }
1198 ChatEvent::BlockStart { .. }
1199 | ChatEvent::BlockDelta { .. }
1200 | ChatEvent::BlockEnd { .. } => {}
1201 }
1202 }
1203
1204 assert!(saw_tool_start);
1205 assert!(saw_tool_args);
1206 assert!(saw_tool_end);
1207
1208 let _ = shutdown_tx.send(());
1209 engine_task.await.unwrap();
1210 chat.shutdown().await.unwrap();
1211 }
1212
1213 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1214 async fn chat_collect_message_preserves_tool_call_arguments_in_final_only_mode() {
1215 let ipc = IpcNamespace::new().unwrap();
1216 let handshake_address = ipc.handshake_endpoint();
1217 let engine_id = b"engine-chat-final-only-tool".to_vec();
1218
1219 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1220 handshake_address.clone(),
1221 engine_id.clone(),
1222 |dealer, push| {
1223 Box::pin(async move {
1224 let _ = recv_engine_message(dealer).await;
1225 send_outputs(
1226 push,
1227 RequestBatchOutputs {
1228 outputs: vec![
1229 request_output(
1230 "chat-final-only-tool",
1231 bytes_to_token_ids(b"<think>Need tool.</think>"),
1232 None,
1233 None,
1234 ),
1235 request_output(
1236 "chat-final-only-tool",
1237 bytes_to_token_ids(b"<tool_call>\n{\"name\":\"get_weather\", "),
1238 None,
1239 None,
1240 ),
1241 request_output(
1242 "chat-final-only-tool",
1243 bytes_with_special_stop_token(
1244 b"\"arguments\":{\"city\":\"Paris\"}}\n</tool_call>",
1245 ),
1246 Some(EngineCoreFinishReason::Stop),
1247 Some(StopReason::TokenId(SPECIAL_STOP_TOKEN_ID)),
1248 ),
1249 ],
1250 finished_requests: Some(BTreeSet::from([
1251 "chat-final-only-tool".to_string()
1252 ])),
1253 ..Default::default()
1254 }
1255 .into(),
1256 )
1257 .await;
1258 })
1259 },
1260 );
1261
1262 let backend: Arc<dyn ChatTextBackend> =
1263 Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B"));
1264 let chat = connect_chat_llm_with_ipc(
1265 EngineCoreClientConfig::new_single(handshake_address),
1266 &ipc,
1267 backend,
1268 )
1269 .await;
1270 let mut request = sample_tool_request("chat-final-only-tool");
1271 request.intermediate = false;
1272
1273 let message = chat.chat(request).await.unwrap().collect_message().await.unwrap();
1274
1275 assert_eq!(
1276 message.finish_reason,
1277 FinishReason::Stop(Some(StopReason::TokenId(SPECIAL_STOP_TOKEN_ID)))
1278 );
1279 assert_eq!(message.message.tool_calls().count(), 1);
1280 assert_eq!(
1281 message.message.tool_calls().next().unwrap().arguments,
1282 r#"{"city":"Paris"}"#
1283 );
1284
1285 let _ = shutdown_tx.send(());
1286 engine_task.await.unwrap();
1287 chat.shutdown().await.unwrap();
1288 }
1289
1290 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1291 async fn chat_stream_and_collect_preserve_prompt_and_sample_logprobs() {
1292 let ipc = IpcNamespace::new().unwrap();
1293 let handshake_address = ipc.handshake_endpoint();
1294 let engine_id = b"engine-chat-logprobs".to_vec();
1295
1296 let (shutdown_tx, engine_task) = spawn_mock_engine_task(
1297 handshake_address.clone(),
1298 engine_id.clone(),
1299 |dealer, push| {
1300 Box::pin(async move {
1301 for _ in 0..2 {
1302 let add = recv_engine_message(dealer).await;
1303 let request: EngineCoreRequest = rmp_serde::from_slice(&add[1]).unwrap();
1304 send_outputs(
1305 push,
1306 RequestBatchOutputs {
1307 outputs: vec![
1308 request_output_with_logprobs(
1309 &request.request_id,
1310 vec![b'H' as u32],
1311 None,
1312 None,
1313 Some(sample_logprobs_for_token(b'H' as u32, b'h' as u32)),
1314 Some(prompt_logprobs_for_hi()),
1315 ),
1316 request_output_with_logprobs(
1317 &request.request_id,
1318 vec![b'i' as u32],
1319 Some(EngineCoreFinishReason::Length),
1320 None,
1321 Some(sample_logprobs_for_token(b'i' as u32, b'I' as u32)),
1322 None,
1323 ),
1324 ],
1325 finished_requests: Some(BTreeSet::from([request.request_id])),
1326 ..Default::default()
1327 }
1328 .into(),
1329 )
1330 .await;
1331 }
1332 })
1333 },
1334 );
1335
1336 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
1337 let chat = connect_chat_llm_with_ipc(
1338 EngineCoreClientConfig::new_single(handshake_address.clone()),
1339 &ipc,
1340 backend,
1341 )
1342 .await;
1343
1344 let mut request = sample_request("chat-logprobs");
1345 request.sampling_params.logprobs = Some(1);
1346 request.sampling_params.prompt_logprobs = Some(1);
1347
1348 let mut stream = chat.chat(request.clone()).await.unwrap();
1349 match next_semantic(&mut stream).await.unwrap().unwrap() {
1350 ChatEvent::Start {
1351 prompt_token_ids,
1352 prompt_logprobs,
1353 } => {
1354 assert_eq!(
1355 prompt_token_ids.len(),
1356 "system: You are terse.\nuser: Say hi\nassistant:".len()
1357 );
1358 assert!(!prompt_token_ids.is_empty());
1359 assert_eq!(
1360 prompt_logprobs,
1361 Some(DecodedPromptLogprobs {
1362 first_token_id: b's' as u32,
1363 first_token: "s".to_string(),
1364 scored_positions: vec![DecodedPositionLogprobs {
1365 entries: vec![
1366 DecodedTokenLogprob {
1367 token_id: b'i' as u32,
1368 token: "i".to_string(),
1369 logprob: -0.3,
1370 rank: 1,
1371 },
1372 DecodedTokenLogprob {
1373 token_id: b'!' as u32,
1374 token: "!".to_string(),
1375 logprob: -0.4,
1376 rank: 1,
1377 },
1378 ],
1379 }],
1380 })
1381 );
1382 }
1383 other => panic!("expected Start, got {other:?}"),
1384 }
1385 assert_eq!(
1386 next_semantic(&mut stream).await.unwrap().unwrap(),
1387 ChatEvent::BlockStart {
1388 index: 0,
1389 kind: AssistantBlockKind::Text,
1390 }
1391 );
1392 assert_eq!(
1393 next_semantic(&mut stream).await.unwrap().unwrap(),
1394 ChatEvent::BlockDelta {
1395 index: 0,
1396 kind: AssistantBlockKind::Text,
1397 delta: "H".to_string(),
1398 token_count: None,
1399 }
1400 );
1401 assert_eq!(
1402 next_semantic(&mut stream).await.unwrap().unwrap(),
1403 ChatEvent::LogprobsDelta {
1404 logprobs: Some(DecodedLogprobs {
1405 positions: vec![DecodedPositionLogprobs {
1406 entries: vec![
1407 DecodedTokenLogprob {
1408 token_id: b'H' as u32,
1409 token: "H".to_string(),
1410 logprob: -0.1,
1411 rank: 1,
1412 },
1413 DecodedTokenLogprob {
1414 token_id: b'h' as u32,
1415 token: "h".to_string(),
1416 logprob: -0.2,
1417 rank: 1,
1418 },
1419 ],
1420 }],
1421 }),
1422 token_ids: vec![b'H' as u32],
1423 }
1424 );
1425 while !matches!(
1426 next_semantic(&mut stream).await,
1427 Some(Ok(ChatEvent::Done { .. }))
1428 ) {}
1429
1430 request.request_id = "chat-logprobs-collect".to_string();
1431 let collected = chat.chat(request).await.unwrap().collect_message().await.unwrap();
1432 assert_eq!(collected.message.text(), "Hi");
1433 assert_eq!(
1434 collected.prompt_logprobs,
1435 Some(DecodedPromptLogprobs {
1436 first_token_id: b's' as u32,
1437 first_token: "s".to_string(),
1438 scored_positions: vec![DecodedPositionLogprobs {
1439 entries: vec![
1440 DecodedTokenLogprob {
1441 token_id: b'i' as u32,
1442 token: "i".to_string(),
1443 logprob: -0.3,
1444 rank: 1,
1445 },
1446 DecodedTokenLogprob {
1447 token_id: b'!' as u32,
1448 token: "!".to_string(),
1449 logprob: -0.4,
1450 rank: 1,
1451 },
1452 ],
1453 }],
1454 })
1455 );
1456 assert_eq!(
1457 collected.logprobs,
1458 Some(DecodedLogprobs {
1459 positions: vec![
1460 DecodedPositionLogprobs {
1461 entries: vec![
1462 DecodedTokenLogprob {
1463 token_id: b'H' as u32,
1464 token: "H".to_string(),
1465 logprob: -0.1,
1466 rank: 1,
1467 },
1468 DecodedTokenLogprob {
1469 token_id: b'h' as u32,
1470 token: "h".to_string(),
1471 logprob: -0.2,
1472 rank: 1,
1473 },
1474 ],
1475 },
1476 DecodedPositionLogprobs {
1477 entries: vec![
1478 DecodedTokenLogprob {
1479 token_id: b'i' as u32,
1480 token: "i".to_string(),
1481 logprob: -0.1,
1482 rank: 1,
1483 },
1484 DecodedTokenLogprob {
1485 token_id: b'I' as u32,
1486 token: "I".to_string(),
1487 logprob: -0.2,
1488 rank: 1,
1489 },
1490 ],
1491 },
1492 ],
1493 })
1494 );
1495
1496 let _ = shutdown_tx.send(());
1497 engine_task.await.unwrap();
1498 chat.shutdown().await.unwrap();
1499 }
1500
1501 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1502 async fn chat_rejects_unknown_tool_parser_before_engine_request() {
1503 let ipc = IpcNamespace::new().unwrap();
1504 let handshake_address = ipc.handshake_endpoint();
1505 let engine_id = b"engine-chat-tool-no-model".to_vec();
1506 let (shutdown_tx, engine_task) =
1507 spawn_mock_engine_task(handshake_address.clone(), engine_id, |dealer, _| {
1508 Box::pin(async move {
1509 assert!(
1510 timeout(Duration::from_millis(100), recv_engine_message(dealer)).await.is_err(),
1511 "chat request should fail before any engine request is sent"
1512 );
1513 })
1514 });
1515
1516 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
1517 let chat = connect_chat_llm_with_ipc(
1518 EngineCoreClientConfig::new_single(handshake_address),
1519 &ipc,
1520 backend,
1521 )
1522 .await
1523 .with_tool_call_parser(ParserSelection::Explicit(
1524 "definitely_missing_tool_parser".into(),
1525 ));
1526 let error = match chat.chat(sample_tool_request("chat-tool-no-model")).await {
1527 Ok(_) => panic!("unknown explicit tool parser should fail"),
1528 Err(error) => error,
1529 };
1530
1531 assert!(matches!(
1532 error,
1533 vllm_chat::Error::ParserUnavailableByName { name, .. }
1534 if name == "definitely_missing_tool_parser"
1535 ));
1536
1537 let _ = shutdown_tx.send(());
1538 engine_task.await.unwrap();
1539 chat.shutdown().await.unwrap();
1540 }
1541
1542 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1543 async fn chat_rejects_unknown_reasoning_parser_before_engine_request() {
1544 let ipc = IpcNamespace::new().unwrap();
1545 let handshake_address = ipc.handshake_endpoint();
1546 let engine_id = b"engine-chat-reasoning-no-model".to_vec();
1547 let (shutdown_tx, engine_task) =
1548 spawn_mock_engine_task(handshake_address.clone(), engine_id, |dealer, _| {
1549 Box::pin(async move {
1550 assert!(
1551 timeout(Duration::from_millis(100), recv_engine_message(dealer)).await.is_err(),
1552 "chat request should fail before any engine request is sent"
1553 );
1554 })
1555 });
1556
1557 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
1558 let chat = connect_chat_llm_with_ipc(
1559 EngineCoreClientConfig::new_single(handshake_address),
1560 &ipc,
1561 backend,
1562 )
1563 .await
1564 .with_reasoning_parser(ParserSelection::Explicit(
1565 "definitely_missing_reasoning_parser".into(),
1566 ));
1567 let error = match chat.chat(sample_request("chat-reasoning-no-model")).await {
1568 Ok(_) => panic!("unknown explicit reasoning parser should fail"),
1569 Err(error) => error,
1570 };
1571
1572 assert!(matches!(
1573 error,
1574 vllm_chat::Error::ParserUnavailableByName { name, .. }
1575 if name == "definitely_missing_reasoning_parser"
1576 ));
1577
1578 let _ = shutdown_tx.send(());
1579 engine_task.await.unwrap();
1580 chat.shutdown().await.unwrap();
1581 }
1582
1583 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1584 async fn chat_rejects_tool_requests_when_tool_parser_is_disabled() {
1585 let ipc = IpcNamespace::new().unwrap();
1586 let handshake_address = ipc.handshake_endpoint();
1587 let engine_id = b"engine-chat-tool-parser-disabled".to_vec();
1588 let (shutdown_tx, engine_task) =
1589 spawn_mock_engine_task(handshake_address.clone(), engine_id, |dealer, _| {
1590 Box::pin(async move {
1591 assert!(
1592 timeout(Duration::from_millis(100), recv_engine_message(dealer)).await.is_err(),
1593 "chat request should fail before any engine request is sent"
1594 );
1595 })
1596 });
1597
1598 let backend: Arc<dyn ChatTextBackend> = Arc::new(FakeChatBackend::new());
1599 let chat = connect_chat_llm_with_ipc(
1600 EngineCoreClientConfig::new_single(handshake_address),
1601 &ipc,
1602 backend,
1603 )
1604 .await
1605 .with_tool_call_parser(ParserSelection::None);
1606 let error = match chat.chat(sample_tool_request("chat-tool-parser-disabled")).await {
1607 Ok(_) => panic!("tool requests should fail when tool parsing is disabled"),
1608 Err(error) => error,
1609 };
1610
1611 assert!(matches!(
1612 error,
1613 vllm_chat::Error::ParserDisabled { kind: "tool" }
1614 ));
1615
1616 let _ = shutdown_tx.send(());
1617 engine_task.await.unwrap();
1618 chat.shutdown().await.unwrap();
1619 }