Source code for furiosa_llm.api

# 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.mm_options import validate_mm_processor_kwargs
from furiosa_llm.mm_types import MediaId
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 .speculative_args import (
    SpeculativeConfigLike,
    SpeculativeMethod,
    resolve_speculative_config,
)
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)


def _load_fxb_hf_config(model_dir: str | os.PathLike) -> Any:
    """Load serving metadata even when Transformers lacks the FXB architecture.

    FXB execution only needs the serialized config fields; its model implementation
    lives in the native runtime. Permit a newly compiled architecture before its config
    class reaches the pinned Transformers release. Registered-config errors remain intact.
    """
    from transformers import AutoConfig

    config_path = Path(model_dir) / "config.json"
    try:
        config = json.loads(config_path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return AutoConfig.from_pretrained(model_dir)

    model_type = config.get("model_type")
    if not isinstance(model_type, str) or not model_type:
        return AutoConfig.from_pretrained(model_dir)

    try:
        AutoConfig.for_model(model_type)
    except (KeyError, ValueError):
        pass
    else:
        return AutoConfig.from_pretrained(model_dir)

    from transformers import PretrainedConfig

    logger.warning(
        "Transformers has no matching config class for FXB model_type '%s'; "
        "using config.json metadata",
        model_type,
    )
    metadata_config_type = type(
        "_FxbMetadataConfig",
        (PretrainedConfig,),
        {"model_type": model_type},
    )
    return cast(type[PretrainedConfig], metadata_config_type).from_dict(config)


# 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: MediaId) -> list[int] | None: ...

    async def encode_vision(
        self,
        media_ids: list[MediaId],
        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, target: str = "all") -> 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_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, speculative_config: SpeculativeConfigLike | None = None, speculative_method: SpeculativeMethod | 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, npu_io_cpus: str | None = None, ssm_state_cache_size: int | None = None, ssm_full_memory_ratio: float | 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", served_model_name: str | None = None, kv_transfer_config: dict[str, Any] | None = None, mm_processor_kwargs: dict[str, Any] | None = None, **kwargs, ) -> None: """Instantiate LLM from a Hugging Face model and a compatible FXB. ``model_id_or_path`` is resolved through the Hugging Face Hub when needed and provides the model weights, configuration, and tokenizer. When ``fxb`` is omitted, a compatible ``.fxb`` file is discovered from the model directory or the local FXB cache. Args: model_id_or_path: A path to furiosa llm engine artifact or a HuggingFace model id. fxb: Optional path to a compatible FXB. When omitted, FXB files are discovered from the model directory or the local FXB cache. speculative_config: Canonical vLLM-compatible speculative configuration. It cannot be combined with the flat speculative arguments. speculative_method: Explicit method for the flat speculative arguments: ``draft_model`` or ``mtp``. 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. 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. The flat API also requires ``speculative_method``. 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 the loaded FXB. npu_io_cpus: One Linux logical CPU list for all NPU runtime I/O threads, e.g. "8-20" or "8-12,16,18". CPUs are assigned in list order to selected clusters in ascending NPU/cluster order. The list must contain at least as many distinct CPUs as selected clusters. The next CPU, if available, pins the shared I/O completion thread (reaper). Remaining CPU IDs in the supplied list are not used for pinning and are logged at debug level. None leaves CPU placement unchanged. This does not set a NUMA memory policy. 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. ssm_state_cache_size: Number of state cache slots for SSM models; ignored for softmax-only models. If unspecified, the pool is sized by `ssm_full_memory_ratio`. ssm_full_memory_ratio: Size the SSM state cache pool relative to the full-attention KV cache instead of a fixed slot count (state : KV = ratio : 1). Mutually exclusive with `ssm_state_cache_size`. Defaults to 1.0 when neither option is specified. 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 covered by the loaded FXB is rejected before any device is opened. If not given, the loaded FXB's limit is used. scheduler_config: Configuration for the scheduler, including the maximum number of tasks queued to hardware and the maximum number of processing samples. If omitted, the default scheduler configuration is 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. 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. mm_processor_kwargs: Processor defaults, e.g. {"min_pixels": 1024}, with optional HF-style images_kwargs. Request keys replace top-level defaults, including whole images_kwargs objects. An option must not appear both at the top level and in images_kwargs. """ if mm_processor_kwargs is not None: mm_processor_kwargs = validate_mm_processor_kwargs(mm_processor_kwargs) self.mm_processor_kwargs = mm_processor_kwargs # 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}") resolved_speculative_config = resolve_speculative_config( speculative_config, speculative_method=speculative_method, draft_model_id_or_path=draft_model_id_or_path, draft_fxb_path=draft_fxb_path, num_speculative_tokens=num_speculative_tokens, ) if resolved_speculative_config is None: speculative_method = None draft_model_id_or_path = None draft_fxb_path = None num_speculative_tokens = None else: speculative_method = resolved_speculative_config.method draft_model_id_or_path = resolved_speculative_config.model draft_fxb_path = resolved_speculative_config.draft_fxb_path num_speculative_tokens = resolved_speculative_config.num_speculative_tokens self.num_speculative_tokens = num_speculative_tokens # Resolve the model snapshot and compatible FXB. An explicit FXB takes precedence; # otherwise, discover one from the model directory or local FXB cache. 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. 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, speculative_method=speculative_method, 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, npu_io_cpus=npu_io_cpus, ssm_state_cache_size=ssm_state_cache_size, ssm_full_memory_ratio=ssm_full_memory_ratio, 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, speculative_method: SpeculativeMethod | 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, npu_io_cpus: str | None = None, ssm_state_cache_size: int | None = None, ssm_full_memory_ratio: float | 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 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 = _load_fxb_hf_config(model_id_or_path) hf_configs = hf_config.to_dict() if self.mm_processor_kwargs is not None: self.mm_processor_kwargs = validate_mm_processor_kwargs( self.mm_processor_kwargs, model_type=hf_config.model_type ) 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, speculative_method, num_speculative_tokens, self.served_model_name, fxb_path, draft_fxb, self._serialize_obj(kv_transfer_config) if kv_transfer_config else None, max_model_len, npu_io_cpus=npu_io_cpus, ssm_state_cache_size=ssm_state_cache_size, ssm_full_memory_ratio=ssm_full_memory_ratio, ) # 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()