Skip to content

Commit b052692

Browse files
fix: port tool parsing to v1 Generator after rebase
The rebase onto main dropped tool parsing changes from generator.py because main refactored it into a thin version-detection wrapper. This ports the tool parsing logic to the v1 Generator: - Add tool_call_parser field and _init_tool_parser() method - Add _extract_tool_calls() using vLLM's ToolParserManager - Update _to_completions() to populate tool_calls/content on Completion - Fix tests to match v1 Generator interface (_to_completions takes prompt) - Fix integration test to import Generator directly (Policy doesn't exist)
1 parent 7825e2d commit b052692

3 files changed

Lines changed: 112 additions & 30 deletions

File tree

src/forge/actors/vllm/v1/generator.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@
3838
from torchstore.api import _controller as get_torchstore_controller
3939
from vllm.engine.arg_utils import EngineArgs
4040
from vllm.entrypoints.llm import UsageContext
41+
from vllm.entrypoints.openai.protocol import (
42+
ChatCompletionRequest,
43+
ExtractedToolCallInformation,
44+
ToolCall,
45+
)
4146
from vllm.outputs import RequestOutput
4247
from vllm.sampling_params import RequestOutputKind, SamplingParams
48+
from vllm.tokenizers import cached_tokenizer_from_config
49+
from vllm.tool_parsers import ToolParserManager
4350
from vllm.v1.engine.async_llm import AsyncLLM
4451

4552
logger = logging.getLogger(__name__)
@@ -78,6 +85,7 @@ class Generator(ForgeActor):
7885
sampling_params: SamplingParams | Mapping = field(default_factory=SamplingParams)
7986
prefetch_weights_to_shm: bool = True
8087
n_fetcher_procs: int = 8
88+
tool_call_parser: str | None = None
8189

8290
def __post_init__(self):
8391
super().__init__()
@@ -91,6 +99,8 @@ def __post_init__(self):
9199
self.engine_args = EngineArgs(**self.engine_args)
92100
self.vllm_config = self.engine_args.create_engine_config(UsageContext.LLM_CLASS)
93101

102+
self._tool_parser = None # Will hold ToolParser instance if configured
103+
94104
if isinstance(self.sampling_params, Mapping):
95105
self.sampling_params = SamplingParams.from_optional(**self.sampling_params)
96106
self.sampling_params.output_kind = RequestOutputKind.FINAL_ONLY
@@ -273,9 +283,42 @@ async def setup(self, host_mesh, worker_registry, gpu_ids: list[str]):
273283
)
274284
logger.info(f"Retrieved workers from registry: {self.workers}")
275285

286+
if self.tool_call_parser is not None:
287+
self._tool_parser = self._init_tool_parser()
288+
276289
if self.prefetch_weights_to_shm:
277290
self._spawn_fetchers()
278291

