main
py 923 lines 29.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from unittest.mock import patch
5
6 import pytest
7 from openai_harmony import Author, Message, Role, TextContent
8
9 from vllm.entrypoints.openai.responses.context import (
10 HarmonyContext,
11 SimpleContext,
12 TurnMetrics,
13 )
14 from vllm.outputs import CompletionOutput, RequestOutput
15 from vllm.parser.harmony import ChunkResult, HarmonyParser, Segment
16
17
18 def create_mock_request_output(
19 prompt_token_ids=None,
20 output_token_ids=None,
21 num_cached_tokens=0,
22 finished=True,
23 ):
24 """Helper function to create a mock RequestOutput object for testing."""
25 outputs = []
26 token_ids = output_token_ids if output_token_ids is not None else []
27 outputs = [
28 CompletionOutput(
29 index=0,
30 text="Test output",
31 token_ids=token_ids,
32 cumulative_logprob=0.0,
33 logprobs=None,
34 finish_reason=None,
35 stop_reason=None,
36 )
37 ]
38
39 return RequestOutput(
40 request_id="test-id",
41 prompt="Test prompt",
42 prompt_token_ids=prompt_token_ids,
43 prompt_logprobs=None,
44 outputs=outputs,
45 finished=finished,
46 num_cached_tokens=num_cached_tokens,
47 )
48
49
50 async def generate_mock_outputs(
51 num_turns, prompt_token_counts, output_token_counts, cached_token_counts=None
52 ):
53 """Generate a sequence of mock RequestOutput objects to simulate multiple
54 turns."""
55 if cached_token_counts is None:
56 cached_token_counts = [0] * num_turns
57
58 for i in range(num_turns):
59 # Create mock prompt token IDs and output token IDs
60 prompt_token_ids = list(range(1, prompt_token_counts[i] + 1))
61 output_token_ids = list(range(1, output_token_counts[i] + 1))
62
63 # Create and yield the RequestOutput
64 yield create_mock_request_output(
65 prompt_token_ids=prompt_token_ids,
66 output_token_ids=output_token_ids,
67 num_cached_tokens=cached_token_counts[i],
68 )
69
70
71 class FakeHarmonyParser(HarmonyParser):
72 def __init__(self):
73 # Skip HarmonyParser initialization and script outputs directly.
74 self._reasoning_parser = None
75 self._tool_parser = None
76 self._chunk_results: list[ChunkResult] = []
77 self._flush_results: list[list[Segment]] = []
78 self.processed_chunks: list[list[int]] = []
79
80 def enqueue_chunk_result(
81 self,
82 segments: list[Segment] | None = None,
83 reasoning_token_count: int = 0,
84 ) -> None:
85 self._chunk_results.append(
86 ChunkResult(
87 segments=[] if segments is None else segments,
88 reasoning_token_count=reasoning_token_count,
89 )
90 )
91
92 def enqueue_flush_result(self, segment: list[Segment]) -> None:
93 self._flush_results.append(segment)
94
95 def process_chunk(self, token_ids) -> ChunkResult:
96 self.processed_chunks.append(list(token_ids))
97 if self._chunk_results:
98 return self._chunk_results.pop(0)
99 return ChunkResult(segments=[], reasoning_token_count=0)
100
101 def flush(self) -> list[Segment]:
102 if self._flush_results:
103 return self._flush_results.pop(0)
104 return []
105
106
107 def make_harmony_context(
108 messages=None, available_tools=None, function_tool_names=None
109 ) -> tuple[HarmonyContext, FakeHarmonyParser]:
110 fake_parser = FakeHarmonyParser()
111 context = HarmonyContext(
112 messages=[] if messages is None else messages,
113 available_tools=[] if available_tools is None else available_tools,
114 function_tool_names=function_tool_names,
115 response_parser=fake_parser,
116 )
117 return context, fake_parser
118
119
120 def test_single_turn_token_counting():
121 """Test token counting behavior for a single turn."""
122 # Create a context
123 context, _ = make_harmony_context()
124
125 # Create a mock RequestOutput with specific token counts
126 mock_output = create_mock_request_output(
127 prompt_token_ids=[1, 2, 3, 4, 5], # 5 prompt tokens
128 output_token_ids=[6, 7, 8], # 3 output tokens
129 num_cached_tokens=2, # 2 cached tokens
130 )
131
132 # Append the output to the context
133 context.append_output(mock_output)
134
135 # Verify the token counts
136 assert context.num_prompt_tokens == 5
137 assert context.num_output_tokens == 3
138 assert context.num_cached_tokens == 2
139 assert context.num_tool_output_tokens == 0 # No tool tokens in first turn
140
141 # Verify internal state tracking
142 assert not context.is_first_turn
143 assert len(context.all_turn_metrics) == 1
144 previous_turn = context.all_turn_metrics[0]
145 assert previous_turn.input_tokens == 5
146 assert previous_turn.output_tokens == 3
147 assert previous_turn.cached_input_tokens == 2
148 assert previous_turn.tool_output_tokens == 0
149
150
151 @pytest.mark.asyncio
152 async def test_multi_turn_token_counting():
153 """Test token counting behavior across multiple turns with tool output."""
154 # Create a context
155 context, _ = make_harmony_context(available_tools=["browser"])
156
157 # Simulate a conversation with 3 turns
158 # Turn 1: prefill 5, decode 3, tool 7
159 # Turn 2: prefill 15, cached 5, decode 4, tool 1
160 # Turn 3: prefill 20, cached 15, decode 5
161 prompt_token_counts = [5, 15, 20]
162 output_token_counts = [3, 4, 5]
163 cached_token_counts = [0, 5, 15]
164 mock_generator = generate_mock_outputs(
165 3, prompt_token_counts, output_token_counts, cached_token_counts
166 )
167
168 # First turn - initial prompt and response
169 mock_output1 = await anext(mock_generator)
170 context.append_output(mock_output1)
171
172 # At this point, we should have 5 prompt tokens and 3 output tokens
173 assert context.num_prompt_tokens == 5
174 assert context.num_output_tokens == 3
175 assert context.num_tool_output_tokens == 0
176
177 # Second turn - after tool output
178 mock_output2 = await anext(mock_generator)
179 context.append_output(mock_output2)
180 # Current prompt tokens (15) - last_turn_input_tokens (5) -
181 # last_turn_output_tokens (3) = 7
182 expected_tool_output = 7
183
184 assert context.num_prompt_tokens == 5 + 15
185 assert context.num_output_tokens == 3 + 4
186 assert context.num_tool_output_tokens == expected_tool_output
187 assert context.num_cached_tokens == 5
188
189 # Third turn - final response
190 mock_output3 = await anext(mock_generator)
191 context.append_output(mock_output3)
192 # Additional tool output tokens from third turn:
193 # Current prompt (20) - last_turn_input_tokens (15) -
194 # last_turn_output_tokens (4) = 1
195 expected_tool_output = 7 + 1
196
197 assert context.num_prompt_tokens == 5 + 15 + 20
198 assert context.num_output_tokens == 3 + 4 + 5
199 assert context.num_tool_output_tokens == expected_tool_output
200 assert context.num_cached_tokens == 5 + 15
201
202 # Validate all turn metrics
203 assert len(context.all_turn_metrics) == 3
204 for i, turn in enumerate(context.all_turn_metrics):
205 assert turn.input_tokens == prompt_token_counts[i]
206 assert turn.output_tokens == output_token_counts[i]
207 assert turn.cached_input_tokens == cached_token_counts[i]
208 assert context.all_turn_metrics[1].tool_output_tokens == 7
209 assert context.all_turn_metrics[2].tool_output_tokens == 1
210
211
212 def test_empty_output_tokens():
213 """Test behavior when RequestOutput has empty output tokens."""
214 context, _ = make_harmony_context()
215
216 # Create a RequestOutput with empty output tokens
217 mock_output = create_mock_request_output(
218 prompt_token_ids=[1, 2, 3], # 3 prompt tokens
219 output_token_ids=[], # Empty output tokens list
220 num_cached_tokens=1,
221 )
222
223 context.append_output(mock_output)
224
225 # Should handle empty outputs gracefully
226 assert context.num_prompt_tokens == 3
227 assert context.num_output_tokens == 0 # No output tokens
228 assert context.num_cached_tokens == 1
229 assert context.num_tool_output_tokens == 0
230
231
232 def test_missing_prompt_token_ids():
233 """Test behavior when RequestOutput has None prompt_token_ids."""
234 context, _ = make_harmony_context()
235
236 mock_output = create_mock_request_output(
237 prompt_token_ids=None, # No prompt token IDs
238 output_token_ids=[1, 2], # 2 output tokens
239 num_cached_tokens=0,
240 )
241
242 # Logger.error will be called, but we don't need to check for warnings
243 # here Just ensure it doesn't raise an exception
244 context.append_output(mock_output)
245
246 # Should handle missing prompt tokens gracefully
247 assert context.num_prompt_tokens == 0
248 assert context.num_output_tokens == 2
249 assert context.num_cached_tokens == 0
250 assert context.num_tool_output_tokens == 0
251
252
253 def test_reasoning_tokens_counting():
254 """Test that reasoning tokens are counted correctly."""
255 context, parser = make_harmony_context()
256 parser.enqueue_chunk_result(reasoning_token_count=4)
257
258 mock_output = create_mock_request_output(
259 prompt_token_ids=[1, 2, 3],
260 output_token_ids=[4, 5, 6, 7], # 4 tokens, all in reasoning
261 num_cached_tokens=0,
262 )
263
264 context.append_output(mock_output)
265
266 # All output tokens should be counted as reasoning
267 assert context.num_reasoning_tokens == 4
268 assert context.num_output_tokens == 4
269
270
271 def test_preamble_tokens_not_counted_as_reasoning():
272 """Preambles (commentary with no recipient) are visible user text,
273 not hidden reasoning. They must NOT inflate num_reasoning_tokens."""
274 context, parser = make_harmony_context()
275 parser.enqueue_chunk_result(reasoning_token_count=0)
276
277 mock_output = create_mock_request_output(
278 prompt_token_ids=[1, 2, 3],
279 output_token_ids=[4, 5, 6],
280 num_cached_tokens=0,
281 )
282 context.append_output(mock_output)
283
284 assert context.num_reasoning_tokens == 0
285 assert context.num_output_tokens == 3
286
287
288 def test_commentary_with_recipient_counted_as_reasoning():
289 """Commentary directed at a tool (recipient != None) is hidden from
290 the user, so it should still count as reasoning tokens."""
291 context, parser = make_harmony_context()
292 parser.enqueue_chunk_result(reasoning_token_count=3)
293
294 mock_output = create_mock_request_output(
295 prompt_token_ids=[1, 2, 3],
296 output_token_ids=[4, 5, 6],
297 num_cached_tokens=0,
298 )
299 context.append_output(mock_output)
300
301 assert context.num_reasoning_tokens == 3
302 assert context.num_output_tokens == 3
303
304
305 def test_zero_tokens_edge_case():
306 """Test behavior with all zero token counts."""
307 context, _ = make_harmony_context()
308
309 # Create a request with empty lists (not None) for both prompt and
310 # output tokens
311 mock_output = create_mock_request_output(
312 prompt_token_ids=[], # Empty prompt tokens
313 output_token_ids=[], # Empty output tokens
314 num_cached_tokens=0,
315 )
316
317 context.append_output(mock_output)
318
319 # All counts should be zero
320 assert context.num_prompt_tokens == 0
321 assert context.num_output_tokens == 0
322 assert context.num_cached_tokens == 0
323 assert context.num_tool_output_tokens == 0
324 assert context.num_reasoning_tokens == 0
325
326
327 @pytest.mark.asyncio
328 async def test_single_turn_no_tool_output():
329 """Test that first turn never generates tool output tokens."""
330 context, _ = make_harmony_context(available_tools=["browser"])
331
332 # Even with large prompt in first turn, no tool tokens should be counted
333 mock_output = create_mock_request_output(
334 prompt_token_ids=list(range(100)), # 100 tokens
335 output_token_ids=[1, 2, 3],
336 num_cached_tokens=0,
337 )
338
339 context.append_output(mock_output)
340
341 # First turn should never have tool output tokens
342 assert context.num_tool_output_tokens == 0
343 assert context.is_first_turn is False # Should be updated after first turn
344
345
346 @pytest.mark.asyncio
347 async def test_negative_tool_tokens_edge_case():
348 """Test edge case where calculation could result in negative tool
349 tokens. We should log an error and clamp the value to 0."""
350 # Use patch to check if logger.error was called
351 with patch("vllm.entrypoints.openai.responses.context.logger.error") as mock_log:
352 context, _ = make_harmony_context(available_tools=["browser"])
353
354 # First turn
355 mock_output1 = create_mock_request_output(
356 prompt_token_ids=list(range(10)), # 10 tokens
357 output_token_ids=[1, 2, 3, 4, 5], # 5 tokens
358 )
359 context.append_output(mock_output1)
360
361 # Second turn with fewer new tokens than previous output
362 # This could happen in edge cases with aggressive caching
363 mock_output2 = create_mock_request_output(
364 prompt_token_ids=list(range(12)), # 12 tokens (only 2 new)
365 output_token_ids=[6, 7], # 2 tokens
366 )
367 context.append_output(mock_output2)
368
369 # Calculated negative tool tokens (12 - 10 - 5 = -3) should be clamped
370 # to 0 and an error should be logged
371 assert context.num_tool_output_tokens == 0
372 assert context.num_prompt_tokens == 10 + 12
373 assert context.num_output_tokens == 5 + 2
374
375 # Verify the error was logged properly
376 mock_log.assert_called_once()
377
378 # Extract the actual log message and arguments from the call
379 args, _ = mock_log.call_args
380 log_message = args[0]
381
382 # Check for key parts of the message
383 assert "Negative tool output tokens calculated" in log_message
384 assert "-3" in str(args) # Check that -3 is in the arguments
385
386
387 @pytest.mark.asyncio
388 async def test_streaming_multi_turn_token_counting():
389 """Test token counting for streaming multi-turn conversations.
390
391 This test focuses on how HarmonyContext counts tokens in a
392 multi-turn conversation with streaming (token-by-token) outputs and
393 message boundaries.
394 """
395 # Create a streaming context
396 context, parser = make_harmony_context(available_tools=["browser"])
397
398 num_prompt_tokens = [3, 8, 13]
399 num_output_tokens = [3, 3, 2]
400 num_cached_tokens = [0, 3, 8]
401
402 # Simulate three turns of conversation:
403 # Turn 1: stream tokens one by one, then finish the message
404 # Turn 2: new prompt, stream more tokens with a reasoning segment
405 # Turn 3: new prompt with tool output and cached tokens
406
407 # First turn: 3 tokens streamed one by one
408 # First token of first turn
409 context.append_output(
410 create_mock_request_output(
411 prompt_token_ids=[1, 2, 3], # 3 prompt tokens
412 output_token_ids=[101], # Single token
413 num_cached_tokens=num_cached_tokens[0],
414 finished=False, # Not end of message yet
415 )
416 )
417
418 # Second token of first turn
419 context.append_output(
420 create_mock_request_output(
421 output_token_ids=[102],
422 finished=False,
423 )
424 )
425
426 # Last token of first turn (finished=True signals end of message)
427 context.append_output(
428 create_mock_request_output(
429 output_token_ids=[103],
430 finished=True, # End of message
431 )
432 )
433
434 # Check token counts after first turn
435 assert context.num_prompt_tokens == 3 # Initial prompt tokens
436 assert context.num_output_tokens == 3 # Three output tokens
437 assert context.num_cached_tokens == 0
438 assert context.num_tool_output_tokens == 0 # No tool output in first turn
439 assert context.first_tok_of_message is True # Ready for next message
440
441 # First token of second turn
442 parser.enqueue_chunk_result(reasoning_token_count=1)
443 context.append_output(
444 create_mock_request_output(
445 prompt_token_ids=[
446 1,
447 2,
448 3,
449 101,
450 102,
451 103,
452 4,
453 5,
454 ], # 8 tokens (includes previous)
455 output_token_ids=[201],
456 num_cached_tokens=num_cached_tokens[1], # Some tokens cached
457 finished=False,
458 )
459 )
460
461 # More tokens in reasoning channel
462 parser.enqueue_chunk_result(reasoning_token_count=1)
463 context.append_output(
464 create_mock_request_output(
465 output_token_ids=[202],
466 finished=False,
467 )
468 )
469
470 parser.enqueue_chunk_result(reasoning_token_count=1)
471 context.append_output(
472 create_mock_request_output(
473 output_token_ids=[203],
474 finished=True, # End of reasoning message
475 )
476 )
477
478 # Check counts after second turn (reasoning message)
479 assert context.num_prompt_tokens == 3 + 8 # Initial + second prompt
480 assert context.num_output_tokens == 3 + 3 # First turn + second turn
481 assert context.num_reasoning_tokens == 3 # All tokens in analysis channel
482 assert context.num_cached_tokens == 3 # Cached tokens from second turn
483
484 # Formula: this turn prompt tokens - last turn prompt - last turn output
485 expected_tool_tokens = 8 - 3 - 3 # = 2
486 assert context.num_tool_output_tokens == expected_tool_tokens
487
488 # Third turn (with more cached tokens)
489 context.append_output(
490 create_mock_request_output(
491 prompt_token_ids=[
492 1,
493 2,
494 3,
495 101,
496 102,
497 103,
498 4,
499 5,
500 201,
501 202,
502 203,
503 6,
504 7,
505 ], # 13 tokens
506 output_token_ids=[301],
507 num_cached_tokens=num_cached_tokens[2], # More cached tokens
508 finished=False,
509 )
510 )
511
512 context.append_output(
513 create_mock_request_output(
514 output_token_ids=[302],
515 finished=True,
516 )
517 )
518
519 # Final token counts check
520 assert context.num_prompt_tokens == sum(num_prompt_tokens) # All prompts
521 assert context.num_output_tokens == sum(num_output_tokens) # All outputs
522 assert context.num_reasoning_tokens == 3 # Unchanged from second turn
523 assert context.num_cached_tokens == sum(
524 num_cached_tokens
525 ) # Accumulated cached tokens
526
527 # Additional tool tokens from third turn
528 # Formula: this turn prompt - last turn prompt - last turn output
529 additional_tool_tokens = 13 - 8 - 3 # = 2
530 assert (
531 context.num_tool_output_tokens == expected_tool_tokens + additional_tool_tokens
532 )
533
534 # Validate all turn metrics
535 assert len(context.all_turn_metrics) == 3
536 for i, turn in enumerate(context.all_turn_metrics):
537 assert turn.input_tokens == num_prompt_tokens[i]
538 assert turn.output_tokens == num_output_tokens[i]
539 assert turn.cached_input_tokens == num_cached_tokens[i]
540 assert context.all_turn_metrics[1].tool_output_tokens == 2
541 assert context.all_turn_metrics[2].tool_output_tokens == 2
542
543
544 @pytest.mark.asyncio
545 async def test_streaming_message_synchronization():
546 """Completed messages from append-local and flush segments sync into context."""
547 # Create a streaming context with some initial messages
548 initial_messages = [
549 Message(
550 author=Author(role=Role.USER, name="user"),
551 content=[TextContent(text="Hello")],
552 recipient=Role.ASSISTANT,
553 )
554 ]
555 context, parser = make_harmony_context(messages=initial_messages)
556
557 # Verify initial state
558 assert len(context._messages) == 1
559 assert context.num_init_messages == 1
560
561 response_text = "First response"
562 message = Message(
563 author=Author(role=Role.ASSISTANT, name="assistant"),
564 content=[TextContent(text=response_text)],
565 recipient=Role.USER,
566 )
567 parser.enqueue_chunk_result(
568 segments=[
569 Segment(
570 channel="commentary",
571 recipient=None,
572 delta="",
573 completed_message=message,
574 )
575 ]
576 )
577
578 # This should sync the completed message from the latest append
579 context.append_output(
580 create_mock_request_output(
581 prompt_token_ids=[1, 2, 3], output_token_ids=[101], finished=False
582 )
583 )
584
585 # Verify that messages were synchronized
586 assert len(context._messages) == 2
587
588 # Verify the new messages were added correctly
589 assert context._messages[1].content[0].text == response_text
590
591 messages_minus_init = len(context._messages) - context.num_init_messages
592 assert messages_minus_init == 1
593
594 response_text = "Second response"
595 message = Message(
596 author=Author(role=Role.ASSISTANT, name="assistant"),
597 content=[TextContent(text=response_text)],
598 recipient=Role.USER,
599 )
600 flush_segments = [
601 Segment(
602 channel="final",
603 recipient=None,
604 delta=response_text,
605 completed_message=None,
606 ),
607 Segment(
608 channel="final",
609 recipient=None,
610 delta="",
611 completed_message=message,
612 ),
613 ]
614 parser.enqueue_flush_result(flush_segments)
615
616 # Create another output to trigger synchronization via flush()
617 context.append_output(
618 create_mock_request_output(
619 prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True
620 )
621 )
622
623 # Verify the flushed response was added, num_init_messages is still 1
624 assert len(context._messages) == 3
625 assert context.num_init_messages == 1
626 assert context._messages[2].content[0].text == response_text
627 assert context.last_append_flush_status is True
628 assert len(context.last_append_segments) == 2
629 assert context.last_append_segments[-2].delta == response_text
630 assert context.last_append_segments[-1].completed_message is message
631
632
633 def test_turn_metrics_copy_and_reset():
634 """Test TurnMetrics copy and reset methods work correctly."""
635 # Create a TurnMetrics with specific values
636 original_metrics = TurnMetrics(
637 input_tokens=10,
638 output_tokens=20,
639 cached_input_tokens=5,
640 tool_output_tokens=3,
641 )
642
643 # Test copy functionality
644 copied_metrics = original_metrics.copy()
645
646 # Verify copy has same values
647 assert copied_metrics.input_tokens == 10
648 assert copied_metrics.output_tokens == 20
649 assert copied_metrics.cached_input_tokens == 5
650 assert copied_metrics.tool_output_tokens == 3
651
652 # Verify they are separate objects
653 assert copied_metrics is not original_metrics
654
655 # Modify copy to ensure independence
656 copied_metrics.input_tokens = 999
657 assert original_metrics.input_tokens == 10 # Original unchanged
658 assert copied_metrics.input_tokens == 999
659
660 # Test reset functionality
661 original_metrics.reset()
662
663 # Verify all fields are reset to zero
664 assert original_metrics.input_tokens == 0
665 assert original_metrics.output_tokens == 0
666 assert original_metrics.cached_input_tokens == 0
667 assert original_metrics.tool_output_tokens == 0
668
669 # Verify copied metrics are unaffected by reset
670 assert copied_metrics.input_tokens == 999
671 assert copied_metrics.output_tokens == 20
672 assert copied_metrics.cached_input_tokens == 5
673 assert copied_metrics.tool_output_tokens == 3
674
675
676 # ==================== SimpleContext Tests ====================
677
678
679 def create_simple_context_output(
680 text="",
681 token_ids=None,
682 prompt="Test prompt",
683 prompt_token_ids=None,
684 num_cached_tokens=0,
685 num_cache_creation_tokens=None,
686 logprobs=None,
687 finished=True,
688 ):
689 """Helper to create a RequestOutput with customizable text for
690 SimpleContext tests."""
691 if token_ids is None:
692 token_ids = []
693 return RequestOutput(
694 request_id="test-id",
695 prompt=prompt,
696 prompt_token_ids=prompt_token_ids,
697 prompt_logprobs=None,
698 outputs=[
699 CompletionOutput(
700 index=0,
701 text=text,
702 token_ids=token_ids,
703 cumulative_logprob=0.0,
704 logprobs=logprobs,
705 finish_reason=None,
706 stop_reason=None,
707 )
708 ],
709 finished=finished,
710 num_cached_tokens=num_cached_tokens,
711 num_cache_creation_tokens=num_cache_creation_tokens,
712 )
713
714
715 def test_simple_context_output_messages_empty():
716 """output_messages should be empty before any output is appended."""
717 context = SimpleContext()
718 assert context.output_messages == []
719
720
721 def test_simple_context_output_messages_single_call():
722 """Non-streaming: single append_output produces a single output message."""
723 context = SimpleContext()
724 output = create_simple_context_output(
725 text="Hello world",
726 token_ids=[10, 20, 30],
727 prompt_token_ids=[1, 2, 3],
728 )
729 context.append_output(output)
730
731 messages = context.output_messages
732 assert len(messages) == 1
733 assert messages[0].message == "Hello world"
734 assert messages[0].tokens == [10, 20, 30]
735 assert messages[0].type == "raw_message_tokens"
736
737
738 def test_simple_context_output_messages_streaming_consolidation():
739 """Streaming: multiple append_output calls consolidate into one message."""
740 context = SimpleContext()
741
742 # Simulate 3 streaming deltas
743 context.append_output(
744 create_simple_context_output(
745 text="Hello",
746 token_ids=[10],
747 prompt_token_ids=[1, 2, 3],
748 )
749 )
750 context.append_output(
751 create_simple_context_output(
752 text=" world",
753 token_ids=[20],
754 prompt_token_ids=[1, 2, 3],
755 )
756 )
757 context.append_output(
758 create_simple_context_output(
759 text="!",
760 token_ids=[30],
761 prompt_token_ids=[1, 2, 3],
762 )
763 )
764
765 messages = context.output_messages
766 assert len(messages) == 1
767 assert messages[0].message == "Hello world!"
768 assert messages[0].tokens == [10, 20, 30]
769
770
771 def test_simple_context_output_messages_many_deltas():
772 """Streaming with many small deltas still produces a single message."""
773 context = SimpleContext()
774
775 words = ["The", " quick", " brown", " fox", " jumps"]
776 for i, word in enumerate(words):
777 context.append_output(
778 create_simple_context_output(
779 text=word,
780 token_ids=[100 + i],
781 prompt_token_ids=[1, 2],
782 )
783 )
784
785 messages = context.output_messages
786 assert len(messages) == 1
787 assert messages[0].message == "The quick brown fox jumps"
788 assert messages[0].tokens == [100, 101, 102, 103, 104]
789
790
791 def test_simple_context_input_messages():
792 """input_messages is populated on the first append_output call."""
793 context = SimpleContext()
794 assert context.input_messages == []
795
796 context.append_output(
797 create_simple_context_output(
798 text="Hi",
799 token_ids=[10],
800 prompt="My prompt text",
801 prompt_token_ids=[1, 2, 3],
802 )
803 )
804
805 assert len(context.input_messages) == 1
806 assert context.input_messages[0].message == "My prompt text"
807 assert context.input_messages[0].tokens == [1, 2, 3]
808
809 # Second call should not add another input message
810 context.append_output(
811 create_simple_context_output(
812 text=" there",
813 token_ids=[20],
814 prompt="My prompt text",
815 prompt_token_ids=[1, 2, 3],
816 )
817 )
818
819 assert len(context.input_messages) == 1
820
821
822 def test_simple_context_token_counting():
823 """Token counting accumulates across streaming deltas."""
824 context = SimpleContext()
825
826 context.append_output(
827 create_simple_context_output(
828 text="a",
829 token_ids=[10, 11],
830 prompt_token_ids=[1, 2, 3, 4, 5],
831 num_cached_tokens=2,
832 num_cache_creation_tokens=3,
833 )
834 )
835 context.append_output(
836 create_simple_context_output(
837 text="b",
838 token_ids=[12],
839 prompt_token_ids=[1, 2, 3, 4, 5],
840 num_cached_tokens=2,
841 num_cache_creation_tokens=3,
842 )
843 )
844
845 assert context.num_prompt_tokens == 5
846 assert context.num_output_tokens == 3 # 2 + 1
847 assert context.num_cached_tokens == 2
848 assert context.num_cache_creation_tokens == 3
849
850
851 def test_simple_context_final_output():
852 """final_output reconstructs accumulated text and token_ids."""
853 context = SimpleContext()
854
855 context.append_output(
856 create_simple_context_output(
857 text="foo",
858 token_ids=[1, 2],
859 prompt_token_ids=[10],
860 )
861 )
862 context.append_output(
863 create_simple_context_output(
864 text="bar",
865 token_ids=[3],
866 prompt_token_ids=[10],
867 )
868 )
869
870 final = context.final_output
871 assert final is not None
872 assert final.outputs[0].text == "foobar"
873 assert final.outputs[0].token_ids == (1, 2, 3)
874
875
876 def test_simple_context_output_messages_empty_text_with_tokens():
877 """output_messages should be returned when tokens exist even if text is
878 empty (e.g. special tokens)."""
879 context = SimpleContext()
880 context.append_output(
881 create_simple_context_output(
882 text="",
883 token_ids=[99],
884 prompt_token_ids=[1],
885 )
886 )
887
888 messages = context.output_messages
889 assert len(messages) == 1
890 assert messages[0].message == ""
891 assert messages[0].tokens == [99]
892
893
894 def test_simple_context_output_messages_no_mutation():
895 """Each call to output_messages returns a fresh list; callers can't
896 corrupt internal state."""
897 context = SimpleContext()
898 context.append_output(
899 create_simple_context_output(
900 text="hello",
901 token_ids=[1],
902 prompt_token_ids=[10],
903 )
904 )
905
906 msgs1 = context.output_messages
907 msgs2 = context.output_messages
908 assert msgs1 is not msgs2
909 assert msgs1[0].message == msgs2[0].message
910
911 # Appending more output updates the property
912 context.append_output(
913 create_simple_context_output(
914 text=" world",
915 token_ids=[2],
916 prompt_token_ids=[10],
917 )
918 )
919
920 msgs3 = context.output_messages
921 assert len(msgs3) == 1
922 assert msgs3[0].message == "hello world"
923 assert msgs3[0].tokens == [1, 2]