|
2 | 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
3 | 3 |
|
4 | 4 | from openai.types.responses import ResponseFunctionToolCall, ResponseReasoningItem |
| 5 | +from openai.types.responses.response_output_item import McpCall |
5 | 6 | from openai_harmony import Author, Message, Role, TextContent |
6 | 7 |
|
7 | 8 | from vllm.entrypoints.openai.parser.harmony_utils import ( |
@@ -400,17 +401,19 @@ def test_commentary_with_multiple_function_calls(self): |
400 | 401 | assert output_items[0].arguments == '{"location": "San Francisco"}' |
401 | 402 | assert output_items[1].arguments == '{"location": "New York"}' |
402 | 403 |
|
403 | | - def test_commentary_with_unknown_recipient_raises_error(self): |
404 | | - """Test that commentary with unknown recipient raises ValueError.""" |
405 | | - message = Message.from_role_and_content(Role.ASSISTANT, "some content") |
| 404 | + def test_commentary_with_unknown_recipient_creates_mcp_call(self): |
| 405 | + """Test that commentary with unknown recipient creates MCP call.""" |
| 406 | + message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') |
406 | 407 | message = message.with_channel("commentary") |
407 | | - message = message.with_recipient("unknown_recipient") |
| 408 | + message = message.with_recipient("custom_tool") |
408 | 409 |
|
409 | | - try: |
410 | | - parse_output_message(message) |
411 | | - raise AssertionError("Expected ValueError to be raised") |
412 | | - except ValueError as e: |
413 | | - assert "Unknown recipient: unknown_recipient" in str(e) |
| 410 | + output_items = parse_output_message(message) |
| 411 | + |
| 412 | + assert len(output_items) == 1 |
| 413 | + assert isinstance(output_items[0], McpCall) |
| 414 | + assert output_items[0].type == "mcp_call" |
| 415 | + assert output_items[0].name == "custom_tool" |
| 416 | + assert output_items[0].server_label == "custom_tool" |
414 | 417 |
|
415 | 418 | def test_analysis_channel_creates_reasoning(self): |
416 | 419 | """Test that analysis channel creates reasoning items.""" |
@@ -451,3 +454,167 @@ def test_has_custom_tools() -> None: |
451 | 454 | assert has_custom_tools( |
452 | 455 | {"web_search_preview", "code_interpreter", "container", "others"} |
453 | 456 | ) |
| 457 | + |
| 458 | + |
| 459 | +def test_parse_mcp_call_basic() -> None: |
| 460 | + """Test that MCP calls are parsed with correct type and server_label.""" |
| 461 | + message = Message.from_role_and_content(Role.ASSISTANT, '{"path": "/tmp"}') |
| 462 | + message = message.with_recipient("filesystem") |
| 463 | + message = message.with_channel("commentary") |
| 464 | + |
| 465 | + output_items = parse_output_message(message) |
| 466 | + |
| 467 | + assert len(output_items) == 1 |
| 468 | + assert isinstance(output_items[0], McpCall) |
| 469 | + assert output_items[0].type == "mcp_call" |
| 470 | + assert output_items[0].name == "filesystem" |
| 471 | + assert output_items[0].server_label == "filesystem" |
| 472 | + assert output_items[0].arguments == '{"path": "/tmp"}' |
| 473 | + assert output_items[0].status == "completed" |
| 474 | + |
| 475 | + |
| 476 | +def test_parse_mcp_call_dotted_recipient() -> None: |
| 477 | + """Test that dotted recipients extract the tool name correctly.""" |
| 478 | + message = Message.from_role_and_content(Role.ASSISTANT, '{"cmd": "ls"}') |
| 479 | + message = message.with_recipient("repo_browser.list") |
| 480 | + message = message.with_channel("commentary") |
| 481 | + |
| 482 | + output_items = parse_output_message(message) |
| 483 | + |
| 484 | + assert len(output_items) == 1 |
| 485 | + assert isinstance(output_items[0], McpCall) |
| 486 | + assert output_items[0].name == "list" |
| 487 | + assert output_items[0].server_label == "repo_browser" |
| 488 | + |
| 489 | + |
| 490 | +def test_mcp_vs_function_call() -> None: |
| 491 | + """Test that function calls are not parsed as MCP calls.""" |
| 492 | + func_message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') |
| 493 | + func_message = func_message.with_recipient("functions.my_tool") |
| 494 | + func_message = func_message.with_channel("commentary") |
| 495 | + |
| 496 | + func_items = parse_output_message(func_message) |
| 497 | + |
| 498 | + assert len(func_items) == 1 |
| 499 | + assert not isinstance(func_items[0], McpCall) |
| 500 | + assert func_items[0].type == "function_call" |
| 501 | + |
| 502 | + |
| 503 | +def test_mcp_vs_builtin_tools() -> None: |
| 504 | + """Test that built-in tools (python, container) are not parsed as MCP calls.""" |
| 505 | + # Test python (built-in tool) - should be reasoning, not MCP |
| 506 | + python_message = Message.from_role_and_content(Role.ASSISTANT, "print('hello')") |
| 507 | + python_message = python_message.with_recipient("python") |
| 508 | + python_message = python_message.with_channel("commentary") |
| 509 | + |
| 510 | + python_items = parse_output_message(python_message) |
| 511 | + |
| 512 | + assert len(python_items) == 1 |
| 513 | + assert not isinstance(python_items[0], McpCall) |
| 514 | + assert python_items[0].type == "reasoning" |
| 515 | + |
| 516 | + |
| 517 | +def test_parse_remaining_state_commentary_channel() -> None: |
| 518 | + """Test parse_remaining_state with commentary channel and various recipients.""" |
| 519 | + from unittest.mock import Mock |
| 520 | + |
| 521 | + from vllm.entrypoints.harmony_utils import parse_remaining_state |
| 522 | + |
| 523 | + # Test 1: functions.* recipient → should return function tool call |
| 524 | + parser_func = Mock() |
| 525 | + parser_func.current_content = '{"arg": "value"}' |
| 526 | + parser_func.current_role = Role.ASSISTANT |
| 527 | + parser_func.current_channel = "commentary" |
| 528 | + parser_func.current_recipient = "functions.my_tool" |
| 529 | + |
| 530 | + func_items = parse_remaining_state(parser_func) |
| 531 | + |
| 532 | + assert len(func_items) == 1 |
| 533 | + assert not isinstance(func_items[0], McpCall) |
| 534 | + assert func_items[0].type == "function_call" |
| 535 | + assert func_items[0].name == "my_tool" |
| 536 | + assert func_items[0].status == "in_progress" |
| 537 | + |
| 538 | + # Test 2: MCP tool (not builtin) → should return MCP call |
| 539 | + parser_mcp = Mock() |
| 540 | + parser_mcp.current_content = '{"path": "/tmp"}' |
| 541 | + parser_mcp.current_role = Role.ASSISTANT |
| 542 | + parser_mcp.current_channel = "commentary" |
| 543 | + parser_mcp.current_recipient = "filesystem" |
| 544 | + |
| 545 | + mcp_items = parse_remaining_state(parser_mcp) |
| 546 | + |
| 547 | + assert len(mcp_items) == 1 |
| 548 | + assert isinstance(mcp_items[0], McpCall) |
| 549 | + assert mcp_items[0].type == "mcp_call" |
| 550 | + assert mcp_items[0].name == "filesystem" |
| 551 | + assert mcp_items[0].server_label == "filesystem" |
| 552 | + assert mcp_items[0].status == "in_progress" |
| 553 | + |
| 554 | + # Test 3: Built-in tool (python) |
| 555 | + # should NOT return MCP call, falls through to reasoning |
| 556 | + parser_builtin = Mock() |
| 557 | + parser_builtin.current_content = "print('hello')" |
| 558 | + parser_builtin.current_role = Role.ASSISTANT |
| 559 | + parser_builtin.current_channel = "commentary" |
| 560 | + parser_builtin.current_recipient = "python" |
| 561 | + |
| 562 | + builtin_items = parse_remaining_state(parser_builtin) |
| 563 | + |
| 564 | + # Should fall through to reasoning logic |
| 565 | + assert len(builtin_items) == 1 |
| 566 | + assert not isinstance(builtin_items[0], McpCall) |
| 567 | + assert builtin_items[0].type == "reasoning" |
| 568 | + |
| 569 | + |
| 570 | +def test_parse_remaining_state_analysis_channel() -> None: |
| 571 | + """Test parse_remaining_state with analysis channel and various recipients.""" |
| 572 | + from unittest.mock import Mock |
| 573 | + |
| 574 | + from vllm.entrypoints.harmony_utils import parse_remaining_state |
| 575 | + |
| 576 | + # Test 1: functions.* recipient → should return function tool call |
| 577 | + parser_func = Mock() |
| 578 | + parser_func.current_content = '{"arg": "value"}' |
| 579 | + parser_func.current_role = Role.ASSISTANT |
| 580 | + parser_func.current_channel = "analysis" |
| 581 | + parser_func.current_recipient = "functions.my_tool" |
| 582 | + |
| 583 | + func_items = parse_remaining_state(parser_func) |
| 584 | + |
| 585 | + assert len(func_items) == 1 |
| 586 | + assert not isinstance(func_items[0], McpCall) |
| 587 | + assert func_items[0].type == "function_call" |
| 588 | + assert func_items[0].name == "my_tool" |
| 589 | + assert func_items[0].status == "in_progress" |
| 590 | + |
| 591 | + # Test 2: MCP tool (not builtin) → should return MCP call |
| 592 | + parser_mcp = Mock() |
| 593 | + parser_mcp.current_content = '{"query": "test"}' |
| 594 | + parser_mcp.current_role = Role.ASSISTANT |
| 595 | + parser_mcp.current_channel = "analysis" |
| 596 | + parser_mcp.current_recipient = "database" |
| 597 | + |
| 598 | + mcp_items = parse_remaining_state(parser_mcp) |
| 599 | + |
| 600 | + assert len(mcp_items) == 1 |
| 601 | + assert isinstance(mcp_items[0], McpCall) |
| 602 | + assert mcp_items[0].type == "mcp_call" |
| 603 | + assert mcp_items[0].name == "database" |
| 604 | + assert mcp_items[0].server_label == "database" |
| 605 | + assert mcp_items[0].status == "in_progress" |
| 606 | + |
| 607 | + # Test 3: Built-in tool (container) |
| 608 | + # should NOT return MCP call, falls through to reasoning |
| 609 | + parser_builtin = Mock() |
| 610 | + parser_builtin.current_content = "docker run" |
| 611 | + parser_builtin.current_role = Role.ASSISTANT |
| 612 | + parser_builtin.current_channel = "analysis" |
| 613 | + parser_builtin.current_recipient = "container" |
| 614 | + |
| 615 | + builtin_items = parse_remaining_state(parser_builtin) |
| 616 | + |
| 617 | + # Should fall through to reasoning logic |
| 618 | + assert len(builtin_items) == 1 |
| 619 | + assert not isinstance(builtin_items[0], McpCall) |
| 620 | + assert builtin_items[0].type == "reasoning" |
0 commit comments