|
23 | 23 | # OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
24 | 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
25 | 25 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 26 | + |
| 27 | + |
26 | 28 | import json |
27 | | -import os |
| 29 | +from pathlib import Path |
28 | 30 | from typing import Dict, List, Optional |
29 | 31 |
|
30 | 32 | import openai |
31 | 33 | import pytest |
| 34 | +from fastapi.testclient import TestClient |
32 | 35 | from openai.types.chat import ( |
33 | 36 | ChatCompletionMessageParam, |
34 | 37 | ChatCompletionMessageToolCall, |
35 | 38 | ChatCompletionNamedToolChoiceParam, |
36 | 39 | ChatCompletionToolParam, |
37 | 40 | ) |
| 41 | +from tests.utils import setup_fastapi_app, setup_server |
38 | 42 |
|
39 | 43 | # resources for testing the tool callings |
40 | 44 | WEATHER_TOOL: ChatCompletionToolParam = { |
@@ -592,3 +596,82 @@ async def test_inconsistent_tool_choice_and_tools( |
592 | 596 | tools=[], |
593 | 597 | logprobs=False, |
594 | 598 | ) |
| 599 | + |
| 600 | + |
| 601 | +class TestStreamingToolParseLimit: |
| 602 | + """ |
| 603 | + Test the streaming tool-call parse-size limit. Utilizes the |
| 604 | + tool_parser_models/tool_stream_py model, which streams a Mistral-format |
| 605 | + tool call in small fragments. A prompt containing "big-tool-args" produces |
| 606 | + an argument large enough to exceed the configured limit, and any other prompt |
| 607 | + results in an argument that remains within the limit. |
| 608 | + """ |
| 609 | + |
| 610 | + MODEL = "tool_stream_py" |
| 611 | + # Small enough that a single tool call can deterministically exceed it. |
| 612 | + LIMIT_BYTES = 512 |
| 613 | + |
| 614 | + @pytest.fixture(scope="class") |
| 615 | + def client(self, tokenizer_model: str): |
| 616 | + model_repository = str(Path(__file__).parent / "tool_parser_models") |
| 617 | + server = setup_server(model_repository) |
| 618 | + app = setup_fastapi_app( |
| 619 | + tokenizer=tokenizer_model, |
| 620 | + server=server, |
| 621 | + backend=None, |
| 622 | + tool_call_parser="mistral", |
| 623 | + max_tool_call_parse_bytes=self.LIMIT_BYTES, |
| 624 | + ) |
| 625 | + with TestClient(app) as test_client: |
| 626 | + yield test_client |
| 627 | + server.stop() |
| 628 | + |
| 629 | + def _stream(self, client, content, with_tools): |
| 630 | + """Streams a chat completion |
| 631 | + returns (finish_reasons, streamed_text).""" |
| 632 | + payload = { |
| 633 | + "model": self.MODEL, |
| 634 | + "messages": [{"role": "user", "content": content}], |
| 635 | + "stream": True, |
| 636 | + "max_completion_tokens": 4096, |
| 637 | + } |
| 638 | + if with_tools: |
| 639 | + payload["tools"] = [WEATHER_TOOL] |
| 640 | + |
| 641 | + finish_reasons: List[str] = [] |
| 642 | + text = "" |
| 643 | + with client.stream("POST", "/v1/chat/completions", json=payload) as response: |
| 644 | + assert response.status_code == 200 |
| 645 | + for line in response.iter_lines(): |
| 646 | + if not line.startswith("data: "): |
| 647 | + continue |
| 648 | + data = line[len("data: ") :] |
| 649 | + if data.strip() == "[DONE]": |
| 650 | + break |
| 651 | + choice = json.loads(data)["choices"][0] |
| 652 | + if choice.get("finish_reason"): |
| 653 | + finish_reasons.append(choice["finish_reason"]) |
| 654 | + delta = choice.get("delta") or {} |
| 655 | + if delta.get("content"): |
| 656 | + text += delta["content"] |
| 657 | + for tool_call in delta.get("tool_calls") or []: |
| 658 | + arguments = (tool_call.get("function") or {}).get("arguments") |
| 659 | + if arguments: |
| 660 | + text += arguments |
| 661 | + return finish_reasons, text |
| 662 | + |
| 663 | + def test_truncated_when_tool_call_exceeds_limit(self, client): |
| 664 | + finish_reasons, _ = self._stream(client, "big-tool-args", with_tools=True) |
| 665 | + # Once the accumulated tool-call text passes the cap, the stream will be terminated with finish_reason="length". |
| 666 | + assert finish_reasons[-1] == "length" |
| 667 | + |
| 668 | + def test_not_truncated_within_limit(self, client): |
| 669 | + finish_reasons, _ = self._stream(client, "small tool call", with_tools=True) |
| 670 | + # A short tool call stays under the cap and completes normally. |
| 671 | + assert "length" not in finish_reasons |
| 672 | + |
| 673 | + def test_limit_ignored_without_tools(self, client): |
| 674 | + finish_reasons, text = self._stream(client, "big-tool-args", with_tools=False) |
| 675 | + # Plain streaming does not enter the tool parser, so the cap never fires. |
| 676 | + assert "length" not in finish_reasons |
| 677 | + assert len(text) > self.LIMIT_BYTES |
0 commit comments