292+
def _init_tool_parser(self, tokenizer=None): # type: ignore[no-untyped-def]
293+
"""Initialize the tool parser based on configuration.
294+
295+
Args:
296+
tokenizer: Optional tokenizer (with encode/decode methods). If not provided,
297+
one is created from vllm_config. Passing explicitly is useful for testing.
298+
299+
Returns:
300+
Initialized ToolParser instance, or None if tool parsing is not configured.
301+
"""
302+
try:
303+
if tokenizer is None:
304+
tokenizer = cached_tokenizer_from_config(
305+
model_config=self.vllm_config.model_config,
306+
)
307+
parser_cls = ToolParserManager.get_tool_parser(self.tool_call_parser) # type: ignore[union-attr]
308+
parser = parser_cls(tokenizer)
309+
logger.info(f"Initialized tool parser: {self.tool_call_parser}")
310+
return parser
311+
except KeyError:
312+
available = list(ToolParserManager.tool_parsers.keys())
313+
logger.error(
314+
f"Unknown tool parser: '{self.tool_call_parser}'. "
315+
f"Available parsers: {available}"
316+
)
317+
return None
318+
except Exception as e:
319+
logger.error(f"Failed to initialize tool parser: {e}")
320+
return None
321+
279322
def _spawn_fetchers(self):
280323
"""Spawn weight fetchers that prefetch weights from torchstore to shared memory.
281324
@@ -545,6 +588,38 @@ def _extract_logprobs(self, output) -> torch.Tensor | None:
545588
)
546589
return None
547590

591+
def _extract_tool_calls(self, model_output: str) -> ExtractedToolCallInformation:
592+
"""Extract tool calls from model output using the configured tool parser.
593+
594+
Args:
595+
model_output: Raw text output from the model.
596+
597+
Returns:
598+
ExtractedToolCallInformation with parsed tool calls and remaining content.
599+
"""
600+
if self._tool_parser is None:
601+
return ExtractedToolCallInformation(
602+
tools_called=False, tool_calls=[], content=model_output
603+
)
604+
605+
try:
606+
dummy_request = ChatCompletionRequest(
607+
model=self.vllm_config.model_config.model,
608+
messages=[{"role": "user", "content": ""}],
609+
seed=42, # to calm the linter
610+
)
611+
612+
extracted = self._tool_parser.extract_tool_calls(
613+
model_output, dummy_request
614+
)
615+
616+
return extracted
617+
except Exception as e:
618+
logger.warning(f"Failed to parse tool calls: {e}")
619+
return ExtractedToolCallInformation(
620+
tools_called=False, tool_calls=[], content=model_output
621+
)
622+
548623
def _to_completions(
549624
self, request_output: RequestOutput, prompt: str
550625
) -> list[Completion]:
@@ -560,6 +635,14 @@ def _to_completions(
560635
completions = []
561636

562637
for output in request_output.outputs:
638+
tool_calls: list[ToolCall] = []
639+
content: str | None = None
640+
641+
if self._tool_parser is not None:
642+
extracted = self._extract_tool_calls(output.text)
643+
tool_calls = extracted.tool_calls
644+
content = extracted.content
645+
563646
completion = Completion(
564647
prompt=to_prompt(prompt),
565648
text=output.text,
@@ -575,6 +658,8 @@ def _to_completions(
575658
stop_reason=output.finish_reason,
576659
generator_version=self.generator_version,
577660
metadata={"num_cached_tokens": request_output.num_cached_tokens},
661+
tool_calls=tool_calls,
662+
content=content,
578663
)
579664
completions.append(completion)
580665

tests/integration_tests/test_tool_parsing.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
import pytest
2222
import pytest_asyncio
2323
import torch
24-
25-
from forge.rl import Policy
24+
from forge.actors.generator import Generator
2625
from huggingface_hub import snapshot_download
2726
from vllm.transformers_utils.tokenizer import get_tokenizer
2827

@@ -89,7 +88,7 @@ def tokenizer():
8988
async def policy(model_path):
9089
"""Create and teardown policy service for each test."""
9190
logger.info("Setting up policy service...")
92-
policy = await Policy.options(
91+
policy = await Generator.options(
9392
procs=1,
9493
num_replicas=1,
9594
with_gpus=True,
@@ -157,8 +156,6 @@ async def test_tool_parsing_multi_turn(policy, tokenizer):
157156
logger.info(f"Final answer: {final.text}")
158157
assert "579" in final.text, "Expected 123 + 456 = 579"
159158

160-
logger.info("✅ test_tool_parsing_multi_turn passed!")
161-
162159

163160
@requires_cuda
164161
@pytest.mark.asyncio
@@ -195,5 +192,3 @@ async def test_content_without_tool_calls(policy, tokenizer):
195192
assert (
196193
completion.content == completion.text
197194
), "Content should equal text when no tools"
198-
199-
logger.info("✅ test_content_without_tool_calls passed!")

tests/unit_tests/test_generator.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@
77
"""Unit tests for Generator's _to_completions and _extract_tool_calls logic."""
88

99
import json
10-
from dataclasses import dataclass
11-
from typing import Any
1210
from unittest.mock import MagicMock
1311

1412
import pytest
15-
1613
from vllm.outputs import CompletionOutput, RequestOutput
1714

1815

@@ -26,20 +23,15 @@ def _import_error():
2623
return True
2724

2825

29-
@dataclass
30-
class TokenizerWrapper:
31-
"""Wrapper to mimic vLLM's tokenizer structure (has .tokenizer attr)."""
32-
33-
tokenizer: Any
34-
35-
3626
class _StubTokenizer:
3727
"""Minimal stub tokenizer for initializing the Hermes tool parser in tests.
3828
3929
The Hermes tool parser from vLLM requires:
4030
- get_vocab(): Returns vocab dict mapping tokens to ids
4131
- vocab: Direct vocab attribute
4232
- eos_token_id: End of sequence token id
33+
- encode(text, add_special_tokens=False): Encode text to token ids
34+
- decode(token_ids): Decode token ids to text
4335
- <tool_call> and </tool_call> tokens in vocab (for streaming support)
4436
"""
4537

@@ -50,22 +42,32 @@ def __init__(self):
5042
"<tool_call>": 1,
5143
"</tool_call>": 2,
5244
}
45+
self._id_to_token = {v: k for k, v in self.vocab.items()}
5346
self.eos_token_id = 0
5447

