This is an automated email from the ASF dual-hosted git repository.

dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new 6144aa7367d [FLINK-40440][python] Add model prediction support to the 
DataFrame API (#29478)
6144aa7367d is described below

commit 6144aa7367d9c56f600fe86afa08d576e6b0e8c0
Author: Liu Liu <[email protected]>
AuthorDate: Thu Oct 15 17:44:27 2026 +0800

    [FLINK-40440][python] Add model prediction support to the DataFrame API 
(#29478)
---
 .../docs/reference/pyflink.dataframe/dataframe.rst |   1 +
 .../docs/reference/pyflink.dataframe/llm.rst       |  39 +++
 flink-python/pyflink/dataframe/dataframe.py        |  17 ++
 flink-python/pyflink/dataframe/llm.py              | 274 ++++++++++++++++-
 flink-python/pyflink/dataframe/tests/test_llm.py   | 327 +++++++++++++++++++++
 .../dataframe/tests/test_llm_integration.py        |  88 ++++++
 flink-python/pyflink/table/model.py                |   6 +-
 flink-python/pyflink/table/table_environment.py    |  27 +-
 flink-python/pyflink/table/tests/test_model.py     |  32 +-
 9 files changed, 787 insertions(+), 24 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 1fc52491ec5..e31a2bf176d 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -161,6 +161,7 @@ Properties
 
     DataFrame.schema
     DataFrame.columns
+    DataFrame.llm
 
 Results
 -------
diff --git a/flink-python/docs/reference/pyflink.dataframe/llm.rst 
b/flink-python/docs/reference/pyflink.dataframe/llm.rst
index b51f9c45eff..ba96681a3ac 100644
--- a/flink-python/docs/reference/pyflink.dataframe/llm.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/llm.rst
@@ -48,6 +48,43 @@ For other providers available in the application's 
classpath, use ``GenericProvi
 with provider-specific option names and string values, or subclass 
``ModelProvider``
 to create a reusable configuration.
 
+Prediction
+----------
+
+Use ``df.llm.predict`` to infer a result for each row and append the output 
columns.
+Supply one or more column names in model input order. The provider determines 
which
+input types and number of columns it supports.
+
+.. code-block:: python
+
+    pf.set_model_provider("chat", pf.OpenAIProvider(
+        endpoint="https://api.openai.com/v1/chat/completions";, api_key="key", 
retry_num=3))
+    questions = pf.from_records([{"question": "What is Apache Flink?"}])
+    answers = questions.llm.predict(
+        "question", provider="chat", model="gpt-6-luna")
+
+The default output is one ``STRING`` column named ``output``. Set 
``output_type``
+to a SQL type string or a ``DataType`` to declare the provider's native output 
schema.
+
+A ``DataType.struct(...)`` expands its top-level fields into named output 
columns.
+Output field names must be unique and must not already occur in the input 
DataFrame.
+The provider validates the output schema it supports.
+
+.. code-block:: python
+
+    # Name the prediction column "answer" instead of the default "output".
+    answers = questions.llm.predict(
+        "question", provider="chat", model="gpt-6-luna",
+        output_type=pf.DataType.struct({"answer": pf.DataType.string()}))
+
+You can pass in extra arguments to override the selected provider's 
constructor settings:
+
+.. code-block:: python
+
+    # Override the provider's retry_num parameter for this call.
+    answers = questions.llm.predict(
+        "question", provider="chat", model="gpt-6-luna", retry_num=0)
+
 .. currentmodule:: pyflink.dataframe
 
 .. autosummary::
@@ -60,3 +97,5 @@ to create a reusable configuration.
     set_model_provider
     set_default_model_provider
     list_model_providers
+
+.. automethod:: pyflink.dataframe.llm.LLMAccessor.predict
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index 1d3eedd0973..d54f3538a5f 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
     import pyarrow
     from pyflink.dataframe.udf import _DataTypeLike
     from pyflink.dataframe.udtf import _DataFrameUDTFCall, 
_DataFrameUDTFWrapper
+    from pyflink.dataframe.llm import LLMAccessor
     from pyflink.table.table_environment import TableEnvironment
     from pyflink.table.table_schema import TableSchema
 
@@ -2639,6 +2640,22 @@ class DataFrame:
 
     # ======================== Properties ========================
 
+    @property
+    @PublicEvolving()
+    def llm(self) -> "LLMAccessor":
+        """
+        Access model inference operations for this DataFrame.
+
+        Example::
+
+            >>> result = df.llm.predict("question", provider="chat", 
model="gpt-6-luna")
+
+        .. versionadded:: 2.4.0
+        """
+        from pyflink.dataframe.llm import LLMAccessor
+
+        return LLMAccessor(self)
+
     @property
     @PublicEvolving()
     def schema(self) -> "TableSchema":
diff --git a/flink-python/pyflink/dataframe/llm.py 
b/flink-python/pyflink/dataframe/llm.py
index 122707dafb4..d9e404eb796 100644
--- a/flink-python/pyflink/dataframe/llm.py
+++ b/flink-python/pyflink/dataframe/llm.py
@@ -19,9 +19,16 @@
 from abc import ABC, abstractmethod
 from collections.abc import Mapping as MappingABC
 import json
-from typing import Dict, List, Literal, Mapping, Optional, Tuple, overload
+from typing import Callable, Dict, List, Literal, Mapping, Optional, Tuple, 
Union, cast, overload
 
+from pyflink.dataframe.dataframe import DataFrame
+from pyflink.dataframe.datatype import DataType
 from pyflink.dataframe.validation import _require_int, _require_number
+from pyflink.table.model_descriptor import ModelDescriptor
+from pyflink.table.schema import Schema
+from pyflink.table.types import (
+    DataType as TableDataType, DataTypes, RowType, _from_java_data_type,
+)
 from pyflink.util.api_stability_decorators import PublicEvolving
 
 __all__ = [
@@ -140,12 +147,12 @@ class ModelProvider(ABC):
 @PublicEvolving()
 class OpenAIProvider(ModelProvider):
     """
-    Configure an OpenAI-compatible service for chat completions or embeddings.
+    Configure an OpenAI-compatible service.
 
-    The full endpoint URL selects the task: use a URL ending in 
``/chat/completions``
-    for chat or ``/embeddings`` for embedding vectors. Chat options such as
-    ``system_prompt`` and ``temperature`` apply to chat requests; 
``dimension`` applies
-    to embeddings. Supply a model name here or when calling an AI function.
+    For chat completions, use a full endpoint URL ending in 
``/chat/completions``.
+    Supply a model name here or when calling ``df.llm.predict``. Chat accepts 
one
+    string input column and returns one string output column, matching the 
default
+    ``output_type``.
 
     All optional parameters default to ``None``. Omitted options use the Flink 
defaults
     described below, or the service's defaults where Flink defines none.
@@ -175,8 +182,8 @@ class OpenAIProvider(ModelProvider):
                  supplied).
     :param response_format: Chat response format: ``text`` or ``json_object``.
                             Default: ``None`` (service default).
-    :param dimension: Number of elements in each embedding vector. Default: 
``None``
-                      (the embedding model's default dimension).
+    :param dimension: Requested output vector dimension, when supported by the 
service.
+                      Default: ``None`` (service default).
     :param max_context_size: Maximum number of input context tokens before 
applying
                              ``context_overflow_action``. Default: ``None`` 
(no Flink
                              context limit).
@@ -201,13 +208,23 @@ class OpenAIProvider(ModelProvider):
         >>> chat = pf.OpenAIProvider(
         ...     endpoint="https://api.openai.com/v1/chat/completions";, 
api_key="key")
         >>> pf.set_model_provider("chat", chat)
-        >>> embeddings = pf.OpenAIProvider(
-        ...     endpoint="https://api.openai.com/v1/embeddings";, api_key="key")
-        >>> pf.set_model_provider("embed", embeddings)
+        >>> questions = pf.from_records([{"question": "What is Apache 
Flink?"}])
+        >>> answers = questions.llm.predict("question", provider="chat", 
model="gpt-6-luna")
 
     .. versionadded:: 2.4.0
     """
 
+    _OPTION_MAP = {
+        "endpoint": "endpoint", "api_key": "api-key", "model": "model",
+        "system_prompt": "system-prompt", "temperature": "temperature", 
"top_p": "top-p",
+        "max_tokens": "max-tokens", "stop": "stop", "presence_penalty": 
"presence-penalty",
+        "n": "n", "seed": "seed", "response_format": "response-format", 
"dimension": "dimension",
+        "max_context_size": "max-context-size",
+        "context_overflow_action": "context-overflow-action",
+        "error_handling_strategy": "error-handling-strategy", "retry_num": 
"retry-num",
+        "retry_fallback_strategy": "retry-fallback-strategy",
+    }
+
     def __init__(
         self, endpoint: str, api_key: str, *, model: Optional[str] = None,
         system_prompt: Optional[str] = None,
@@ -230,8 +247,7 @@ class OpenAIProvider(ModelProvider):
         retry_fallback_strategy: Optional[Literal["FAILOVER", "IGNORE"]] = 
None,
         options: Optional[Mapping[str, str]] = None,
     ):
-        _validate_name(endpoint, "endpoint")
-        _validate_name(api_key, "api_key")
+        self._validate_required_options({"endpoint": endpoint, "api-key": 
api_key})
         typed_options: Dict[str, object] = {
             "endpoint": endpoint, "api-key": api_key, "model": model,
             "system-prompt": system_prompt, "temperature": temperature, 
"top-p": top_p,
@@ -265,7 +281,14 @@ class OpenAIProvider(ModelProvider):
         ):
             if number is not None:
                 _require_number(number, name, lower_bound, maximum=upper_bound)
-        self._options = _merge_options(options, 
_stringify_options(typed_options))
+        self._parameters = {name: typed_options[key] for name, key in 
self._OPTION_MAP.items()}
+        self._typed_options = _stringify_options(typed_options)
+        self._options = _merge_options(options, self._typed_options)
+
+    @staticmethod
+    def _validate_required_options(options: Mapping[str, object]) -> None:
+        _validate_name(options.get("endpoint"), "endpoint")
+        _validate_name(options.get("api-key"), "api_key")
 
     def provider_identifier(self) -> str:
         """Return ``openai``.
@@ -292,6 +315,9 @@ class TritonProvider(ModelProvider):
     fallback options control how failed requests are handled; health checks 
and the
     circuit breaker can reduce requests to an unavailable server.
 
+    Set ``output_type`` in ``df.llm.predict`` to match the deployed model's 
output
+    type. The default is ``STRING``; other output types must be declared 
explicitly.
+
     All optional parameters default to ``None``. Omitted options use the Flink 
defaults
     described below. Specify durations as strings such as ``"30 s"`` or ``"100 
ms"``.
 
@@ -352,10 +378,29 @@ class TritonProvider(ModelProvider):
         ...     endpoint="http://localhost:8000";, model_name="classifier",
         ...     flatten_batch_dim=True, max_retries=2, default_value="-1")
         >>> pf.set_model_provider("classifier", provider)
+        >>> features = pf.from_records([{"features": [0.1, 0.9]}])
+        >>> predictions = features.llm.predict(
+        ...     "features", provider="classifier", 
output_type=pf.DataType.int32())
 
     .. versionadded:: 2.4.0
     """
 
+    _OPTION_MAP = {
+        "endpoint": "endpoint", "model_name": "model-name", "model_version": 
"model-version",
+        "timeout": "timeout", "flatten_batch_dim": "flatten-batch-dim", 
"priority": "priority",
+        "sequence_id": "sequence-id", "sequence_start": "sequence-start",
+        "sequence_end": "sequence-end", "compression": "compression", 
"auth_token": "auth-token",
+        "custom_headers": "custom-headers",
+        "max_retries": "max-retries", "retry_initial_backoff": 
"retry-initial-backoff",
+        "retry_max_backoff": "retry-max-backoff", "default_value": 
"default-value",
+        "health_check_enabled": "health-check-enabled",
+        "health_check_interval": "health-check-interval",
+        "circuit_breaker_enabled": "circuit-breaker-enabled",
+        "circuit_breaker_failure_threshold": 
"circuit-breaker-failure-threshold",
+        "circuit_breaker_timeout": "circuit-breaker-timeout",
+        "circuit_breaker_half_open_requests": 
"circuit-breaker-half-open-requests",
+    }
+
     def __init__(
         self, endpoint: str, *, model_name: Optional[str] = None,
         model_version: Optional[str] = None,
@@ -380,7 +425,7 @@ class TritonProvider(ModelProvider):
         circuit_breaker_half_open_requests: Optional[int] = None,
         options: Optional[Mapping[str, str]] = None,
     ):
-        _validate_name(endpoint, "endpoint")
+        self._validate_required_options({"endpoint": endpoint})
         typed_options: Dict[str, object] = {
             "endpoint": endpoint, "model-name": model_name, "model-version": 
model_version,
             "timeout": timeout, "flatten-batch-dim": flatten_batch_dim, 
"priority": priority,
@@ -413,7 +458,15 @@ class TritonProvider(ModelProvider):
         if circuit_breaker_failure_threshold is not None:
             _require_number(circuit_breaker_failure_threshold, 
"circuit_breaker_failure_threshold",
                             0, maximum=1, include_minimum=False)
-        self._options = _merge_options(options, 
_stringify_options(typed_options))
+        self._parameters = {name: typed_options[key] for name, key in 
self._OPTION_MAP.items()}
+        self._parameters["custom_headers"] = (
+            dict(custom_headers) if custom_headers is not None else None)
+        self._typed_options = _stringify_options(typed_options)
+        self._options = _merge_options(options, self._typed_options)
+
+    @staticmethod
+    def _validate_required_options(options: Mapping[str, object]) -> None:
+        _validate_name(options.get("endpoint"), "endpoint")
 
     def provider_identifier(self) -> str:
         """Return ``triton``.
@@ -437,6 +490,9 @@ class TritonProvider(ModelProvider):
         return dict(self._options)
 
 
+TYPED_PROVIDERS = (OpenAIProvider, TritonProvider)
+
+
 @PublicEvolving()
 class GenericProvider(ModelProvider):
     """
@@ -610,3 +666,189 @@ def _resolve_provider(name: Optional[str] = None) -> 
ModelProvider:
         "Multiple model providers are registered; call 
set_default_model_provider() "
         "or select a provider explicitly"
     )
+
+
+def _override_options(
+    options: Mapping[str, str], overrides: Mapping[str, object],
+) -> Dict[str, str]:
+    result = dict(options)
+    for key, value in overrides.items():
+        if not isinstance(key, str):
+            raise TypeError("Option keys must be strings")
+        if value is None:
+            result.pop(key, None)
+        elif isinstance(value, str):
+            result[key] = value
+        else:
+            raise TypeError(f"Option {key!r} must have a string value or None")
+    return result
+
+
+def _provider_options(
+    provider: ModelProvider, overrides: Mapping[str, object],
+) -> Dict[str, str]:
+    configured = provider.to_options()
+    if not isinstance(configured, MappingABC):
+        raise TypeError("ModelProvider.to_options() must return a mapping of 
strings to strings")
+    options = _merge_options(configured, {})
+    if type(provider) not in TYPED_PROVIDERS:
+        return _override_options(options, overrides)
+    if not overrides:
+        return options
+
+    typed_provider = cast(Union[OpenAIProvider, TritonProvider], provider)
+    parameters = dict(overrides)
+    raw_supplied = "options" in parameters
+    raw = parameters.pop("options", None)
+    if raw is not None and not isinstance(raw, MappingABC):
+        raise TypeError("options must be a mapping of strings to strings or 
None")
+
+    for name in parameters:
+        if name not in typed_provider._OPTION_MAP:
+            raise TypeError(f"Unexpected provider parameter {name!r}")
+    constructor: Callable[..., ModelProvider] = type(typed_provider)
+    validated = constructor(**{**typed_provider._parameters, 
**parameters}).to_options()
+    typed = {typed_provider._OPTION_MAP[name]: 
validated.get(typed_provider._OPTION_MAP[name])
+             for name in parameters}
+    if raw_supplied and raw is None:
+        options = dict(typed_provider._typed_options)
+    elif raw is not None:
+        # Validate raw values even if an equal typed override supplies the 
same key.
+        _override_options({}, raw)
+        for key in typed.keys() & raw.keys():
+            if typed[key] != raw[key]:
+                raise ValueError(f"conflicting values for option {key!r}")
+
+    options = _override_options(options, typed)
+    if raw is not None:
+        options = _override_options(options, raw)
+    typed_provider._validate_required_options(options)
+    return options
+
+
+def _prediction_output_columns(
+    output_type: Optional[Union[str, DataType]],
+) -> List[Tuple[str, TableDataType]]:
+    if output_type is None:
+        return [("output", DataTypes.STRING())]
+    if isinstance(output_type, str):
+        return [("output", 
DataType._from_sql(output_type)._to_table_data_type())]
+    if not isinstance(output_type, DataType):
+        raise TypeError("output_type must be a SQL type string or a 
pyflink.dataframe.DataType")
+    table_type = output_type._to_table_data_type()
+    if not isinstance(table_type, RowType):
+        return [("output", table_type)]
+    if not table_type.fields:
+        raise ValueError("Struct output_type must contain at least one field")
+    return [(field.name, field.data_type) for field in table_type.fields]
+
+
+@PublicEvolving()
+class LLMAccessor:
+    """Model inference operations accessed through :attr:`DataFrame.llm`.
+
+    .. versionadded:: 2.4.0
+    """
+
+    def __init__(self, dataframe: DataFrame):
+        self._df = dataframe
+
+    @PublicEvolving()
+    def predict(
+        self, *input_cols: str, provider: Optional[str] = None, model: 
Optional[str] = None,
+        output_type: Optional[Union[str, DataType]] = None,
+        config: Optional[Mapping[str, str]] = None, **kwargs: object,
+    ) -> DataFrame:
+        """
+        Predict each row and append the model's output columns.
+
+        The selected provider validates input and output types and the number 
of columns.
+
+        :param input_cols: One or more existing column names, in model input 
order.
+        :param provider: Registered provider name. If omitted, use the 
selected default
+                         or the sole registered provider.
+        :param model: Override the provider's model name for this call.
+        :param output_type: SQL type string or :class:`DataType` declaring the 
provider's
+                            native output schema. Defaults to ``STRING``. A 
scalar or container
+                            type appends one column named ``output``.
+                            ``DataType.struct(...)`` appends its top-level 
fields as
+                            named columns, preserving nested types. Output 
names must
+                            be unique and must not occur in the input 
DataFrame.
+        :param config: Runtime configuration as string keys and values.
+        :param kwargs: Provider constructor overrides for this call. Built-ins 
accept
+                       their typed Python parameter names and merge 
``options`` per key.
+                       Custom providers accept raw option names and string 
values.
+                       ``None`` removes an inherited option; ``options=None`` 
clears
+                       inherited raw options. The registered provider stays 
unchanged.
+        :return: A new DataFrame containing the input and prediction columns.
+
+        Examples::
+
+            >>> import pyflink.dataframe as pf
+            >>> pf.set_model_provider(pf.OpenAIProvider(
+            ...     "https://api.openai.com/v1/chat/completions";, 
api_key="key", retry_num=3))
+            >>> df = pf.from_records([{"question": "What is Apache Flink?"}])
+            >>> answer = df.llm.predict("question", model="gpt-6-luna")
+
+            >>> # Override the provider's retry_num parameter for this call.
+            >>> answer = df.llm.predict(
+            ...     "question", model="gpt-6-luna", retry_num=0)
+
+            >>> # Name the prediction column "answer" instead of the default 
"output".
+            >>> answer = df.llm.predict(
+            ...     "question", model="gpt-6-luna",
+            ...     output_type=pf.DataType.struct({"answer": 
pf.DataType.string()}))
+
+        .. versionadded:: 2.4.0
+        """
+        if not input_cols:
+            raise ValueError("predict() requires at least one input column")
+        for name in input_cols:
+            _validate_name(name, "input column")
+        if len(set(input_cols)) != len(input_cols):
+            raise ValueError("predict() input columns must be unique")
+        if model is not None:
+            _validate_name(model, "model")
+        if config is not None and not isinstance(config, MappingABC):
+            raise TypeError("config must be a mapping of strings to strings")
+        runtime_options = _merge_options(config, {})
+        selected = _resolve_provider(provider)
+        identifier = _validate_name(selected.provider_identifier(), "provider 
identifier")
+        options = _provider_options(selected, kwargs)
+        if model is not None:
+            key = _validate_name(selected.model_option_key(), "model option 
key")
+            options[key] = model
+        output_columns = _prediction_output_columns(output_type)
+        output_names = [name for name, _ in output_columns]
+        for name in output_names:
+            _validate_name(name, "output field name")
+        if len(set(output_names)) != len(output_names):
+            raise ValueError("Struct output_type contains duplicate field 
names")
+
+        resolved = self._df._table.get_resolved_schema()
+        names = list(resolved.get_column_names())
+        missing = [name for name in input_cols if name not in names]
+        if missing:
+            raise ValueError(f"Input columns not found in DataFrame: 
{missing}")
+        collisions = sorted(set(names).intersection(output_names))
+        if collisions:
+            raise ValueError(
+                f"Prediction output columns conflict with existing columns: 
{collisions}")
+
+        input_schema = Schema.new_builder()
+        column_indexes = {name: index for index, name in enumerate(names)}
+        j_column_types = resolved._j_resolved_schema.getColumnDataTypes()
+        for name in input_cols:
+            input_schema.column(
+                name, 
_from_java_data_type(j_column_types.get(column_indexes[name])))
+        output_schema = Schema.new_builder()
+        for name, dtype in output_columns:
+            output_schema.column(name, dtype)
+        descriptor = (ModelDescriptor.for_provider(identifier)
+                      .input_schema(input_schema.build())
+                      .output_schema(output_schema.build()))
+        for key, value in options.items():
+            if key != "provider":
+                descriptor.option(key, value)
+        flink_model = self._df._table._t_env.from_model(descriptor.build())
+        return DataFrame(flink_model.predict(self._df._table, 
list(input_cols), runtime_options))
diff --git a/flink-python/pyflink/dataframe/tests/test_llm.py 
b/flink-python/pyflink/dataframe/tests/test_llm.py
new file mode 100644
index 00000000000..6bbe3973a01
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_llm.py
@@ -0,0 +1,327 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+import json
+import unittest
+from types import MappingProxyType
+from unittest.mock import Mock, call, patch, sentinel
+
+import pyflink.dataframe as pf
+from pyflink.dataframe import llm
+from pyflink.table import DataTypes
+from pyflink.testing.test_case_utils import PyFlinkDataFrameUTTestCase
+
+
+class ProviderOverrideTests(unittest.TestCase):
+    def test_builtin_overrides_merge_without_mutating_provider(self):
+        provider = pf.OpenAIProvider(
+            "https://example.test/chat/completions";, "key", temperature=0.7,
+            options={"top-p": "0.9", "seed": "42", "future-option": "old"},
+        )
+        configured = provider.to_options()
+        overrides = {
+            "temperature": 0.2,
+            "options": MappingProxyType({"top-p": "0.3", "seed": None}),
+        }
+
+        self.assertEqual(llm._provider_options(provider, overrides), {
+            "endpoint": "https://example.test/chat/completions";, "api-key": 
"key",
+            "temperature": "0.2", "top-p": "0.3", "future-option": "old",
+        })
+        self.assertEqual(provider.to_options(), configured)
+        self.assertEqual(overrides["options"], {"top-p": "0.3", "seed": None})
+        self.assertNotIn("top-p", llm._provider_options(provider, {"top_p": 
None}))
+
+    def test_none_removes_inherited_parameters_or_raw_options(self):
+        provider = pf.TritonProvider(
+            "http://localhost:8000";, timeout="10 s", flatten_batch_dim=False,
+            options={"priority": "2", "future-option": "value"},
+        )
+        self.assertEqual(llm._provider_options(provider, {"timeout": None}), {
+            "endpoint": "http://localhost:8000";, "flatten-batch-dim": "false",
+            "priority": "2", "future-option": "value",
+        })
+        self.assertEqual(llm._provider_options(provider, {"options": None}), {
+            "endpoint": "http://localhost:8000";, "timeout": "10 s",
+            "flatten-batch-dim": "false",
+        })
+        self.assertEqual(llm._provider_options(provider, {"options": {}}), 
provider.to_options())
+
+    def test_builtin_overrides_validate_typed_values_and_raw_conflicts(self):
+        provider = pf.OpenAIProvider("https://example.test/chat/completions";, 
"key")
+        for overrides, error in [
+            ({"unknown": "value"}, TypeError),
+            ({"temperature": True}, TypeError),
+            ({"top_p": 1.1}, ValueError),
+            ({"endpoint": None}, TypeError),
+            ({"api_key": None}, TypeError),
+            ({"options": []}, TypeError),
+            ({"options": {1: "value"}}, TypeError),
+            ({"options": {"seed": 42}}, TypeError),
+            ({"options": {"api-key": None}}, TypeError),
+            ({"options": {"api-key": " "}}, ValueError),
+            ({"options": {"endpoint": None}}, TypeError),
+            ({"temperature": 0.2, "options": {"temperature": "0.3"}}, 
ValueError),
+        ]:
+            with self.subTest(overrides=overrides):
+                with self.assertRaises(error):
+                    llm._provider_options(provider, overrides)
+        self.assertEqual(llm._provider_options(provider, {
+            "temperature": 0.2, "options": {"temperature": "0.2"},
+        })["temperature"], "0.2")
+
+    def test_triton_overrides_preserve_typed_parameters_and_copy_headers(self):
+        headers = {"X-Request": "original"}
+        provider = pf.TritonProvider(
+            "http://localhost:8000";, custom_headers=headers, 
flatten_batch_dim=False,
+            max_retries=2, compression="gzip")
+        headers["X-Request"] = "changed"
+
+        options = llm._provider_options(provider, {"max_retries": 0})
+
+        self.assertEqual(options["max-retries"], "0")
+        self.assertEqual(options["flatten-batch-dim"], "false")
+        self.assertEqual(json.loads(options["custom-headers"]), {"X-Request": 
"original"})
+        self.assertEqual(provider.to_options()["max-retries"], "2")
+        overridden = llm._provider_options(provider, {"custom_headers": 
{"X-Request": "new"}})
+        self.assertEqual(json.loads(overridden["custom-headers"]), 
{"X-Request": "new"})
+        self.assertNotIn("custom-headers", llm._provider_options(
+            provider, {"custom_headers": None}))
+        for value in (None, " "):
+            with self.subTest(endpoint=value):
+                with self.assertRaises((TypeError, ValueError)):
+                    llm._provider_options(provider, {"options": {"endpoint": 
value}})
+
+    def test_custom_provider_keeps_no_argument_serialization_contract(self):
+        class CustomProvider(pf.ModelProvider):
+            def provider_identifier(self):
+                return "custom"
+
+            def to_options(self):
+                return {"endpoint": "original", "seed": "42"}
+
+        for provider in (CustomProvider(), pf.GenericProvider(
+                "custom", endpoint="original", seed="42")):
+            with self.subTest(provider=type(provider).__name__):
+                self.assertEqual(llm._provider_options(provider, {
+                    "endpoint": "override", "seed": None, "new-option": 
"value",
+                }), {"endpoint": "override", "new-option": "value"})
+                self.assertEqual(provider.to_options(), {"endpoint": 
"original", "seed": "42"})
+                with self.assertRaises(TypeError):
+                    llm._provider_options(provider, {"seed": 1})
+
+
+class DataFramePredictTests(unittest.TestCase):
+    def setUp(self):
+        previous = dict(llm._provider_registry)
+        self.addCleanup(llm._provider_registry.update, previous)
+        self.addCleanup(llm._provider_registry.clear)
+        self.addCleanup(setattr, llm, "_default_provider", 
llm._default_provider)
+        llm._provider_registry.clear()
+        llm._default_provider = None
+        self.provider = pf.OpenAIProvider(
+            "https://example.test/chat/completions";, "key", model="configured",
+            response_format="text",
+        )
+        pf.set_model_provider("chat", self.provider)
+        self.table = Mock()
+        resolved = self.table.get_resolved_schema.return_value
+        resolved.get_column_names.return_value = ["id", "prompt"]
+        resolved.get_column_data_types.return_value = [DataTypes.BIGINT(), 
DataTypes.STRING()]
+        j_column_types = 
resolved._j_resolved_schema.getColumnDataTypes.return_value
+        j_column_types.get.side_effect = 
resolved.get_column_data_types.return_value.__getitem__
+        conversion_patch = patch.object(
+            llm, "_from_java_data_type", side_effect=lambda data_type: 
data_type)
+        self.addCleanup(conversion_patch.stop)
+        conversion_patch.start()
+        self.dataframe = pf.DataFrame(self.table)
+        self.model = self.table._t_env.from_model.return_value
+        self.model.predict.return_value = sentinel.prediction
+        self.builders = []
+
+        def schema_builder():
+            builder = Mock()
+            self.builders.append(builder)
+            return builder
+
+        builder_patch = patch.object(llm.Schema, "new_builder", 
side_effect=schema_builder)
+        self.addCleanup(builder_patch.stop)
+        builder_patch.start()
+        self.descriptor = Mock()
+        self.descriptor.input_schema.return_value = self.descriptor
+        self.descriptor.output_schema.return_value = self.descriptor
+        descriptor_patch = patch.object(
+            llm.ModelDescriptor, "for_provider", return_value=self.descriptor)
+        self.addCleanup(descriptor_patch.stop)
+        self.for_provider = descriptor_patch.start()
+
+    def 
test_predict_forwards_input_order_runtime_config_and_model_precedence(self):
+        self.assertIsInstance(self.dataframe.llm, llm.LLMAccessor)
+        resolved = self.table.get_resolved_schema.return_value
+        resolved.get_column_names.return_value += [f"metadata_{i}" for i in 
range(1000)]
+        resolved.get_column_data_types.return_value += [DataTypes.STRING()] * 
1000
+        provider = pf.TritonProvider(
+            "http://localhost:8000";, model_name="configured", 
options={"provider": "other"})
+        pf.set_model_provider("triton", provider)
+        pf.set_default_model_provider("chat")
+        config = MappingProxyType({"timeout": "30 s", "custom-runtime-option": 
"value"})
+
+        result = self.dataframe.llm.predict(
+            "prompt", "id", provider="triton", model="selected", config=config,
+            model_name="overridden", timeout="10 s",
+        )
+
+        self.assertIs(result._table, sentinel.prediction)
+        self.for_provider.assert_called_once_with("triton")
+        self.assertEqual(self.builders[0].column.call_args_list, [
+            call("prompt", DataTypes.STRING()), call("id", DataTypes.BIGINT()),
+        ])
+        self.assertEqual(self.builders[1].column.call_args_list,
+                         [call("output", DataTypes.STRING())])
+        
self.table._t_env.from_model.assert_called_once_with(self.descriptor.build.return_value)
+        self.model.predict.assert_called_once_with(self.table, ["prompt", 
"id"], dict(config))
+        self.assertEqual(dict(args.args for args in 
self.descriptor.option.call_args_list), {
+            "endpoint": "http://localhost:8000";, "model-name": "selected", 
"timeout": "10 s",
+        })
+        self.assertEqual(provider.to_options()["model-name"], "configured")
+        self.assertIs(llm._resolve_provider(), self.provider)
+
+    def 
test_predict_rejects_invalid_inputs_models_and_config_before_planning(self):
+        for columns, kwargs, error in [
+            ((), {}, ValueError),
+            ((1,), {}, TypeError),
+            ((" ",), {}, ValueError),
+            (("prompt", "prompt"), {}, ValueError),
+            (("missing",), {}, ValueError),
+            (("prompt",), {"model": ""}, ValueError),
+            (("prompt",), {"provider": "missing"}, ValueError),
+            (("prompt",), {"config": []}, TypeError),
+            (("prompt",), {"config": {"async": True}}, TypeError),
+            (("prompt",), {"config": {1: "value"}}, TypeError),
+            (("prompt",), {"output_type": DataTypes.STRING()}, TypeError),
+        ]:
+            with self.subTest(columns=columns, kwargs=kwargs):
+                with self.assertRaises(error):
+                    self.dataframe.llm.predict(*columns, **kwargs)
+        self.assertEqual(self.builders, [])
+        self.table._t_env.from_model.assert_not_called()
+
+    def test_predict_rejects_empty_duplicate_and_colliding_output_fields(self):
+        for fields in ({}, [("x", pf.DataType.string()), ("x", 
pf.DataType.int32())],
+                       {" ": pf.DataType.string()}, {"prompt": 
pf.DataType.string()}):
+            with self.subTest(fields=fields):
+                with self.assertRaises(ValueError):
+                    self.dataframe.llm.predict("prompt", 
output_type=pf.DataType.struct(fields))
+        
self.table.get_resolved_schema.return_value.get_column_names.return_value = [
+            "prompt", "output",
+        ]
+        with self.assertRaisesRegex(ValueError, "conflict with existing 
columns"):
+            self.dataframe.llm.predict("prompt")
+        self.assertEqual(self.builders, [])
+
+    def test_default_output_is_string_for_all_providers(self):
+        pf.set_model_provider("other-task", pf.OpenAIProvider(
+            "https://example.test/embeddings";, "key"))
+        pf.set_model_provider("triton", 
pf.TritonProvider("http://localhost:8000";))
+        pf.set_model_provider("generic", pf.GenericProvider("custom"))
+        for provider in ("chat", "other-task", "triton", "generic"):
+            with self.subTest(provider=provider):
+                self.dataframe.llm.predict("prompt", provider=provider)
+                self.assertEqual(self.builders[-1].column.call_args_list,
+                                 [call("output", DataTypes.STRING())])
+
+    def 
test_prediction_declares_native_output_types_and_preserves_nested_fields(self):
+        pf.set_model_provider("custom", pf.GenericProvider("custom"))
+        pf.set_model_provider("embeddings", pf.OpenAIProvider(
+            "https://example.test/embeddings";, "key", model="embedding"))
+        nested = pf.DataType.struct({"label": pf.DataType.string()})
+        for provider, output_type, fields in [
+            ("custom", None, [("output", DataTypes.STRING())]),
+            ("custom", pf.DataType.int32(), [("output", DataTypes.INT())]),
+            ("custom", pf.DataType(DataTypes.VARCHAR(5)), [("output", 
DataTypes.VARCHAR(5))]),
+            ("custom", pf.DataType.string().not_null(),
+             [("output", DataTypes.STRING().not_null())]),
+            ("custom", pf.DataType.struct({"answer": nested, "score": 
pf.DataType.int32()}),
+             [("answer", nested._to_table_data_type()), ("score", 
DataTypes.INT())]),
+            ("embeddings", pf.DataType.list(pf.DataType.float32()),
+             [("output", DataTypes.ARRAY(DataTypes.FLOAT()))]),
+        ]:
+            with self.subTest(output_type=output_type):
+                self.descriptor.reset_mock()
+                self.dataframe.llm.predict("prompt", provider=provider, 
output_type=output_type)
+                self.assertEqual(self.builders[-1].column.call_args_list,
+                                 [call(name, dtype) for name, dtype in fields])
+                options = dict(args.args for args in 
self.descriptor.option.call_args_list)
+                self.assertNotIn("response-format", options)
+
+    def 
test_prediction_preserves_inherited_and_explicit_response_formats(self):
+        for overrides, response_format in [
+            ({}, "text"), ({"response_format": "json_object"}, "json_object"),
+        ]:
+            with self.subTest(overrides=overrides):
+                self.descriptor.reset_mock()
+                self.dataframe.llm.predict(
+                    "prompt", output_type=pf.DataType.int32(), **overrides)
+                options = dict(args.args for args in 
self.descriptor.option.call_args_list)
+                self.assertEqual(options["response-format"], response_format)
+        self.assertEqual(self.provider.to_options()["response-format"], "text")
+
+
+class DataFramePredictSchemaTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        previous = dict(llm._provider_registry)
+        self.addCleanup(llm._provider_registry.update, previous)
+        self.addCleanup(llm._provider_registry.clear)
+        self.addCleanup(setattr, llm, "_default_provider", 
llm._default_provider)
+        llm._provider_registry.clear()
+        llm._default_provider = None
+        pf.set_model_provider(pf.GenericProvider("python-values"))
+        self.dataframe = pf.from_records([(1, "question")], schema=["id", 
"prompt"])
+
+    def test_prediction_preserves_input_columns_and_is_not_registered(self):
+        registered_models = self.t_env.list_models()
+        prediction = self.dataframe.llm.predict("id", "prompt")
+
+        self.assert_dataframe_schema(prediction, ["id", "prompt", "output"], [
+            DataTypes.BIGINT(), DataTypes.STRING(), DataTypes.STRING(),
+        ])
+        self.assertEqual(self.t_env.list_models(), registered_models)
+
+    def test_sql_row_is_one_column_and_struct_expands_only_top_level(self):
+        nested = DataTypes.ROW([DataTypes.FIELD("label", DataTypes.STRING())])
+        for output_type, names, types in [
+            ("ROW<label STRING>", ["output"], [nested]),
+            (pf.DataType.struct({"details": pf.DataType(nested), "score": 
pf.DataType.int32()}),
+             ["details", "score"], [nested, DataTypes.INT()]),
+        ]:
+            with self.subTest(output_type=output_type):
+                prediction = self.dataframe.llm.predict("id", 
output_type=output_type)
+                self.assert_dataframe_schema(prediction, ["id", "prompt"] + 
names, [
+                    DataTypes.BIGINT(), DataTypes.STRING(),
+                ] + types)
+
+
+if __name__ == '__main__':
+    try:
+        import xmlrunner
+
+        testRunner = xmlrunner.XMLTestRunner(output='target/test-reports')
+    except ImportError:
+        testRunner = None
+    unittest.main(testRunner=testRunner, verbosity=2)
diff --git a/flink-python/pyflink/dataframe/tests/test_llm_integration.py 
b/flink-python/pyflink/dataframe/tests/test_llm_integration.py
new file mode 100644
index 00000000000..56d4c829ec1
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_llm_integration.py
@@ -0,0 +1,88 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+################################################################################
+
+import unittest
+
+import pyflink.dataframe as pf
+from pyflink.common import Row
+from pyflink.dataframe import llm
+from pyflink.testing.test_case_utils import PyFlinkStreamDataFrameTestCase
+
+
+class DataFrameLlmITCase(PyFlinkStreamDataFrameTestCase):
+
+    def setUp(self):
+        previous = dict(llm._provider_registry)
+        self.addCleanup(llm._provider_registry.update, previous)
+        self.addCleanup(llm._provider_registry.clear)
+        self.addCleanup(setattr, llm, "_default_provider", 
llm._default_provider)
+        llm._provider_registry.clear()
+        llm._default_provider = None
+        self.provider = pf.GenericProvider("python-values", {"async": "false", 
"latency": "0"})
+        pf.set_model_provider("values", self.provider)
+        self.output_type = pf.DataType.struct({
+            "x": pf.DataType.string(),
+            "y": pf.DataType.int32(),
+            "z": pf.DataType.string(),
+        })
+
+    def test_sync_prediction_appends_typed_columns(self):
+        registered_models = self.t_env.list_models()
+        dataframe = pf.from_records([(1, "first"), (3, "third")], 
schema=["id", "content"])
+
+        prediction = dataframe.llm.predict(
+            "id", output_type=self.output_type, config={"async": "false"},
+        )
+
+        self.assertEqual(prediction.columns, ["id", "content", "x", "y", "z"])
+        self.assertCountEqual(prediction.collect(), [
+            Row(1, "first", "x1", 1, "z1"),
+            Row(3, "third", "x3", 3, "z3"),
+        ])
+        self.assertEqual(self.t_env.list_models(), registered_models)
+
+    def test_async_prediction_with_provider_override_and_runtime_config(self):
+        dataframe = pf.from_records([(2, "second"), (4, "fourth")], 
schema=["id", "content"])
+
+        prediction = dataframe.llm.predict(
+            "id", provider="values", output_type=self.output_type,
+            config={
+                "async": "true",
+                "output-mode": "allow_unordered",
+                "max-concurrent-operations": "2",
+                "timeout": "30 s",
+            },
+            **{"async": "true"},
+        )
+        selected = prediction.select("content", "x", (pf.col("y") + 
1).alias("next_id"), "z")
+
+        self.assertCountEqual(selected.collect(), [
+            Row("second", "x2", 3, "z2"),
+            Row("fourth", "x4", 5, "z4"),
+        ])
+        self.assertEqual(self.provider.to_options(), {"async": "false", 
"latency": "0"})
+
+
+if __name__ == '__main__':
+    try:
+        import xmlrunner
+
+        testRunner = xmlrunner.XMLTestRunner(output='target/test-reports')
+    except ImportError:
+        testRunner = None
+    unittest.main(testRunner=testRunner, verbosity=2)
diff --git a/flink-python/pyflink/table/model.py 
b/flink-python/pyflink/table/model.py
index 485bf3a64b8..db57f19f640 100644
--- a/flink-python/pyflink/table/model.py
+++ b/flink-python/pyflink/table/model.py
@@ -28,8 +28,10 @@ __all__ = ['Model']
 @PublicEvolving()
 class Model(object):
     """
-    A model object represents a machine learning model registered in the 
catalog.
-    It provides a way to perform predictions on input tables using the predict 
method.
+    A model object represents a machine learning model retrieved from the 
catalog or
+    created from a model descriptor. Use :meth:`~TableEnvironment.from_model` 
to retrieve
+    a named model or create an anonymous model, then :meth:`predict` to perform
+    predictions on input tables.
 
     .. versionadded:: 2.4.0
     """
diff --git a/flink-python/pyflink/table/table_environment.py 
b/flink-python/pyflink/table/table_environment.py
index 6d84c39ec0a..ebab2034a38 100644
--- a/flink-python/pyflink/table/table_environment.py
+++ b/flink-python/pyflink/table/table_environment.py
@@ -1250,9 +1250,12 @@ class TableEnvironment(object):
         self._j_tenv.createModel(model_path, 
model_descriptor._j_model_descriptor, ignore_if_exists)
 
     @PublicEvolving()
-    def from_model(self, model_path: str) -> 'Model':
+    def from_model(self, model_path: Union[str, ModelDescriptor]) -> 'Model':
         """
-        Gets a model registered in the catalog.
+        Gets a registered model or creates an anonymous model from a 
descriptor.
+
+        A descriptor defines the provider options and input/output schemas 
directly,
+        without registering a model in the catalog.
 
         Example:
         ::
@@ -1262,12 +1265,28 @@ class TableEnvironment(object):
             >>> input_table = t_env.from_path("src")
             >>> result_table = model.predict(input_table, ["user_comment"])
 
-        :param model_path: The path of the model to retrieve.
+        Create an anonymous model:
+        ::
+
+            >>> from pyflink.table import DataTypes
+            >>> descriptor = (ModelDescriptor.for_provider("my-provider")
+            ...     .input_schema(Schema.new_builder()
+            ...                   .column("text", DataTypes.STRING()).build())
+            ...     .output_schema(Schema.new_builder()
+            ...                    .column("label", 
DataTypes.STRING()).build())
+            ...     .build())
+            >>> model = t_env.from_model(descriptor)
+
+        :param model_path: The path of the registered model, or a descriptor 
defining
+                           an anonymous model.
         :return: The requested model instance.
 
         .. versionadded:: 2.4.0
         """
-        j_model = self._j_tenv.fromModel(model_path)
+        if isinstance(model_path, ModelDescriptor):
+            j_model = self._j_tenv.fromModel(model_path._j_model_descriptor)
+        else:
+            j_model = self._j_tenv.fromModel(model_path)
         return Model(j_model)
 
     def create_temporary_model(self,
diff --git a/flink-python/pyflink/table/tests/test_model.py 
b/flink-python/pyflink/table/tests/test_model.py
index 5e1a64d6458..7ceb3a795cc 100644
--- a/flink-python/pyflink/table/tests/test_model.py
+++ b/flink-python/pyflink/table/tests/test_model.py
@@ -18,7 +18,35 @@
 from pyflink.common import Row
 from pyflink.table import ModelDescriptor, Schema, DataTypes
 from pyflink.table.catalog import ResolvedSchema
-from pyflink.testing.test_case_utils import PyFlinkStreamTableTestCase
+from pyflink.testing.test_case_utils import PyFlinkStreamTableTestCase, 
PyFlinkUTTestCase
+
+
+class AnonymousModelTests(PyFlinkUTTestCase):
+
+    def test_anonymous_model_descriptor(self):
+        descriptor = ModelDescriptor.for_provider("python-values") \
+            .input_schema(Schema.new_builder()
+                          .column("id", DataTypes.BIGINT())
+                          .build()) \
+            .output_schema(Schema.new_builder()
+                           .column("x", DataTypes.STRING())
+                           .column("y", DataTypes.INT())
+                           .column("z", DataTypes.STRING())
+                           .build()) \
+            .build()
+        registered_models = self.t_env.list_models()
+
+        model = self.t_env.from_model(descriptor)
+
+        
self.assertEqual(list(model.get_resolved_input_schema().get_column_names()), 
["id"])
+        
self.assertEqual(model.get_resolved_input_schema().get_column_data_types(),
+                         [DataTypes.BIGINT()])
+        
self.assertEqual(list(model.get_resolved_output_schema().get_column_names()),
+                         ["x", "y", "z"])
+        
self.assertEqual(model.get_resolved_output_schema().get_column_data_types(),
+                         [DataTypes.STRING(), DataTypes.INT(), 
DataTypes.STRING()])
+
+        self.assertEqual(self.t_env.list_models(), registered_models)
 
 
 class ModelTest(PyFlinkStreamTableTestCase):
@@ -125,7 +153,7 @@ class ModelTest(PyFlinkStreamTableTestCase):
             .build()
 
         self.t_env.create_temporary_model("opt_model", model_descriptor, True)
-        model = self.t_env.from_model("opt_model")
+        model = self.t_env.from_model(model_path="opt_model")
 
         data = [
             (1, 12, "Julian"),

Reply via email to