# NOTE: This module is imported at the top level of furiosa_llm, so it must not
# import packages that require native packages (e.g.,
# furiosa-llm-native, furiosa-native-llm-common) at
# module scope. Use lazy imports inside methods or TYPE_CHECKING blocks instead,
# so that `import furiosa_llm` works without those packages installed.
# This is necessary because npu-tools and download CI environments depend only
# on furiosa-models without native packages.
from collections.abc import AsyncGenerator, Sequence
import json
import logging
import os
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
cast,
)
import weakref
from pydantic_core import to_json
from furiosa_llm.vllm_compat import (
PromptType,
fit_prompt_to_context,
get_score_prompt,
preprocess_prompt,
prompt_to_str,
resolve_score_truncation_kwargs,
resolve_truncation_side,
)
if TYPE_CHECKING:
from furiosa.llm_native.llm import VisionPatches
from openai.types.chat import ChatCompletionMessageParam
import uuid
import torch
from transformers.tokenization_python import PreTrainedTokenizer
from transformers.tokenization_utils_base import BatchEncoding
from transformers.tokenization_utils_tokenizers import PreTrainedTokenizerFast
from furiosa_llm.metadata.tasks import POOLING_TASKS, PoolingTask, TaskType
from furiosa_llm.server.utils import is_list_of, random_uuid
from furiosa_llm.version import FURIOSA_LLM_VERSION
from .device import resolve_devices
from .errors import validate_context_length
from .metadata.config_types import LoggerConfig, SchedulerConfig
from .metadata.runtime import ModelMetadata
from .outputs import (
EmbeddingRequestOutput,
NativeOutputConverter,
PoolingOutput,
PoolingRequestOutput,
RequestOutput,
RequestOutputKind,
ScoringRequestOutput,
)
from .sampling_params import PoolingParams, SamplingParams
from .tokenizer import encode_auto, get_tokenizer
from .utils import get_logger_with_tz
logger = get_logger_with_tz(logging.getLogger(__name__))
def _resolve_fxb(resolved, model_id_or_path, revision):
"""Resolve the FXB to run when none was given explicitly via ``--fxb``.
Order: (1) an ``.fxb`` shipped in the model's own repo (``discover_fxb``,
already filtered to the running build's revision), then (2) the local FXB
cache. Cache compatibility is decided by the model fingerprint
(architecture plus the kernel-affecting ``config.json`` fields — hidden_size,
intermediate_size, quantization, etc.), matched across *every* cached bundle
regardless of which repo it came from; the source repo id is only a ranking
tiebreaker. The cache is therefore consulted only when ``model_id_or_path``
is a repo id (not a local path), because building that fingerprint requires
fetching the target's ``config.json`` from the Hub by repo id.
``revision`` is the already-resolved HuggingFace revision (the caller has
applied the furiosa-llm-version default), used to fetch that target
``config.json`` so the fingerprint reflects the same repo snapshot as the
model download.
From the cache we auto-use only a fingerprint-compatible bundle whose
FuriosaIR (npu-ir) revision matches the running build; if the cache holds
only compatible-but-stale (revision-mismatched) bundles, we refuse and tell
the user to pass ``--fxb`` explicitly rather than silently loading a bundle
built for a different compiler revision.
"""
from furiosa_llm.utils import _looks_like_path, discover_fxb
try:
return discover_fxb(resolved)
except FileNotFoundError as repo_err:
if _looks_like_path(model_id_or_path):
raise
from furiosa.llm_native import fxb as _fxb
try:
outcome = _fxb.check(str(model_id_or_path), revision=revision, cache_dir=None)
except Exception as check_err: # noqa: BLE001 - no network / not a real repo / no config
logger.debug("FXB cache lookup failed: %s", check_err)
raise repo_err from None
recommended = next(
(m for m in outcome.matches if m.recommended and m.npu_tools_match), None
)
if recommended is not None:
logger.info(
"No FXB in '%s'; using compatible cached FXB %s",
model_id_or_path,
recommended.entry.path,
)
return recommended.entry.path
if outcome.matches:
# Compatible bundles exist but none matches the running FuriosaIR
# revision; require an explicit choice.
raise FileNotFoundError(
f"No FXB found in '{model_id_or_path}', and the local cache only has "
f"FuriosaIR-revision-mismatched (stale) matches. Pass --fxb <path> to use one "
f"explicitly (e.g. {outcome.matches[0].entry.path})."
)
raise repo_err
def _detect_fxb_task(model_dir: Path, hf_config: Any) -> tuple[TaskType, bool]:
"""Infer (task, use_binary_seq_class) for an FXB-served model from its
sentence-transformers metadata, mirroring the Rust `detect_convert_task`
(furiosa-generator/src/fxb/metadata.rs): Qwen3 reuses `Qwen3ForCausalLM` for
its embedding and reranker repos, so the HF architecture can't tell them
apart. Gate on `modules.json` (absent -> generative); otherwise
`config_sentence_transformers.json`'s `model_type` `"CrossEncoder"` -> score
(binary seq-class head), anything else -> embed.
Cross-encoder rerankers ship no sentence-transformers metadata at all
(BAAI/bge-reranker-v2-m3), so before falling back to generative, read the HF
architecture: a one-label sequence-classification head is a binary scorer."""
from furiosa_llm.metadata.tasks import EMBED, GENERATE, SCORE
if not (model_dir / "modules.json").exists():
architectures = getattr(hf_config, "architectures", None) or []
if (
any(arch.endswith("ForSequenceClassification") for arch in architectures)
and getattr(hf_config, "num_labels", None) == 1
):
return SCORE, True
return GENERATE, False
model_type = None
st_config = model_dir / "config_sentence_transformers.json"
if st_config.exists():
try:
model_type = json.loads(st_config.read_text(encoding="utf-8")).get("model_type")
except (OSError, ValueError):
model_type = None
return (SCORE, True) if model_type == "CrossEncoder" else (EMBED, False)
# Default index of the padding block when paged attention model is used.
DEFAULT_PAGED_ATTENTION_PADDING_BLOCK_IDX = 0
TokenizerModeType = Literal["auto", "slow"]
ChatTemplateContentFormatOption = Literal["string"]
RAY_LOG_PREFIX = "[furiosa-llm]"
class NativeEngineLike(Protocol):
@property
def max_model_len(self) -> int: ...
@property
def pooling_type(self) -> str | None: ...
def generate(self, inputs: Any, sampling_params: Any) -> Any: ...
def stream_generate(
self,
inputs: Any,
sampling_params: Any,
request_id: str | None = None,
) -> AsyncGenerator[Any, None]: ...
def acquire_vision(self, media_id: int) -> list[int] | None: ...
async def encode_vision(
self,
media_ids: list[int],
patches: list["VisionPatches"],
request_id: str | None = None,
) -> list[list[int]]: ...
async def encode(
self,
inputs: Any,
pooling_params: Any,
request_id: str | None = None,
) -> Any: ...
def abort_request(self, request_id: str) -> None: ...
def is_alive(self) -> bool: ...
def wait_until_terminated(self) -> None: ...
def shutdown(self) -> None: ...
def reset_prefix_cache(self) -> bool: ...
def get_prometheus_metrics(self) -> str: ...
def _llm_finalize(engine: "NativeEngineLike | None", tmp_dir: Any) -> None:
if engine is not None:
engine.shutdown()
if tmp_dir is not None:
tmp_dir.cleanup()
def validate_speculative_args(
draft_model_id_or_path: "str | os.PathLike | None",
draft_fxb_path: "str | os.PathLike | None",
num_speculative_tokens: "int | None",
) -> None:
"""Validate the mutual constraints between the speculative-decoding arguments.
Shared by ``LLM.__init__`` and ``EngineArgs.__post_init__`` so the two stay in sync.
"""
if draft_model_id_or_path is None:
if num_speculative_tokens is not None:
raise ValueError(
"draft_model_id_or_path is required when num_speculative_tokens is set"
)
if draft_fxb_path is not None:
raise ValueError("draft_model_id_or_path is required when draft_fxb_path is set")
elif num_speculative_tokens is None:
raise ValueError("num_speculative_tokens is required when draft_model_id_or_path is set")
elif num_speculative_tokens < 1:
raise ValueError(f"num_speculative_tokens must be >= 1, got {num_speculative_tokens}")
def _validate_speculative_tokenizers(
target_tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast,
draft_tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast,
) -> None:
"""Require target and draft tokenizers to use the same token-id contract."""
target_vocab = target_tokenizer.get_vocab()
draft_vocab = draft_tokenizer.get_vocab()
if target_vocab != draft_vocab:
token = min(
token
for token in target_vocab.keys() | draft_vocab.keys()
if target_vocab.get(token) != draft_vocab.get(token)
)
raise ValueError(
"speculative decoding requires identical target and draft token-to-id mappings; "
f"first mismatch: token={token!r}, target id={target_vocab.get(token)!r}, "
f"draft id={draft_vocab.get(token)!r} "
f"(entries: target={len(target_vocab)}, draft={len(draft_vocab)})"
)
[docs]
class LLM:
"""An LLM for generating texts from given prompts and sampling parameters."""
max_model_len: int
engine: NativeEngineLike
def __init__(
self,
model_id_or_path: str | os.PathLike,
*,
# V3 engine Configuration
fxb: str | os.PathLike | None = None,
draft_model_id_or_path: str | os.PathLike | None = None,
draft_fxb_path: str | os.PathLike | None = None,
num_speculative_tokens: int | None = None,
# Repo Configuration
revision: str | None = None,
# Runtime Configuration
devices: str | Sequence[str] | None = None,
data_parallel_size: int | None = None,
pipeline_parallel_size: int | None = None,
max_io_memory_mb: int | None = None,
max_model_len: int | None = None,
# Pipeline selection related Configs
scheduler_config: SchedulerConfig | None = None,
# Observability related Configs
logger_config: LoggerConfig | None = None,
# Structured outputs related Configuration
structured_outputs_backend: Literal["auto", "guidance", "xgrammar"] = "auto",
# Other Configuration
tokenizer: str | PreTrainedTokenizer | PreTrainedTokenizerFast | None = None,
tokenizer_mode: TokenizerModeType = "auto",
seed: int | None = None,
served_model_name: str | None = None,
kv_transfer_config: dict[str, Any] | None = None,
**kwargs,
) -> None:
"""Instantiate LLM from saved artifacts or from a v3 engine (FXB).
When ``fxb`` is provided, the v3 engine path is used: ``model_id_or_path`` is
treated as a HuggingFace model id or directory, and model metadata is derived from it.
When ``fxb`` is omitted, ``model_id_or_path`` is resolved via HuggingFace Hub
download if needed. If the resolved directory contains ``artifact.json``, the
legacy artifact path is used. Otherwise, the v3 engine path is used and ``.fxb``
files are auto-discovered from the model directory.
Args:
model_id_or_path: A path to furiosa llm engine artifact or a HuggingFace model id.
fxb: Path to the FXB artifact file. When provided, the v3 engine is used
and ``model_id_or_path`` is treated as a HuggingFace model id or directory.
When omitted, FXB files are auto-discovered from the model directory if
no ``artifact.json`` is present.
draft_model_id_or_path: HuggingFace model id or local path for the draft model used by
speculative decoding.
draft_fxb_path: Optional FXB file for the draft model in v3 engine mode. When omitted,
draft FXB files are auto-discovered from the draft model directory.
num_speculative_tokens: Maximum draft tokens to propose per speculative decoding round.
revision: The revision of the model, if `model_id_or_path` is a HuggingFace model id.
devices: The devices to run the model. It can be a single device or a list of devices.
Each device can be either "npu:X" or "npu:X:\\*" where X is a specific device index.
If not given, all available devices will be used.
data_parallel_size: The size of the data parallelism group. If not given, it will be inferred from
total available PEs and other parallelism degrees.
pipeline_parallel_size: The size of the pipeline parallelism.
If not given, it will use the value from artifact.
num_blocks_per_pp_stage: The number of transformer blocks per each pipeline parallelism stage.
If only `pipeline_parallel_size` is provided, transformer blocks will be distributed equally.
max_io_memory_mb: NPU runtime I/O buffer reserve per device pool, from 1 to 49152 MiB.
Co-located speculative target and draft lanes share one reserve. If unspecified,
the runtime estimates the required size from the model's bucket shapes.
max_model_len: Cap on the context length (prompt + generated tokens) a single request
may use. It may only lower the model's own limit: a value above either the
model's ``max_position_embeddings`` or the context length the artifact was
compiled for is rejected before any device is opened. If not given, the
artifact's own limit is used.
scheduler_config: Configuration for the scheduler, allowing to maximum number of tasks which can
be queued to HW, maximum number of samples that can be processed by the scheduler, and ratio
of spare blocks that are reserved by scheduler. If this is not given, scheduler config saved
in the artifacts will be used.
logger_config: Observability configuration (e.g. disabling the periodic per-DP stats
log). If not given, default logging behavior is used.
structured_outputs_backend: The backend for structured outputs. "auto" will automatically select the
best backend based on the model. "guidance" will use the guidance library. "xgrammar" will
use the xgrammar library.
tokenizer: The name or path of a HuggingFace Transformers tokenizer.
tokenizer_mode: The tokenizer mode. "auto" will use the fast tokenizer
if available, and "slow" will always use the slow tokenizer.
seed: The seed to initialize the random number generator for sampling.
skip_engine: If True, the native runtime engine will not be initialized. This is useful when you need
the pipelines for other purposes than running them with the engine.
served_model_name: The model name used in metrics and API responses.
If not specified, defaults to ``model_id_or_path``.
kv_transfer_config: Configuration for the remote KV connector used in PD-disaggregation.
"""
# The engine rejects an out-of-range cap, but `skip_engine=True` never builds one.
if max_model_len is not None and max_model_len <= 0:
raise ValueError(f"max_model_len must be greater than 0, got {max_model_len}")
validate_speculative_args(draft_model_id_or_path, draft_fxb_path, num_speculative_tokens)
self.num_speculative_tokens = num_speculative_tokens
# Resolve model_id_or_path and determine v2 (artifact) vs v3 (FXB) engine.
# - fxb explicitly provided → v3 engine
# - artifact.json exists → legacy v2 artifact
# - otherwise → v3 engine with FXB auto-discovery
from furiosa_llm.utils import get_path_or_hf_download, resolve_effective_revision
# When no explicit revision is given, default to the furiosa-llm version
# tag for furiosa-ai repos (same rule as the legacy artifact path). This
# effective revision is reused for both the model download below and the
# FXB cache lookup, so they always target the same repo snapshot. When
# the default tag was auto-applied and the repo has no such tag yet, fall
# back to the default branch (main).
effective_revision, allow_main_fallback = resolve_effective_revision(
model_id_or_path, revision, FURIOSA_LLM_VERSION
)
resolved = get_path_or_hf_download(
model_id_or_path,
effective_revision,
fallback_to_main_on_missing=allow_main_fallback,
)
if draft_model_id_or_path is not None:
if os.fspath(draft_model_id_or_path) == os.fspath(model_id_or_path):
draft_effective_revision = effective_revision
resolved_draft = resolved
else:
draft_effective_revision, draft_allow_main_fallback = resolve_effective_revision(
draft_model_id_or_path, None, FURIOSA_LLM_VERSION
)
resolved_draft = get_path_or_hf_download(
draft_model_id_or_path,
draft_effective_revision,
fallback_to_main_on_missing=draft_allow_main_fallback,
)
else:
draft_effective_revision = None
resolved_draft = None
resolved_draft_fxb_path = draft_fxb_path
fxb_path = fxb or _resolve_fxb(resolved, model_id_or_path, effective_revision)
if resolved_draft is not None and resolved_draft_fxb_path is None:
if Path(resolved_draft).resolve() == Path(resolved).resolve():
resolved_draft_fxb_path = fxb_path
else:
resolved_draft_fxb_path = _resolve_fxb(
resolved_draft, draft_model_id_or_path, draft_effective_revision
)
self._init_from_v3_engine(
model_id_or_path=str(resolved),
fxb_path=fxb_path,
draft_model_id_or_path=resolved_draft,
draft_fxb_path=resolved_draft_fxb_path,
num_speculative_tokens=num_speculative_tokens,
devices=devices,
data_parallel_size=data_parallel_size,
pipeline_parallel_size=pipeline_parallel_size,
max_io_memory_mb=max_io_memory_mb,
max_model_len=max_model_len,
scheduler_config=scheduler_config,
logger_config=logger_config,
structured_outputs_backend=structured_outputs_backend,
tokenizer=tokenizer,
tokenizer_mode=tokenizer_mode,
served_model_name=served_model_name,
kv_transfer_config=kv_transfer_config,
)
self._finalizer = weakref.finalize(
self,
_llm_finalize,
getattr(self, "engine", None),
getattr(self, "tmp_dir", None),
)
def _init_from_v3_engine(
self,
model_id_or_path: str | os.PathLike,
fxb_path: str | os.PathLike,
*,
draft_model_id_or_path: str | os.PathLike | None = None,
draft_fxb_path: str | os.PathLike | None = None,
num_speculative_tokens: int | None = None,
devices: str | Sequence[str] | None = None,
data_parallel_size: int | None = None,
pipeline_parallel_size: int | None = None,
max_io_memory_mb: int | None = None,
max_model_len: int | None = None,
scheduler_config: SchedulerConfig | None = None,
logger_config: LoggerConfig | None = None,
structured_outputs_backend: Literal["auto", "guidance", "xgrammar"] = "auto",
tokenizer: str | PreTrainedTokenizer | PreTrainedTokenizerFast | None = None,
tokenizer_mode: TokenizerModeType = "auto",
served_model_name: str | None = None,
kv_transfer_config: dict[str, Any] | None = None,
) -> None:
from transformers import AutoConfig
from furiosa_llm.generation_config import get_diff_sampling_params
from furiosa_llm.metadata.tasks import GENERATION_TASKS
model_id_or_path = str(model_id_or_path)
fxb_path = str(fxb_path)
draft_model_path = (
str(draft_model_id_or_path) if draft_model_id_or_path is not None else None
)
draft_fxb = str(draft_fxb_path) if draft_fxb_path is not None else None
# Load tokenizer from HF model directory
self.tokenizer = get_tokenizer(model_id_or_path, tokenizer, tokenizer_mode=tokenizer_mode)
if draft_model_path is not None:
draft_tokenizer = get_tokenizer(draft_model_path, tokenizer_mode=tokenizer_mode)
_validate_speculative_tokenizers(self.tokenizer, draft_tokenizer)
# Recover the pooling task from sentence-transformers metadata; an FXB
# may be an embedding/reranker bundle despite a generative HF arch, and
# treating it as generative would 400 the pooling endpoints.
hf_config = AutoConfig.from_pretrained(model_id_or_path)
hf_configs = hf_config.to_dict()
task, use_binary_seq_class = _detect_fxb_task(Path(model_id_or_path), hf_config)
model_metadata = ModelMetadata(
model_type=hf_config.model_type,
task=task,
hf_configs=hf_configs,
use_binary_seq_class=use_binary_seq_class,
)
# Derive sequence length limits from HF config
max_position_embeddings = hf_configs.get("max_position_embeddings") or hf_configs.get(
"text_config", {}
).get("max_position_embeddings")
if max_position_embeddings is None:
raise ValueError("'max_position_embeddings' not found in the model config.")
# Set instance attributes
# `model_id_or_path` is a local snapshot dir (already resolved
# via get_path_or_hf_download in __init__). The MM helpers in
# furiosa_llm.multimodal feed it to AutoProcessor.from_pretrained.
self.model_id_or_path = model_id_or_path
self.served_model_name = served_model_name or model_id_or_path
self.model_metadata = model_metadata
self.model_config = hf_configs
self.artifact_id = Path(fxb_path).name
self.is_generative_model = task in GENERATION_TASKS
self.default_generation_config: dict[str, Any] = get_diff_sampling_params(model_id_or_path)
devices = resolve_devices(devices)
from furiosa.llm_native.llm import NativeLLMEngine
self.engine = NativeLLMEngine(
model_id_or_path,
draft_model_path,
devices,
data_parallel_size,
pipeline_parallel_size,
max_io_memory_mb,
self._serialize_obj(scheduler_config or SchedulerConfig()),
self._serialize_obj(logger_config or LoggerConfig()),
structured_outputs_backend,
num_speculative_tokens,
self.served_model_name,
fxb_path, # fxb_path — signals v3 engine
draft_fxb,
self._serialize_obj(kv_transfer_config) if kv_transfer_config else None,
max_model_len,
)
# Read back the value the engine resolved, not the one requested.
self.max_model_len = self.engine.max_model_len
[docs]
def get_default_sampling_params(self) -> SamplingParams:
"""Return SamplingParams reflecting model's generation_config defaults.
If the model has no generation_config or it matches HF defaults,
returns a default SamplingParams().
"""
if self.default_generation_config:
return SamplingParams.from_optional(**self.default_generation_config)
return SamplingParams()
@classmethod
def _serialize_obj(
cls,
obj: Any,
) -> str:
return to_json(obj).decode("utf-8")
[docs]
def generate(
self,
prompts: str | list[str],
sampling_params: SamplingParams | None = None,
prompt_token_ids: BatchEncoding | None = None,
tokenizer_kwargs: dict[str, Any] | None = None,
) -> RequestOutput | list[RequestOutput]:
"""Generate texts from given prompts and sampling parameters.
Args:
prompts: The prompts to generate texts.
sampling_params: The sampling parameters for generating texts.
If None, model's generation config defaults are used.
prompt_token_ids: Pre-tokenized prompt input as a `BatchEncoding` object.
If not provided, the prompt will be tokenized internally using the tokenizer.
tokenizer_kwargs: Additional keyword arguments passed to the tokenizer's
`encode` method, such as `{"use_special_tokens": True}`.
Returns:
A list of `RequestOutput` objects containing the generated
completions in the same order as the input prompts.
"""
if sampling_params is None:
sampling_params = self.get_default_sampling_params()
if not self.is_generative_model:
raise ValueError("generate API can only be used for generative models.")
if prompt_token_ids is None:
if tokenizer_kwargs is None:
tokenizer_kwargs = {}
prompt_token_ids = encode_auto(self.tokenizer, prompts, **tokenizer_kwargs)
input_ids = prompt_token_ids.input_ids
if input_ids and isinstance(input_ids[0], list):
longest_prompt_len = max(len(prompt) for prompt in input_ids)
else:
longest_prompt_len = len(input_ids)
validate_context_length(
max_model_len=self.max_model_len,
prompt_tokens=longest_prompt_len,
max_completion_tokens=sampling_params.max_tokens,
min_completion_tokens=max(1, sampling_params.min_tokens),
)
if isinstance(prompts, list):
prompt_list = prompts
batch_encodings = [
BatchEncoding({key: value[index] for key, value in prompt_token_ids.items()})
for index in range(len(prompts))
]
else:
prompt_list = [prompts]
batch_encodings = [prompt_token_ids]
async def generate_one(prompt: str, batch_encoding: BatchEncoding) -> RequestOutput:
final_output: RequestOutput | None = None
async for output in self._convert_generation_stream(
prompt,
batch_encoding,
sampling_params,
RequestOutputKind.FINAL,
):
final_output = output
if final_output is None:
raise RuntimeError("Native generation stream completed without producing output.")
return final_output
from furiosa_llm.utils import async_gather, run_sync
outputs = run_sync(
async_gather(
*(
generate_one(prompt, batch_encoding)
for prompt, batch_encoding in zip(prompt_list, batch_encodings)
)
)
)
return outputs if isinstance(prompts, list) else outputs[0]
[docs]
def chat(
self,
messages: list["ChatCompletionMessageParam"] | list[list["ChatCompletionMessageParam"]],
sampling_params: SamplingParams | None = None,
chat_template: str | None = None,
chat_template_content_format: ChatTemplateContentFormatOption = "string",
add_generation_prompt: bool = True,
continue_final_message: bool = False,
tools: list[dict[str, Any]] | None = None,
chat_template_kwargs: dict[str, Any] | None = None,
) -> list[RequestOutput]:
"""
Generate responses for a chat conversation.
The chat conversation is converted into a text prompt using the
tokenizer and calls the :meth:`generate` method to generate the
responses.
Args:
messages: A list of conversations or a single conversation.
- Each conversation is represented as a list of messages.
- Each message is a dictionary with 'role' and 'content' keys.
sampling_params: The sampling parameters for text generation.
chat_template: The template to use for structuring the chat.
If not provided, the model's default chat template will be used.
chat_template_content_format: The format to render message content.
Currently only "string" is supported.
add_generation_prompt: If True, adds a generation template
to each message.
continue_final_message: If True, continues the final message in
the conversation instead of starting a new one. Cannot be
``True`` if ``add_generation_prompt`` is also ``True``.
tools: Optional list of tools to use in the chat.
chat_template_kwargs: Additional keyword arguments to pass to the
chat template rendering function.
Returns:
A list of ``RequestOutput`` objects containing the generated
responses in the same order as the input messages.
"""
if sampling_params is None:
sampling_params = self.get_default_sampling_params()
if continue_final_message and add_generation_prompt:
raise ValueError(
"continue_final_message cannot be True when add_generation_prompt is True."
)
messages_list: list[list[ChatCompletionMessageParam]]
if is_list_of(messages, list):
messages_list = cast(list[list["ChatCompletionMessageParam"]], messages)
else:
messages_list = [cast(list["ChatCompletionMessageParam"], messages)]
_chat_template_kwargs: dict[str, Any] = dict(
chat_template=chat_template,
add_generation_prompt=add_generation_prompt,
continue_final_message=continue_final_message,
tools=tools,
)
_chat_template_kwargs.update(chat_template_kwargs or {})
rendered_prompts = self.tokenizer.apply_chat_template(
messages_list, # type: ignore[arg-type]
tokenize=False,
**_chat_template_kwargs,
)
return self.generate(
rendered_prompts, sampling_params, tokenizer_kwargs={"add_special_tokens": False} # type: ignore
)
[docs]
async def stream_generate(
self,
prompt: str,
sampling_params: SamplingParams | None = None,
prompt_token_ids: BatchEncoding | None = None,
tokenizer_kwargs: dict[str, Any] | None = None,
is_demo: bool = False,
) -> AsyncGenerator[str, None]:
"""Generate texts from given prompt and sampling parameters.
Args:
prompt: The prompt to generate texts. Note that unlike `generate`,
this API supports only a single prompt.
sampling_params: The sampling parameters for generating texts.
prompt_token_ids: Pre-tokenized prompt input as a `BatchEncoding` object.
If not provided, the prompt will be tokenized internally using the tokenizer.
tokenizer_kwargs: Additional keyword arguments passed to the tokenizer's
`encode` method, such as `{"use_special_tokens": True}`.
Returns:
A stream of generated output tokens.
"""
if sampling_params is None:
sampling_params = self.get_default_sampling_params()
if not self.is_generative_model:
raise ValueError("generate API can only be used for generative models.")
if not isinstance(prompt, str):
raise ValueError("prompt must be a single string.")
if prompt_token_ids is None:
if tokenizer_kwargs is None:
tokenizer_kwargs = {}
prompt_token_ids = encode_auto(self.tokenizer, prompt, **tokenizer_kwargs)
input_ids = prompt_token_ids.input_ids
if input_ids and isinstance(input_ids[0], list):
max_prompt_len = max(len(p) for p in input_ids)
else:
max_prompt_len = len(input_ids)
validate_context_length(
max_model_len=self.max_model_len,
prompt_tokens=max_prompt_len,
max_completion_tokens=sampling_params.max_tokens,
min_completion_tokens=max(1, sampling_params.min_tokens),
)
# FIXME: LLM.__init__() should take max_tokens to determine the maximum sequence length through bucket generations
# and use the config value to raise an error.
if is_demo and len(prompt_token_ids.input_ids) > 1024: # type: ignore
raise ValueError("The length of the prompt is larger than 1024 tokens")
output_stream = self._convert_generation_stream(
prompt,
prompt_token_ids,
sampling_params,
RequestOutputKind.DELTA,
)
async for request_output in output_stream:
for completion_output in request_output.outputs:
if completion_output.text:
yield completion_output.text
def _convert_generation_stream(
self,
prompt: str,
prompt_token_ids: BatchEncoding,
sampling_params: SamplingParams,
output_kind: RequestOutputKind,
) -> AsyncGenerator[RequestOutput, None]:
request_id = f"llm-engine-{random_uuid()}"
converter = NativeOutputConverter(
self.tokenizer,
sampling_params.n,
output_kind,
sampling_params.skip_special_tokens,
sampling_params.stop,
sampling_params.include_stop_str_in_output,
self.engine.abort_request,
request_id,
prompt,
prompt_token_ids.input_ids,
min_tokens=sampling_params.min_tokens,
)
native_sampling_params = sampling_params.clone()
native_sampling_params.stop = None
native_output_generator = self.engine.stream_generate(
prompt_token_ids,
native_sampling_params,
request_id,
)
return converter.convert_stream(native_output_generator)
# XXX(n0gu):
# More pooling APIs should be implemented - classify, reward, score.
# However as of 2025.11 only embed API will be used; We support Qwen3-Reranker,
# but this model uses slightly different scoring logic, thus not supported by vLLM's LLM.score() too.
# See: https://huggingface.co/Qwen/Qwen3-Reranker-0.6B#vllm-usage
[docs]
def encode(
self,
prompts: PromptType | Sequence[PromptType],
pooling_params: PoolingParams | Sequence[PoolingParams] | None = None,
*,
pooling_task: PoolingTask | None = None,
) -> list[PoolingRequestOutput]:
"""
Apply pooling to the hidden states corresponding to the input prompts.
Args:
prompts: The prompts to the LLM. You may pass a sequence of prompts
for batch inference.
pooling_params: The pooling parameters for pooling.
pooling_task: Override the pooling task to use.
Returns:
A list of `PoolingRequestOutput` objects containing the
pooled hidden states in the same order as the input prompts.
"""
task_type_from_model = self.model_metadata.task
if task_type_from_model not in POOLING_TASKS:
raise ValueError("Pooling API is not supported by this model.")
if not isinstance(prompts, list):
prompt_list = [cast(PromptType, prompts)]
else:
prompt_list = prompts
coroutines = []
prompt_token_ids = []
for i, prompt in enumerate(prompt_list):
param: PoolingParams
if isinstance(pooling_params, (list, tuple)):
param = pooling_params[i]
elif pooling_params is None:
param = PoolingParams()
elif isinstance(pooling_params, PoolingParams):
param = pooling_params
else:
raise TypeError(
f"pooling_params must be PoolingParams, Sequence[PoolingParams], or None, "
f"got {type(pooling_params)}"
)
batch_encoding, _ = preprocess_prompt(prompt, self.tokenizer)
fit_prompt_to_context(
batch_encoding,
truncate_prompt_tokens=param.truncate_prompt_tokens,
truncation_side=resolve_truncation_side(self.engine),
max_model_len=self.max_model_len,
)
# Set pooling task by precedence (highest to lowest).
# 1. Use the `pooling_task` argument if provided.
# 2. Otherwise, use the task already set in `params.task`.
# 3. If neither is set, infer the task from the model metadata.
from furiosa_llm.utils import coalesce
param.task = coalesce(pooling_task, param.task, cast(PoolingTask, task_type_from_model))
assert param.task is not None, "pooling task must be set at this point."
coroutines.append(
self.engine.encode(
batch_encoding,
param,
None, # TODO: set request id
)
)
prompt_token_ids.append(batch_encoding.input_ids)
request_id = uuid.uuid4().__str__()
from furiosa_llm.utils import async_gather, run_sync
native_outputs_list = run_sync(async_gather(*coroutines))
return [
PoolingRequestOutput(
request_id=request_id,
prompt_token_ids=prompt_token_ids[i],
outputs=PoolingOutput(data=torch.Tensor(native_outputs[0].data)),
finished=True,
)
for i, native_outputs in enumerate(native_outputs_list)
]
[docs]
def embed(
self,
prompts: PromptType | Sequence[PromptType],
pooling_params: PoolingParams | Sequence[PoolingParams] | None = None,
) -> list[EmbeddingRequestOutput]:
"""
Generate an embedding vector for each prompt. Only applicable to embedding models.
Args:
prompts: The prompts to the LLM. You may pass a sequence of prompts
for batch embedding.
pooling_params: The pooling parameters for pooling.
Returns:
A list of `EmbeddingRequestOutput` objects containing the
embedding vectors in the same order as the input prompts.
"""
if "embed" != self.model_metadata.task:
raise ValueError("Embedding API is not supported by this model.")
items = self.encode(
prompts,
pooling_params=pooling_params,
pooling_task="embed",
)
return [EmbeddingRequestOutput.from_base(item) for item in items]
[docs]
def score(
self,
data_1: PromptType | Sequence[PromptType],
data_2: PromptType | Sequence[PromptType],
/,
*,
truncate_prompt_tokens: int | None = None,
pooling_params: PoolingParams | None = None,
chat_template: str | None = None,
) -> list[ScoringRequestOutput]:
"""Generate similarity scores for all pairs `<text,text_pair>`.
The inputs can be `1 -> 1`, `1 -> N` or `N -> N`.
In the `1 - N` case the `data_1` input will be replicated `N`
times to pair with the `data_2` inputs.
Args:
data_1: Can be a single prompt or a list of prompts.
When a list, it must have the same length as the `data_2` list.
data_2: The data to pair with the query to form the input to
the LLM.
truncate_prompt_tokens: The number of tokens to truncate the prompt to.
pooling_params: The pooling parameters for pooling. If None, we
use the default pooling parameters.
chat_template: The chat template to use for the scoring. If None, the model's
own format is used. The template receives exactly two messages, `query` and
`document`, matching vLLM's template contract.
Returns:
A list of `ScoringRequestOutput` objects containing the
generated scores in the same order as the input prompts.
"""
model_metadata = self.model_metadata
if model_metadata.task != "score" and not model_metadata.use_binary_seq_class:
raise ValueError("LLM.score() is only supported for binary classification models.")
# Validate inputs and create pairs
# Convert single prompts to lists for uniform processing
is_data_1_list = isinstance(data_1, list)
is_data_2_list = isinstance(data_2, list)
# Normalize inputs to List[PromptType]
data_1_list: list[PromptType]
data_2_list: list[PromptType]
if not is_data_1_list and not is_data_2_list:
# 1 -> 1 case
data_1_list = [cast(PromptType, data_1)]
data_2_list = [cast(PromptType, data_2)]
elif not is_data_1_list and is_data_2_list:
# 1 -> N case: replicate data_1
data_2_list = cast(list[PromptType], data_2)
data_1_list = [cast(PromptType, data_1)] * len(data_2_list)
elif is_data_1_list and is_data_2_list:
# N -> N case: must have same length
data_1_list = cast(list[PromptType], data_1)
data_2_list = cast(list[PromptType], data_2)
if len(data_1_list) != len(data_2_list):
raise ValueError(
f"When both data_1 and data_2 are lists, they must have the same length. "
f"Got {len(data_1_list)} and {len(data_2_list)}."
)
else:
# data_1 is list, data_2 is not - this is not a standard case for scoring
raise ValueError(
"Invalid input combination. data_1 is a list but data_2 is not. "
"Expected patterns: (single, single), (single, list), or (list, list)."
)
# Normalize inputs to list[str]
data_1_strs: list[str] = [prompt_to_str(d, self.tokenizer) for d in data_1_list]
data_2_strs: list[str] = [prompt_to_str(d, self.tokenizer) for d in data_2_list]
# Set up pooling parameters with truncation if specified
if pooling_params is None:
pooling_params = PoolingParams()
if truncate_prompt_tokens is not None:
pooling_params.truncate_prompt_tokens = truncate_prompt_tokens
tokenization_kwargs = resolve_score_truncation_kwargs(
truncate_prompt_tokens=pooling_params.truncate_prompt_tokens,
max_model_len=self.max_model_len,
)
# Construct prompts for each pair
prompts: list[PromptType] = []
for str_1, str_2 in zip(data_1_strs, data_2_strs):
_, prompt = get_score_prompt(
self.tokenizer,
str_1,
str_2,
hf_configs=model_metadata.hf_configs,
score_template=chat_template,
tokenization_kwargs=tokenization_kwargs,
)
prompts.append(prompt)
# Call encode with the constructed prompts
items = self.encode(
prompts,
pooling_params=pooling_params,
pooling_task="score",
)
return [ScoringRequestOutput.from_base(item) for item in items]
[docs]
def shutdown(self):
"""Shutdown the LLM engine gracefully. Idempotent."""
finalizer = getattr(self, "_finalizer", None)
if finalizer is not None:
finalizer()
def __enter__(self) -> "LLM":
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.shutdown()