5548
def get_vocab(self) -> dict[str, int]:
5649
"""Return vocabulary dict (required by Hermes tool parser)."""
5750
return self.vocab
5851

52+
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
53+
"""Encode text to token ids. Returns ids for known tokens, empty otherwise."""
54+
if text in self.vocab:
55+
return [self.vocab[text]]
56+
return [ord(c) for c in text]
57+
58+
def decode(self, token_ids: list[int]) -> str:
59+
"""Decode token ids to text."""
60+
return "".join(self._id_to_token.get(tid, chr(tid)) for tid in token_ids)
5961

60-
@pytest.fixture(scope="module")
61-
def tokenizer_wrapper():
62-
"""Create a tokenizer wrapper with Hermes-compatible tokenizer (has tool_call tokens)."""
6362

64-
return TokenizerWrapper(_StubTokenizer())
63+
@pytest.fixture(scope="module")
64+
def stub_tokenizer():
65+
"""Create a stub tokenizer compatible with Hermes tool parser."""
66+
return _StubTokenizer()
6567

6668

6769
@pytest.fixture
68-
def generator_with_hermes(tokenizer_wrapper):
70+
def generator_with_hermes(stub_tokenizer):
6971
"""Create Generator with hermes parser properly initialized."""
7072
from forge.actors.generator import Generator
7173

@@ -74,7 +76,7 @@ def generator_with_hermes(tokenizer_wrapper):
7476
sampling_params={"max_tokens": 64},
7577
tool_call_parser="hermes",
7678
)
77-
generator._tool_parser = generator._init_tool_parser(tokenizer_wrapper)
79+
generator._tool_parser = generator._init_tool_parser(stub_tokenizer)
7880
generator.generator_version = 1
7981

8082
return generator
@@ -115,7 +117,7 @@ def make_mock_request_output(
115117
class TestInitToolParser:
116118
"""Test the _init_tool_parser method of Generator."""
117119

118-
def test_init_hermes_parser(self, tokenizer_wrapper):
120+
def test_init_hermes_parser(self, stub_tokenizer):
119121
"""Test that passing tool_call_parser='hermes' initializes the parser."""
120122
from forge.actors.generator import Generator
121123

@@ -125,7 +127,7 @@ def test_init_hermes_parser(self, tokenizer_wrapper):
125127
tool_call_parser="hermes",
126128
)
127129

128-
parser = generator._init_tool_parser(tokenizer_wrapper)
130+
parser = generator._init_tool_parser(stub_tokenizer)
129131

130132
assert parser is not None
131133
assert hasattr(parser, "extract_tool_calls")
@@ -142,7 +144,7 @@ def test_init_parser_none_when_not_configured(self):
142144

143145
assert generator.tool_call_parser is None
144146

145-
def test_init_parser_invalid_parser_name(self, tokenizer_wrapper):
147+
def test_init_parser_invalid_parser_name(self, stub_tokenizer):
146148
"""Test that invalid parser name returns None."""
147149
from forge.actors.generator import Generator
148150

@@ -152,7 +154,7 @@ def test_init_parser_invalid_parser_name(self, tokenizer_wrapper):
152154
tool_call_parser="nonexistent_parser",
153155
)
154156

155-
parser = generator._init_tool_parser(tokenizer_wrapper)
157+
parser = generator._init_tool_parser(stub_tokenizer)
156158
assert parser is None
157159

158160

@@ -298,7 +300,7 @@ def test_to_completions_without_tool_parser(self):
298300
outputs=[{"text": "The answer is 4.", "token_ids": [10, 20, 30]}],
299301
)
300302

301-
completions = generator._to_completions(request_output)
303+
completions = generator._to_completions(request_output, request_output.prompt)
302304

303305
assert len(completions) == 1
304306
completion = completions[0]
@@ -319,7 +321,7 @@ def test_to_completions_no_tool_call_with_parser(self, generator_with_hermes):
319321
],
320322
)
321323

322-
completions = generator._to_completions(request_output)
324+
completions = generator._to_completions(request_output, request_output.prompt)
323325

324326
assert len(completions) == 1
325327
completion = completions[0]
@@ -345,7 +347,7 @@ def test_to_completions_multiple_outputs(self, generator_with_hermes):
345347
],
346348
)
347349

348-
completions = generator._to_completions(request_output)
350+
completions = generator._to_completions(request_output, request_output.prompt)
349351

350352
assert len(completions) == 2
351353
# First completion has tool call

0 commit comments

Comments
 (0)