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 e0785f05542 [FLINK-40439][python] Add DataFrame model provider
management
e0785f05542 is described below
commit e0785f055420c6290c8a5c8ff7ad8b058ba83c8b
Author: auroflow <[email protected]>
AuthorDate: Fri Oct 9 16:02:59 2026 +0800
[FLINK-40439][python] Add DataFrame model provider management
This closes #29454.
---
.../model/triton/TritonModelProviderFactory.java | 6 +
.../triton/TritonModelProviderFactoryTest.java | 41 +-
.../docs/reference/pyflink.dataframe/index.rst | 1 +
.../docs/reference/pyflink.dataframe/llm.rst | 62 +++
flink-python/pyflink/dataframe/__init__.py | 16 +
flink-python/pyflink/dataframe/llm.py | 612 +++++++++++++++++++++
.../pyflink/dataframe/tests/test_model_provider.py | 481 ++++++++++++++++
flink-python/pyflink/dataframe/validation.py | 33 +-
8 files changed, 1243 insertions(+), 9 deletions(-)
diff --git
a/flink-models/flink-model-triton/src/main/java/org/apache/flink/model/triton/TritonModelProviderFactory.java
b/flink-models/flink-model-triton/src/main/java/org/apache/flink/model/triton/TritonModelProviderFactory.java
index 4fd6139a245..951c4c76153 100644
---
a/flink-models/flink-model-triton/src/main/java/org/apache/flink/model/triton/TritonModelProviderFactory.java
+++
b/flink-models/flink-model-triton/src/main/java/org/apache/flink/model/triton/TritonModelProviderFactory.java
@@ -75,6 +75,12 @@ public class TritonModelProviderFactory implements
ModelProviderFactory {
set.add(TritonOptions.RETRY_INITIAL_BACKOFF);
set.add(TritonOptions.RETRY_MAX_BACKOFF);
set.add(TritonOptions.DEFAULT_VALUE);
+ set.add(TritonOptions.HEALTH_CHECK_ENABLED);
+ set.add(TritonOptions.HEALTH_CHECK_INTERVAL);
+ set.add(TritonOptions.CIRCUIT_BREAKER_ENABLED);
+ set.add(TritonOptions.CIRCUIT_BREAKER_FAILURE_THRESHOLD);
+ set.add(TritonOptions.CIRCUIT_BREAKER_TIMEOUT);
+ set.add(TritonOptions.CIRCUIT_BREAKER_HALF_OPEN_REQUESTS);
return set;
}
diff --git
a/flink-models/flink-model-triton/src/test/java/org/apache/flink/model/triton/TritonModelProviderFactoryTest.java
b/flink-models/flink-model-triton/src/test/java/org/apache/flink/model/triton/TritonModelProviderFactoryTest.java
index 7fab9ce7446..b3bbcffc771 100644
---
a/flink-models/flink-model-triton/src/test/java/org/apache/flink/model/triton/TritonModelProviderFactoryTest.java
+++
b/flink-models/flink-model-triton/src/test/java/org/apache/flink/model/triton/TritonModelProviderFactoryTest.java
@@ -17,8 +17,16 @@
package org.apache.flink.model.triton;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.factories.utils.FactoryMocks;
+import org.apache.flink.table.ml.AsyncPredictRuntimeProvider;
+
import org.junit.jupiter.api.Test;
+import java.util.Map;
+
import static org.assertj.core.api.Assertions.assertThat;
/** Test for {@link TritonModelProviderFactory}. */
@@ -42,7 +50,7 @@ class TritonModelProviderFactoryTest {
void testOptionalOptions() {
TritonModelProviderFactory factory = new TritonModelProviderFactory();
assertThat(factory.optionalOptions())
- .hasSize(14)
+ .hasSize(20)
.containsExactlyInAnyOrder(
TritonOptions.MODEL_VERSION,
TritonOptions.TIMEOUT,
@@ -57,6 +65,35 @@ class TritonModelProviderFactoryTest {
TritonOptions.MAX_RETRIES,
TritonOptions.RETRY_INITIAL_BACKOFF,
TritonOptions.RETRY_MAX_BACKOFF,
- TritonOptions.DEFAULT_VALUE);
+ TritonOptions.DEFAULT_VALUE,
+ TritonOptions.HEALTH_CHECK_ENABLED,
+ TritonOptions.HEALTH_CHECK_INTERVAL,
+ TritonOptions.CIRCUIT_BREAKER_ENABLED,
+ TritonOptions.CIRCUIT_BREAKER_FAILURE_THRESHOLD,
+ TritonOptions.CIRCUIT_BREAKER_TIMEOUT,
+ TritonOptions.CIRCUIT_BREAKER_HALF_OPEN_REQUESTS);
+ }
+
+ @Test
+ void testCreateProviderWithHealthCheckAndCircuitBreaker() {
+ final TritonModelProviderFactory factory = new
TritonModelProviderFactory();
+ final ResolvedSchema schema =
+ ResolvedSchema.of(Column.physical("text", DataTypes.STRING()));
+ final Map<String, String> options =
+ Map.of(
+ "provider", "triton",
+ "endpoint", "http://localhost:8000",
+ "model-name", "test-model",
+ "health-check-enabled", "true",
+ "health-check-interval", "10 s",
+ "circuit-breaker-enabled", "true",
+ "circuit-breaker-failure-threshold", "0.25",
+ "circuit-breaker-timeout", "5 s",
+ "circuit-breaker-half-open-requests", "2");
+
+ assertThat(
+ factory.createModelProvider(
+ FactoryMocks.createModelContext(schema,
schema, options)))
+ .isInstanceOf(AsyncPredictRuntimeProvider.class);
}
}
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst
b/flink-python/docs/reference/pyflink.dataframe/index.rst
index d92c806a027..24059215551 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -34,3 +34,4 @@ This page gives an overview of all public PyFlink DataFrame
APIs.
datatype
environment
config
+ llm
diff --git a/flink-python/docs/reference/pyflink.dataframe/llm.rst
b/flink-python/docs/reference/pyflink.dataframe/llm.rst
new file mode 100644
index 00000000000..b51f9c45eff
--- /dev/null
+++ b/flink-python/docs/reference/pyflink.dataframe/llm.rst
@@ -0,0 +1,62 @@
+..
################################################################################
+ 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.
+
################################################################################
+
+========
+AI / LLM
+========
+
+Model Providers
+---------------
+
+Model providers configure connections and options for AI functions.
+``OpenAIProvider`` and ``TritonProvider`` provide typed configurations for
Flink's
+built-in providers. See their API references below for parameters and defaults.
+Use ``options`` to supply Java provider option names and string values
directly.
+
+Use ``set_model_provider`` to register a configuration. Pass a provider alone
to
+use its identifier as the name, or supply a name to distinguish configurations.
+Use ``list_model_providers`` to inspect registered names:
+
+.. code-block:: python
+
+ import pyflink.dataframe as pf
+
+ provider = pf.TritonProvider(endpoint="http://localhost:8000")
+ pf.set_model_provider("inference", provider)
+ pf.list_model_providers() # ["inference"]
+
+A single registered provider is selected automatically. When using several
+providers, choose a default with ``set_default_model_provider("inference")``.
+Registrations are shared within the Python process.
+
+For other providers available in the application's classpath, use
``GenericProvider``
+with provider-specific option names and string values, or subclass
``ModelProvider``
+to create a reusable configuration.
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ ModelProvider
+ OpenAIProvider
+ TritonProvider
+ GenericProvider
+ set_model_provider
+ set_default_model_provider
+ list_model_providers
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 174a3e4df79..5c0b3af3941 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -73,6 +73,15 @@ from pyflink.dataframe.io import (
read_parquet,
)
from pyflink.dataframe.iteration import CloseableIterator
+from pyflink.dataframe.llm import (
+ GenericProvider,
+ ModelProvider,
+ OpenAIProvider,
+ TritonProvider,
+ list_model_providers,
+ set_default_model_provider,
+ set_model_provider,
+)
from pyflink.dataframe.over_window import following, preceding
from pyflink.dataframe.sql import sql
from pyflink.dataframe.udf import udf
@@ -83,6 +92,13 @@ __all__ = [
"DataFrame",
"GroupedDataFrame",
"DataType",
+ "ModelProvider",
+ "OpenAIProvider",
+ "TritonProvider",
+ "GenericProvider",
+ "set_model_provider",
+ "set_default_model_provider",
+ "list_model_providers",
"col",
"lit",
"udf",
diff --git a/flink-python/pyflink/dataframe/llm.py
b/flink-python/pyflink/dataframe/llm.py
new file mode 100644
index 00000000000..122707dafb4
--- /dev/null
+++ b/flink-python/pyflink/dataframe/llm.py
@@ -0,0 +1,612 @@
+################################################################################
+# 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.
+################################################################################
+
+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 pyflink.dataframe.validation import _require_int, _require_number
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = [
+ "ModelProvider", "OpenAIProvider", "TritonProvider", "GenericProvider",
+ "set_model_provider", "set_default_model_provider", "list_model_providers",
+]
+
+
+def _validate_name(name: object, argument: str) -> str:
+ if not isinstance(name, str):
+ raise TypeError(f"{argument} must be a string")
+ if not name.strip():
+ raise ValueError(f"{argument} must not be empty or whitespace")
+ return name
+
+
+def _stringify_options(options: Dict[str, object]) -> Dict[str, str]:
+ return {
+ name: str(value).lower() if isinstance(value, bool) else str(value)
+ for name, value in options.items() if value is not None
+ }
+
+
+def _merge_options(
+ options: Optional[Mapping[str, str]], parameters: Mapping[str, str],
+) -> Dict[str, str]:
+ if options is not None and not isinstance(options, MappingABC):
+ raise TypeError("options must be a mapping of strings to strings")
+ merged: Dict[str, str] = {}
+ for source in (options, parameters):
+ if source is None:
+ continue
+ for key, value in source.items():
+ if not isinstance(key, str):
+ raise TypeError("Option keys must be strings")
+ if not isinstance(value, str):
+ raise TypeError(f"Option {key!r} must have a string value")
+ if key in merged and merged[key] != value:
+ raise ValueError(f"conflicting values for option {key!r}")
+ merged[key] = value
+ return merged
+
+
+def _validate_type(name: str, value: object, expected: type) -> None:
+ if value is not None and not isinstance(value, expected):
+ raise TypeError(f"{name} must be a {expected.__name__}")
+
+
+def _enum_option(name: str, value: Optional[str], choices: Tuple[str, ...]) ->
Optional[str]:
+ _validate_type(name, value, str)
+ if value is None:
+ return None
+ for choice in choices:
+ if value.lower() == choice.lower():
+ return choice
+ raise ValueError(f"{name} must be one of {choices}")
+
+
+def _header_option(headers: Optional[Mapping[str, str]]) -> Optional[str]:
+ if headers is None:
+ return None
+ if not isinstance(headers, MappingABC):
+ raise TypeError("custom_headers must be a mapping of strings to
strings")
+ if any(not isinstance(key, str) or not isinstance(value, str)
+ for key, value in headers.items()):
+ raise TypeError("custom_headers must contain string keys and values")
+ return json.dumps(dict(headers))
+
+
+@PublicEvolving()
+class ModelProvider(ABC):
+ """
+ Base class for model provider configurations.
+
+ Subclass this to configure a provider available in your application's
classpath.
+ Implement :meth:`provider_identifier` to identify the provider and
:meth:`to_options`
+ to supply its options as string keys and values.
+
+ Example::
+
+ >>> class MyProvider(ModelProvider):
+ ... def provider_identifier(self):
+ ... return "my-provider"
+ ... def to_options(self):
+ ... return {"endpoint": "https://example.test/inference"}
+ >>> MyProvider().provider_identifier()
+ 'my-provider'
+
+ .. versionadded:: 2.4.0
+ """
+
+ @abstractmethod
+ def provider_identifier(self) -> str:
+ """Return the provider identifier, such as ``openai`` or ``triton``.
+
+ .. versionadded:: 2.4.0
+ """
+
+ @abstractmethod
+ def to_options(self) -> Dict[str, str]:
+ """Return provider options as string keys and values.
+
+ .. versionadded:: 2.4.0
+ """
+
+ def model_option_key(self) -> str:
+ """Return the option key for a model name, normally ``model``.
+
+ Override this for providers whose model option has a different name.
+
+ .. versionadded:: 2.4.0
+ """
+ return "model"
+
+
+@PublicEvolving()
+class OpenAIProvider(ModelProvider):
+ """
+ Configure an OpenAI-compatible service for chat completions or embeddings.
+
+ 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.
+
+ All optional parameters default to ``None``. Omitted options use the Flink
defaults
+ described below, or the service's defaults where Flink defines none.
+
+ :param endpoint: Required full API URL, for example
+ ``https://api.openai.com/v1/chat/completions``.
+ :param api_key: Required API key for authenticating requests.
+ :param model: Model name to use. Default: ``None``; if omitted, supply it
when
+ calling an AI function.
+ :param system_prompt: System message that guides the chat response. If
omitted,
+ Flink uses ``"You are a helpful assistant."``. Set
``""``
+ for an empty system message.
+ :param temperature: Controls randomness in chat responses; typical values
are
+ between 0.0 and 1.0. Default: ``None`` (service
default).
+ :param top_p: Probability cutoff for token selection, between 0 and 1.
Usually
+ set either this or ``temperature``. Default: ``None``
(service default).
+ :param max_tokens: Maximum number of tokens generated in a chat completion.
+ Default: ``None`` (service default).
+ :param stop: Comma-separated strings that stop generation when encountered.
+ Default: ``None`` (no stop sequences supplied).
+ :param presence_penalty: Value between -2 and 2. Positive values
discourage tokens
+ already used in the response, encouraging new
topics.
+ Default: ``None`` (service default).
+ :param n: Number of chat completion choices generated per input. Setting
this to
+ 1 limits token usage. Default: ``None`` (service default).
+ :param seed: Seed for best-effort repeatable sampling. Default: ``None``
(no seed
+ 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 max_context_size: Maximum number of input context tokens before
applying
+ ``context_overflow_action``. Default: ``None``
(no Flink
+ context limit).
+ :param context_overflow_action: ``truncated-tail`` removes excess tokens
from the
+ end, ``truncated-head`` removes them from
the start,
+ and ``skipped`` skips the input. Each has a
``-log``
+ variant that logs the action. If omitted,
Flink uses
+ ``truncated-tail``.
+ :param error_handling_strategy: ``RETRY`` retries failed requests,
``FAILOVER``
+ fails the job, and ``IGNORE`` skips the
failed input.
+ If omitted, Flink uses ``RETRY``.
+ :param retry_num: Number of retries when ``error_handling_strategy`` is
``RETRY``.
+ If omitted, Flink uses ``100``.
+ :param retry_fallback_strategy: Action after retries are exhausted:
``FAILOVER``
+ fails the job and ``IGNORE`` skips the
failed input.
+ If omitted, Flink uses ``FAILOVER``.
+ :param options: Additional Java provider options as string keys and values.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> 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)
+
+ .. versionadded:: 2.4.0
+ """
+
+ def __init__(
+ self, endpoint: str, api_key: str, *, model: Optional[str] = None,
+ system_prompt: Optional[str] = None,
+ temperature: Optional[float] = None,
+ top_p: Optional[float] = None,
+ max_tokens: Optional[int] = None,
+ stop: Optional[str] = None,
+ presence_penalty: Optional[float] = None,
+ n: Optional[int] = None,
+ seed: Optional[int] = None,
+ response_format: Optional[Literal["text", "json_object"]] = None,
+ dimension: Optional[int] = None,
+ max_context_size: Optional[int] = None,
+ context_overflow_action: Optional[Literal[
+ "truncated-tail", "truncated-tail-log", "truncated-head",
"truncated-head-log",
+ "skipped", "skipped-log",
+ ]] = None,
+ error_handling_strategy: Optional[Literal["RETRY", "FAILOVER",
"IGNORE"]] = None,
+ retry_num: Optional[int] = None,
+ retry_fallback_strategy: Optional[Literal["FAILOVER", "IGNORE"]] =
None,
+ options: Optional[Mapping[str, str]] = None,
+ ):
+ _validate_name(endpoint, "endpoint")
+ _validate_name(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,
+ "max-tokens": max_tokens, "stop": stop, "presence-penalty":
presence_penalty,
+ "n": n, "seed": seed, "dimension": dimension, "max-context-size":
max_context_size,
+ "retry-num": retry_num,
+ "response-format": _enum_option("response_format", response_format,
+ ("text", "json_object")),
+ "context-overflow-action": _enum_option(
+ "context_overflow_action", context_overflow_action,
+ ("truncated-tail", "truncated-tail-log", "truncated-head",
"truncated-head-log",
+ "skipped", "skipped-log")),
+ "error-handling-strategy": _enum_option(
+ "error_handling_strategy", error_handling_strategy,
+ ("RETRY", "FAILOVER", "IGNORE")),
+ "retry-fallback-strategy": _enum_option(
+ "retry_fallback_strategy", retry_fallback_strategy,
("FAILOVER", "IGNORE")),
+ }
+ for name in ("model", "system-prompt", "stop"):
+ _validate_type(name, typed_options[name], str)
+ for name, integer, minimum in (
+ ("max_tokens", max_tokens, 1), ("n", n, 1), ("dimension",
dimension, 1),
+ ("max_context_size", max_context_size, 1), ("retry_num",
retry_num, 0),
+ ("seed", seed, None),
+ ):
+ if integer is not None:
+ _require_int(integer, name, minimum)
+ for name, number, lower_bound, upper_bound in (
+ ("temperature", temperature, None, None), ("top_p", top_p, 0, 1),
+ ("presence_penalty", presence_penalty, -2, 2),
+ ):
+ if number is not None:
+ _require_number(number, name, lower_bound, maximum=upper_bound)
+ self._options = _merge_options(options,
_stringify_options(typed_options))
+
+ def provider_identifier(self) -> str:
+ """Return ``openai``.
+
+ .. versionadded:: 2.4.0
+ """
+ return "openai"
+
+ def to_options(self) -> Dict[str, str]:
+ """Return a fresh dictionary of provider options.
+
+ .. versionadded:: 2.4.0
+ """
+ return dict(self._options)
+
+
+@PublicEvolving()
+class TritonProvider(ModelProvider):
+ """
+ Configure inference requests to a model served by NVIDIA Triton Inference
Server.
+
+ Supply a model name here or when calling an AI function. For array inputs,
use
+ ``flatten_batch_dim`` to match the model's expected shape. Retry and
+ fallback options control how failed requests are handled; health checks
and the
+ circuit breaker can reduce requests to an unavailable server.
+
+ 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"``.
+
+ :param endpoint: Required Triton server URL, for example
``http://localhost:8000``.
+ :param model_name: Name of the model to invoke. Default: ``None``; if
omitted,
+ supply it when calling an AI function.
+ :param model_version: Model version to invoke. If omitted, Flink uses
``"latest"``.
+ :param timeout: HTTP timeout for each request, separate from Flink's
asynchronous
+ prediction timeout. If omitted, Flink uses ``"30 s"``.
+ :param flatten_batch_dim: Convert the array input shape from ``[1, N]`` to
``[N]``
+ when the model expects a vector without a batch
dimension.
+ If omitted, Flink uses ``False``.
+ :param priority: Request priority between 0 and 255. Default: ``None`` (no
priority
+ supplied).
+ :param sequence_id: Identifier shared by requests in a stateful model
sequence.
+ Default: ``None`` (no sequence identifier supplied).
+ :param sequence_start: Mark requests as starting a stateful sequence. If
omitted,
+ Flink uses ``False``.
+ :param sequence_end: Mark requests as ending a stateful sequence. If
omitted,
+ Flink uses ``False``.
+ :param compression: Compress request bodies using ``gzip``. Default:
``None``
+ (no compression).
+ :param auth_token: Authentication token sent as a Bearer token. Default:
``None``
+ (no token supplied).
+ :param custom_headers: Additional HTTP headers as a mapping, for example
+ ``{"X-Trace-Id": "abc"}``. Default: ``None`` (no
extra headers).
+ :param max_retries: Additional attempts for transient failures, such as
network
+ errors and server errors. If omitted, Flink uses ``0``
(no retries).
+ :param retry_initial_backoff: Initial delay between retries; delays
increase
+ exponentially up to ``retry_max_backoff``.
If omitted,
+ Flink uses ``"100 ms"``.
+ :param retry_max_backoff: Maximum delay between retries. If omitted, Flink
uses
+ ``"30 s"``.
+ :param default_value: Fallback value when inference fails, expressed as a
string
+ matching the output type: plain text for strings, a
numeric
+ string for numbers, a JSON array for arrays, or
``"null"`` for
+ SQL NULL. Default: ``None`` (propagate failures).
+ :param health_check_enabled: Enable periodic server health checks. If
omitted,
+ Flink uses ``False``.
+ :param health_check_interval: Time between health checks when enabled. If
omitted,
+ Flink uses ``"30 s"``.
+ :param circuit_breaker_enabled: Temporarily stop sending requests when the
server
+ has a high failure rate. If omitted, Flink
uses ``False``.
+ :param circuit_breaker_failure_threshold: Failure rate in ``(0, 1]`` that
opens the
+ circuit breaker; ``0.5`` means
50% failures.
+ If omitted, Flink uses ``0.5``.
+ :param circuit_breaker_timeout: Time to wait before probing recovery after
the
+ circuit breaker opens. If omitted, Flink
uses ``"60 s"``.
+ :param circuit_breaker_half_open_requests: Successful recovery probes
needed to
+ close the circuit breaker. If
omitted,
+ Flink uses ``3``.
+ :param options: Additional Java provider options as string keys and values.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> provider = pf.TritonProvider(
+ ... endpoint="http://localhost:8000", model_name="classifier",
+ ... flatten_batch_dim=True, max_retries=2, default_value="-1")
+ >>> pf.set_model_provider("classifier", provider)
+
+ .. versionadded:: 2.4.0
+ """
+
+ def __init__(
+ self, endpoint: str, *, model_name: Optional[str] = None,
+ model_version: Optional[str] = None,
+ timeout: Optional[str] = None,
+ flatten_batch_dim: Optional[bool] = None,
+ priority: Optional[int] = None,
+ sequence_id: Optional[str] = None,
+ sequence_start: Optional[bool] = None,
+ sequence_end: Optional[bool] = None,
+ compression: Optional[Literal["gzip"]] = None,
+ auth_token: Optional[str] = None,
+ custom_headers: Optional[Mapping[str, str]] = None,
+ max_retries: Optional[int] = None,
+ retry_initial_backoff: Optional[str] = None,
+ retry_max_backoff: Optional[str] = None,
+ default_value: Optional[str] = None,
+ health_check_enabled: Optional[bool] = None,
+ health_check_interval: Optional[str] = None,
+ circuit_breaker_enabled: Optional[bool] = None,
+ circuit_breaker_failure_threshold: Optional[float] = None,
+ circuit_breaker_timeout: Optional[str] = None,
+ circuit_breaker_half_open_requests: Optional[int] = None,
+ options: Optional[Mapping[str, str]] = None,
+ ):
+ _validate_name(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,
+ "sequence-id": sequence_id, "sequence-start": sequence_start,
+ "sequence-end": sequence_end,
+ "compression": _enum_option("compression", compression, ("gzip",)),
+ "auth-token": auth_token, "custom-headers":
_header_option(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,
+ }
+ for name in ("model-name", "model-version", "timeout", "sequence-id",
"auth-token",
+ "retry-initial-backoff", "retry-max-backoff",
"default-value",
+ "health-check-interval", "circuit-breaker-timeout"):
+ _validate_type(name, typed_options[name], str)
+ for name in ("flatten-batch-dim", "sequence-start", "sequence-end",
"health-check-enabled",
+ "circuit-breaker-enabled"):
+ _validate_type(name, typed_options[name], bool)
+ for name, integer, minimum, maximum in (
+ ("priority", priority, 0, 255), ("max_retries", max_retries, 0,
None),
+ ("circuit_breaker_half_open_requests",
circuit_breaker_half_open_requests, 1, None),
+ ):
+ if integer is not None:
+ _require_int(integer, name, minimum, maximum=maximum)
+ 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))
+
+ def provider_identifier(self) -> str:
+ """Return ``triton``.
+
+ .. versionadded:: 2.4.0
+ """
+ return "triton"
+
+ def model_option_key(self) -> str:
+ """Return ``model-name``, the option key for a model name.
+
+ .. versionadded:: 2.4.0
+ """
+ return "model-name"
+
+ def to_options(self) -> Dict[str, str]:
+ """Return a fresh dictionary of provider options.
+
+ .. versionadded:: 2.4.0
+ """
+ return dict(self._options)
+
+
+@PublicEvolving()
+class GenericProvider(ModelProvider):
+ """
+ Configure an installed model provider with its option names and string
values.
+
+ Use this for a provider without a dedicated Python configuration class.
Option
+ names and values follow that provider's documentation. Supply options as a
+ mapping, keyword arguments, or both. Conflicting values raise
``ValueError``.
+
+ :param identifier: Java model provider factory identifier.
+ :param options: Provider option names and string values, supplied as the
second
+ positional argument. Default: ``None``.
+ :param kwargs: Provider options supplied as keyword arguments.
+
+ Example::
+
+ >>> provider = GenericProvider(
+ ... "my-provider",
+ ... {"endpoint": "https://example.test/inference", "api-key":
"key"})
+ >>> provider.to_options()
+ {'endpoint': 'https://example.test/inference', 'api-key': 'key'}
+
+ .. versionadded:: 2.4.0
+ """
+
+ def __init__(
+ self, identifier: str, options: Optional[Mapping[str, str]] = None, /,
**kwargs: str,
+ ):
+ _validate_name(identifier, "identifier")
+ self._identifier = identifier
+ self._options = _merge_options(options, kwargs)
+
+ def provider_identifier(self) -> str:
+ """Return the configured provider identifier.
+
+ .. versionadded:: 2.4.0
+ """
+ return self._identifier
+
+ def to_options(self) -> Dict[str, str]:
+ """Return a fresh dictionary of provider options.
+
+ .. versionadded:: 2.4.0
+ """
+ return dict(self._options)
+
+
+_provider_registry: Dict[str, ModelProvider] = {}
+_default_provider: Optional[str] = None
+_UNSET = object()
+
+
+@overload
+def set_model_provider(provider: ModelProvider, /) -> None:
+ ...
+
+
+@overload
+def set_model_provider(*, provider: ModelProvider) -> None:
+ ...
+
+
+@overload
+def set_model_provider(name: str, provider: ModelProvider) -> None:
+ ...
+
+
+@PublicEvolving()
+def set_model_provider(
+ *args: object, name: object = _UNSET, provider: object = _UNSET,
+) -> None:
+ """
+ Register a model provider configuration.
+
+ Pass a provider alone to register it under its identifier, such as
``openai``.
+ Supply a name to keep several configurations of the same provider.
+
+ Registering an existing name updates its configuration. Registered
providers are
+ shared across DataFrame environments in the current Python process.
+
+ :param name: Name used to look up the provider. Omit it to use the
provider identifier.
+ :param provider: Provider configuration to register.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> provider = pf.OpenAIProvider(
+ ... "https://api.openai.com/v1/chat/completions", api_key="key")
+ >>> pf.set_model_provider(provider)
+ >>> pf.set_model_provider("chat", provider)
+
+ .. versionadded:: 2.4.0
+ """
+ if len(args) > 2:
+ raise TypeError("set_model_provider() takes at most 2 positional
arguments")
+ if args:
+ if name is not _UNSET:
+ raise TypeError("set_model_provider() got multiple values for
argument 'name'")
+ if len(args) == 2:
+ if provider is not _UNSET:
+ raise TypeError("set_model_provider() got multiple values for
argument 'provider'")
+ name, provider = args
+ elif provider is _UNSET:
+ provider = args[0]
+ else:
+ name = args[0]
+ if not isinstance(provider, ModelProvider):
+ raise TypeError("provider must be a ModelProvider")
+ if name is _UNSET:
+ name = provider.provider_identifier()
+ name = _validate_name(name, "name")
+ _provider_registry[name] = provider
+
+
+@PublicEvolving()
+def list_model_providers() -> List[str]:
+ """
+ Return registered provider names in registration order.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.set_model_provider("custom", pf.GenericProvider("my-provider"))
+ >>> pf.list_model_providers()
+ ['custom']
+
+ .. versionadded:: 2.4.0
+ """
+ return list(_provider_registry)
+
+
+@PublicEvolving()
+def set_default_model_provider(name: str) -> None:
+ """
+ Select the registered provider to use by default.
+
+ A single registered provider is selected automatically. If you register
several
+ providers, select a default or choose a provider for each operation.
+
+ :param name: An already registered provider lookup name.
+ :raises ValueError: If the name is not registered.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.set_model_provider("custom", pf.GenericProvider("my-provider"))
+ >>> pf.set_default_model_provider("custom")
+
+ .. versionadded:: 2.4.0
+ """
+ global _default_provider
+ _validate_name(name, "name")
+ if name not in _provider_registry:
+ raise ValueError(f"Model provider {name!r} is not registered")
+ _default_provider = name
+
+
+def _resolve_provider(name: Optional[str] = None) -> ModelProvider:
+ if name is not None:
+ _validate_name(name, "name")
+ if name not in _provider_registry:
+ raise ValueError(f"Model provider {name!r} is not registered")
+ return _provider_registry[name]
+ if _default_provider is not None:
+ return _provider_registry[_default_provider]
+ if len(_provider_registry) == 1:
+ return next(iter(_provider_registry.values()))
+ if not _provider_registry:
+ raise ValueError("No model provider is registered")
+ raise ValueError(
+ "Multiple model providers are registered; call
set_default_model_provider() "
+ "or select a provider explicitly"
+ )
diff --git a/flink-python/pyflink/dataframe/tests/test_model_provider.py
b/flink-python/pyflink/dataframe/tests/test_model_provider.py
new file mode 100644
index 00000000000..71dd1d62a98
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_model_provider.py
@@ -0,0 +1,481 @@
+################################################################################
+# 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 typing import get_type_hints
+
+import pyflink.dataframe as pf
+from pyflink.dataframe import llm
+from pyflink.table import TableEnvironment
+
+
+class ModelProviderTests(unittest.TestCase):
+ def test_triton_options_follow_community_option_names(self):
+
self.assertEqual(pf.TritonProvider("http://localhost:8000").to_options(),
+ {"endpoint": "http://localhost:8000"})
+ headers = {"X-Request": "comma, colon: quote'", "X-Empty": ""}
+ provider = pf.TritonProvider(
+ "http://localhost:8000", model_name="model", model_version="2",
timeout="10 s",
+ flatten_batch_dim=False, priority=0,
sequence_id="18446744073709551615",
+ sequence_start=False, sequence_end=True, compression="gzip",
auth_token="token",
+ custom_headers=headers, max_retries=0, retry_initial_backoff="100
ms",
+ retry_max_backoff="1 s", default_value="null",
health_check_enabled=True,
+ health_check_interval="5 s", circuit_breaker_enabled=False,
+ circuit_breaker_failure_threshold=0.5, circuit_breaker_timeout="30
s",
+ circuit_breaker_half_open_requests=2,
+ )
+ self.assertEqual(provider.provider_identifier(), "triton")
+ self.assertEqual(provider.model_option_key(), "model-name")
+ options = provider.to_options()
+ self.assertEqual(json.loads(options.pop("custom-headers")), headers)
+ expected = {
+ "endpoint": "http://localhost:8000", "model-name": "model",
"model-version": "2",
+ "timeout": "10 s", "flatten-batch-dim": "false", "priority": "0",
+ "sequence-id": "18446744073709551615", "sequence-start": "false",
+ "sequence-end": "true", "compression": "gzip", "auth-token":
"token",
+ "max-retries": "0", "retry-initial-backoff": "100 ms",
"retry-max-backoff": "1 s",
+ "default-value": "null", "health-check-enabled": "true",
"health-check-interval": "5 s",
+ "circuit-breaker-enabled": "false",
"circuit-breaker-failure-threshold": "0.5",
+ "circuit-breaker-timeout": "30 s",
"circuit-breaker-half-open-requests": "2",
+ }
+ self.assertEqual(options, expected)
+ headers["X-Request"] = "changed"
+ self.assertEqual(json.loads(provider.to_options()["custom-headers"]),
+ {"X-Request": "comma, colon: quote'", "X-Empty": ""})
+ provider.to_options().clear()
+ self.assertEqual(set(provider.to_options()), set(expected) |
{"custom-headers"})
+
+ def test_triton_rejects_invalid_typed_options(self):
+ for options, error in [
+ ({"model_name": 1}, TypeError),
+ ({"timeout": 10}, TypeError),
+ ({"flatten_batch_dim": "false"}, TypeError),
+ ({"sequence_start": 1}, TypeError),
+ ({"priority": True}, TypeError),
+ ({"priority": 0.5}, TypeError),
+ ({"priority": -1}, ValueError),
+ ({"priority": 256}, ValueError),
+ ({"max_retries": -1}, ValueError),
+ ({"compression": "deflate"}, ValueError),
+ ({"default_value": [0.0]}, TypeError),
+ ({"custom_headers": [("X-Header", "value")]}, TypeError),
+ ({"custom_headers": {1: "value"}}, TypeError),
+ ({"custom_headers": {"X-Header": 1}}, TypeError),
+ ({"health_check_enabled": 1}, TypeError),
+ ({"circuit_breaker_enabled": "false"}, TypeError),
+ ({"circuit_breaker_failure_threshold": 0.0}, ValueError),
+ ({"circuit_breaker_failure_threshold": 1.1}, ValueError),
+ ({"circuit_breaker_failure_threshold": True}, TypeError),
+ ({"circuit_breaker_failure_threshold": float("inf")}, ValueError),
+ ({"circuit_breaker_half_open_requests": 0}, ValueError),
+ ({"retry_initial_backoff": 100}, TypeError),
+ ({"extra_body": "{}"}, TypeError),
+ ]:
+ with self.subTest(options=options):
+ with self.assertRaises(error):
+ pf.TritonProvider("http://localhost:8000", **options)
+
+ def test_openai_connection_options_allow_deferred_model_selection(self):
+ endpoint = "https://example.test/v1/embeddings"
+ provider = pf.OpenAIProvider(endpoint=endpoint, api_key="key")
+ self.assertEqual(provider.provider_identifier(), "openai")
+ self.assertEqual(provider.model_option_key(), "model")
+ self.assertEqual(provider.to_options(), {"endpoint": endpoint,
"api-key": "key"})
+ self.assertEqual(
+ pf.OpenAIProvider(endpoint, "key",
model="embed-model").to_options(),
+ {"endpoint": endpoint, "api-key": "key", "model": "embed-model"},
+ )
+
+ def test_openai_options_follow_community_option_names(self):
+ provider = pf.OpenAIProvider(
+ "https://example.test/v1/chat/completions", "key",
model="chat-model",
+ system_prompt="", temperature=0.0, top_p=1.0, max_tokens=100,
+ stop="END,STOP", presence_penalty=-1.0, n=2, seed=0,
+ response_format="json_object", dimension=3, max_context_size=1000,
+ context_overflow_action="truncated-head-log",
error_handling_strategy="IGNORE",
+ retry_num=0, retry_fallback_strategy="IGNORE",
+ )
+ expected = {
+ "endpoint": "https://example.test/v1/chat/completions", "api-key":
"key",
+ "model": "chat-model", "system-prompt": "", "temperature": "0.0",
+ "top-p": "1.0", "max-tokens": "100", "stop": "END,STOP",
+ "presence-penalty": "-1.0", "n": "2", "seed": "0",
+ "response-format": "json_object", "dimension": "3",
"max-context-size": "1000",
+ "context-overflow-action": "truncated-head-log",
"error-handling-strategy": "IGNORE",
+ "retry-num": "0", "retry-fallback-strategy": "IGNORE",
+ }
+ self.assertEqual(provider.to_options(), expected)
+ provider.to_options().clear()
+ self.assertEqual(provider.to_options(), expected)
+
+ def test_openai_rejects_invalid_typed_options(self):
+ for overrides, error in [
+ ({"endpoint": None}, TypeError),
+ ({"api_key": ""}, ValueError),
+ ({"model": 1}, TypeError),
+ ({"system_prompt": 1}, TypeError),
+ ({"max_tokens": True}, TypeError),
+ ({"dimension": "3"}, TypeError),
+ ({"temperature": True}, TypeError),
+ ({"temperature": float("nan")}, ValueError),
+ ({"temperature": float("-inf")}, ValueError),
+ ({"seed": True}, TypeError),
+ ({"top_p": -0.1}, ValueError),
+ ({"top_p": 1.1}, ValueError),
+ ({"presence_penalty": -3.0}, ValueError),
+ ({"presence_penalty": 3.0}, ValueError),
+ ({"retry_num": -1}, ValueError),
+ ({"n": 0}, ValueError),
+ ({"max_context_size": 0}, ValueError),
+ ({"stop": ["STOP"]}, TypeError),
+ ({"response_format": "json_schema"}, ValueError),
+ ({"context_overflow_action": "unknown"}, ValueError),
+ ({"error_handling_strategy": "unknown"}, ValueError),
+ ({"retry_fallback_strategy": "RETRY"}, ValueError),
+ ({"task": "chat/completions"}, TypeError),
+ ]:
+ with self.subTest(overrides=overrides):
+ arguments = {"endpoint":
"https://example.test/chat/completions", "api_key": "key"}
+ arguments.update(overrides)
+ with self.assertRaises(error):
+ pf.OpenAIProvider(**arguments)
+
+ def test_numeric_options_accept_boundaries_and_unbounded_values(self):
+ for top_p, presence_penalty in [(0, -2), (1, 2)]:
+ with self.subTest(top_p=top_p, presence_penalty=presence_penalty):
+ provider = pf.OpenAIProvider(
+ "https://example.test/chat/completions", "key",
+ temperature=-0.5, seed=-100, top_p=top_p,
presence_penalty=presence_penalty,
+ max_tokens=1, n=1, dimension=1, max_context_size=1,
retry_num=0,
+ )
+ self.assertEqual(provider.to_options(), {
+ "endpoint": "https://example.test/chat/completions",
"api-key": "key",
+ "temperature": "-0.5", "seed": "-100", "top-p": str(top_p),
+ "presence-penalty": str(presence_penalty), "max-tokens":
"1", "n": "1",
+ "dimension": "1", "max-context-size": "1", "retry-num":
"0",
+ })
+ provider = pf.TritonProvider(
+ "http://localhost:8000", priority=255, max_retries=0,
+ circuit_breaker_failure_threshold=1,
circuit_breaker_half_open_requests=1,
+ )
+ self.assertEqual(provider.to_options(), {
+ "endpoint": "http://localhost:8000", "priority": "255",
"max-retries": "0",
+ "circuit-breaker-failure-threshold": "1",
"circuit-breaker-half-open-requests": "1",
+ })
+
+ def test_generic_provider_preserves_raw_options(self):
+ expected = {"endpoint": "HTTPS://example.test/v1", "api-key": "key",
+ "custom_option": "", "enabled": "false", "identifier":
"raw",
+ "self": "raw", "options": "raw"}
+ for form in ("keywords", "mapping", "mixed", "none"):
+ with self.subTest(form=form):
+ options = dict(expected)
+ if form == "keywords":
+ provider = pf.GenericProvider("custom", **options)
+ elif form == "mapping":
+ provider = pf.GenericProvider("custom",
MappingProxyType(options))
+ elif form == "mixed":
+ provider = pf.GenericProvider(
+ "custom", {"endpoint": options.pop("endpoint")},
**options)
+ else:
+ provider = pf.GenericProvider("custom", None, **options)
+ options.clear()
+ self.assertIsInstance(provider, pf.ModelProvider)
+ self.assertEqual(provider.provider_identifier(), "custom")
+ self.assertEqual(provider.model_option_key(), "model")
+ self.assertEqual(provider.to_options(), expected)
+ provider.to_options().clear()
+ self.assertEqual(provider.to_options(), expected)
+
+ def test_typed_providers_accept_raw_options(self):
+ for provider_type, connection, raw, typed, expected in [
+ (pf.OpenAIProvider,
+ {"endpoint": "https://example.test/chat/completions", "api_key":
"key"},
+ {"model": "chat-model", "max-tokens": "100", "temperature": "0.0",
+ "system-prompt": "", "future-option": "Mixed CASE"},
+ {"max_tokens": 100, "temperature": 0.0, "system_prompt": ""},
+ {"endpoint": "https://example.test/chat/completions", "api-key":
"key",
+ "model": "chat-model", "max-tokens": "100", "temperature": "0.0",
+ "system-prompt": "", "future-option": "Mixed CASE"}),
+ (pf.TritonProvider,
+ {"endpoint": "http://localhost:8000"},
+ {"model-name": "classifier", "max-retries": "0",
"flatten-batch-dim": "false",
+ "future_option": ""},
+ {"max_retries": 0, "flatten_batch_dim": False},
+ {"endpoint": "http://localhost:8000", "model-name": "classifier",
+ "max-retries": "0", "flatten-batch-dim": "false",
"future_option": ""}),
+ ]:
+ for parameters in ({}, typed):
+ with self.subTest(provider=provider_type.__name__,
parameters=parameters):
+ options = dict(raw)
+ provider = provider_type(
+ **connection, **parameters,
options=MappingProxyType(options))
+ options.clear()
+ self.assertEqual(provider.to_options(), expected)
+ provider.to_options().clear()
+ self.assertEqual(provider.to_options(), expected)
+
+ def test_public_contracts_have_resolvable_type_hints(self):
+ for api in (pf.set_model_provider, pf.set_default_model_provider,
pf.list_model_providers,
+ pf.ModelProvider.provider_identifier,
pf.ModelProvider.to_options,
+ pf.ModelProvider.model_option_key,
pf.OpenAIProvider.__init__,
+ pf.TritonProvider.__init__, pf.GenericProvider.__init__):
+ with self.subTest(api=api.__qualname__):
+ self.assertTrue(get_type_hints(api))
+
+ def test_providers_reject_conflicting_options(self):
+ for construct, key, value in [
+ (lambda options: pf.OpenAIProvider(
+ "https://example.test/chat/completions", "key", max_tokens=10,
options=options),
+ "max-tokens", "10"),
+ (lambda options: pf.OpenAIProvider(
+ "https://example.test/chat/completions", "key",
options=options),
+ "api-key", "key"),
+ (lambda options: pf.OpenAIProvider(
+ "https://example.test/chat/completions", "key",
system_prompt="", options=options),
+ "system-prompt", ""),
+ (lambda options: pf.TritonProvider(
+ "http://localhost:8000", flatten_batch_dim=False,
options=options),
+ "flatten-batch-dim", "false"),
+ (lambda options: pf.TritonProvider(
+ "http://localhost:8000", max_retries=0, options=options),
+ "max-retries", "0"),
+ (lambda options: pf.GenericProvider("custom", options, timeout="10
s"),
+ "timeout", "10 s"),
+ ]:
+ with self.subTest(key=key):
+ options = {key: value}
+ self.assertEqual(construct(options).to_options()[key], value)
+ options[key] = "different"
+ with self.assertRaisesRegex(ValueError, "conflicting values
for option"):
+ construct(options)
+ self.assertEqual(options, {key: "different"})
+
+ def test_generic_provider_rejects_invalid_configuration(self):
+ for identifier, options, error in [
+ (1, {}, TypeError),
+ ("", {}, ValueError),
+ (" ", {}, ValueError),
+ ("custom", {"enabled": True}, TypeError),
+ ("custom", {"retries": 1}, TypeError),
+ ("custom", {"endpoint": None}, TypeError),
+ ]:
+ with self.subTest(identifier=identifier, options=options):
+ with self.assertRaises(error):
+ pf.GenericProvider(identifier, **options)
+
+ def test_providers_reject_invalid_raw_options(self):
+ for name, construct, expected in (
+ ("openai", lambda options: pf.OpenAIProvider(
+ "https://example.test/chat/completions", "key",
options=options),
+ {"endpoint": "https://example.test/chat/completions", "api-key":
"key"}),
+ ("triton", lambda options:
pf.TritonProvider("http://localhost:8000", options=options),
+ {"endpoint": "http://localhost:8000"}),
+ ("generic", lambda options: pf.GenericProvider("custom", options),
{}),
+ ):
+ for options in ([], "options", False, {1: "value"}, {"option":
None},
+ {"option": False}, {"option": 1}):
+ with self.subTest(provider=name, options=options):
+ with self.assertRaises(TypeError):
+ construct(options)
+ self.assertEqual(construct({}).to_options(), expected)
+
+ with self.assertRaises(TypeError):
+ pf.OpenAIProvider("https://example.test/chat/completions", "key",
+ max_tokens=True, options={"max-tokens": "1"})
+ with self.assertRaises(ValueError):
+ pf.TritonProvider("http://localhost:8000", priority=256,
options={"priority": "1"})
+
+
+class ModelProviderRegistryTests(unittest.TestCase):
+ def setUp(self):
+ previous = dict(llm._provider_registry)
+ self.addCleanup(llm._provider_registry.update, previous)
+ self.addCleanup(llm._provider_registry.clear)
+ llm._provider_registry.clear()
+ previous_default = llm._default_provider
+ self.addCleanup(setattr, llm, "_default_provider", previous_default)
+ llm._default_provider = None
+
+ def test_registration_is_lazy_and_names_are_ordered(self):
+ class UnserializedProvider(pf.ModelProvider):
+ def provider_identifier(self):
+ raise AssertionError("Registration must not inspect the
provider")
+
+ def to_options(self):
+ raise AssertionError("Registration must not serialize the
provider")
+
+ pf.set_model_provider(name="chat", provider=UnserializedProvider())
+ pf.set_model_provider("embed", provider=pf.GenericProvider("custom"))
+
+ names = pf.list_model_providers()
+ self.assertEqual(names, ["chat", "embed"])
+ names.clear()
+ self.assertEqual(pf.list_model_providers(), ["chat", "embed"])
+
+ def
test_single_provider_registration_derives_names_without_serializing(self):
+ class UnserializedProvider(pf.ModelProvider):
+ def provider_identifier(self):
+ return "custom"
+
+ def to_options(self):
+ raise AssertionError("Registration must not serialize the
provider")
+
+ def model_option_key(self):
+ raise AssertionError("Registration must not inspect model
options")
+
+ provider = UnserializedProvider()
+ pf.set_model_provider(provider)
+ self.assertEqual(pf.list_model_providers(), ["custom"])
+ self.assertIs(llm._resolve_provider(), provider)
+
+
pf.set_model_provider(provider=pf.OpenAIProvider("https://example.test/embeddings",
"key"))
+ with self.assertRaisesRegex(ValueError, "Multiple model providers"):
+ llm._resolve_provider()
+
+ pf.set_default_model_provider("custom")
+ pf.set_model_provider(pf.TritonProvider("http://localhost:8000"))
+ self.assertEqual(pf.list_model_providers(), ["custom", "openai",
"triton"])
+ self.assertIs(llm._resolve_provider(), provider)
+
+ def test_invalid_registration_preserves_registered_names(self):
+ class InvalidIdentifierProvider(pf.ModelProvider):
+ def __init__(self, identifier):
+ self.identifier = identifier
+
+ def provider_identifier(self):
+ if isinstance(self.identifier, RuntimeError):
+ raise self.identifier
+ return self.identifier
+
+ def to_options(self):
+ raise AssertionError("Registration must not serialize the
provider")
+
+ provider = pf.GenericProvider("custom")
+ other = pf.GenericProvider("other")
+ pf.set_model_provider("chat", provider)
+ pf.set_model_provider("embed", other)
+ pf.set_default_model_provider("chat")
+ invalid_calls = [
+ (("", provider), {}, ValueError),
+ ((" ", provider), {}, ValueError),
+ ((1, provider), {}, TypeError),
+ ((None, provider), {}, TypeError),
+ (("chat", None), {}, TypeError),
+ (("embed", "custom"), {}, TypeError),
+ ((), {}, TypeError),
+ (("chat",), {}, TypeError),
+ ((), {"provider": None}, TypeError),
+ ((), {"name": provider}, TypeError),
+ ((provider, provider), {}, TypeError),
+ ((provider, None), {}, TypeError),
+ (("chat", provider, provider), {}, TypeError),
+ (("chat",), {"name": "embed", "provider": provider}, TypeError),
+ (("chat", provider), {"provider": other}, TypeError),
+ (("chat", provider), {"endpoint": "https://example.test"},
TypeError),
+ ]
+ for identifier, error in [
+ ("", ValueError), (" ", ValueError), (1, TypeError), (None,
TypeError),
+ (RuntimeError("Identifier unavailable"), RuntimeError),
+ ]:
+ invalid_calls.append(((), {"provider":
InvalidIdentifierProvider(identifier)}, error))
+ for args, kwargs, error in invalid_calls:
+ with self.subTest(args=args, kwargs=kwargs):
+ with self.assertRaises(error):
+ pf.set_model_provider(*args, **kwargs)
+ self.assertEqual(pf.list_model_providers(), ["chat", "embed"])
+ self.assertIs(llm._resolve_provider(), provider)
+ self.assertIs(llm._resolve_provider("chat"), provider)
+ self.assertIs(llm._resolve_provider("embed"), other)
+
+ def test_replacement_preserves_order_and_defaults_bound_to_names(self):
+ original = pf.GenericProvider("custom", token="old")
+ pf.set_model_provider(original)
+ replacement = pf.GenericProvider("custom", token="new")
+ pf.set_model_provider(replacement)
+ self.assertEqual(pf.list_model_providers(), ["custom"])
+ self.assertIs(llm._resolve_provider(), replacement)
+
+ pf.set_model_provider("other", pf.GenericProvider("other"))
+ pf.set_default_model_provider("custom")
+ named = pf.GenericProvider("different-factory")
+ pf.set_model_provider(name="custom", provider=named)
+ self.assertEqual(pf.list_model_providers(), ["custom", "other"])
+ self.assertIs(llm._resolve_provider(), named)
+
+ pf.set_model_provider(provider=original)
+ other = pf.GenericProvider("another-factory")
+ pf.set_model_provider("other", other)
+ self.assertEqual(pf.list_model_providers(), ["custom", "other"])
+ self.assertIs(llm._resolve_provider(), original)
+ self.assertIs(llm._resolve_provider("other"), other)
+
+ def test_selection_requires_choice_only_with_multiple_providers(self):
+ chat = pf.GenericProvider("custom", task="chat")
+ embed = pf.GenericProvider("custom", task="embed")
+ with self.assertRaisesRegex(ValueError, "No model provider"):
+ llm._resolve_provider()
+
+ pf.set_model_provider("chat", chat)
+ self.assertIs(llm._resolve_provider(), chat)
+ pf.set_model_provider("embed", embed)
+ with self.assertRaisesRegex(ValueError, "Multiple model providers"):
+ llm._resolve_provider()
+
+ pf.set_default_model_provider("chat")
+ pf.set_model_provider("other", pf.GenericProvider("other"))
+ self.assertIs(llm._resolve_provider(), chat)
+ self.assertIs(llm._resolve_provider("embed"), embed)
+
+ def test_invalid_selection_preserves_default(self):
+ provider = pf.GenericProvider("custom")
+ pf.set_model_provider("chat", provider)
+ pf.set_default_model_provider("chat")
+ for name, error in [("unknown", ValueError), ("", ValueError),
+ (" ", ValueError), (1, TypeError), (None,
TypeError)]:
+ with self.subTest(name=name):
+ with self.assertRaises(error):
+ pf.set_default_model_provider(name)
+ self.assertIs(llm._resolve_provider(), provider)
+ if name is not None:
+ with self.assertRaises(error):
+ llm._resolve_provider(name)
+
+ def test_registry_and_default_survive_environment_replacement(self):
+ previous_environment = pf.get_table_environment()
+ buffered = dict(pf.config._buffered)
+ self.addCleanup(pf.config._buffered.update, buffered)
+ self.addCleanup(pf.config._buffered.clear)
+ self.addCleanup(pf.set_table_environment, previous_environment)
+ pf.config._buffered.clear()
+ provider = pf.GenericProvider("custom")
+ pf.set_model_provider("chat", provider)
+ pf.set_model_provider("embed", pf.GenericProvider("custom"))
+ pf.set_default_model_provider("chat")
+
+ for environment in (object.__new__(TableEnvironment), None,
+ object.__new__(TableEnvironment)):
+ pf.set_table_environment(environment)
+ self.assertEqual(pf.list_model_providers(), ["chat", "embed"])
+ self.assertIs(llm._resolve_provider(), provider)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/flink-python/pyflink/dataframe/validation.py
b/flink-python/pyflink/dataframe/validation.py
index 49cc95d03aa..de55e8cba49 100644
--- a/flink-python/pyflink/dataframe/validation.py
+++ b/flink-python/pyflink/dataframe/validation.py
@@ -25,23 +25,42 @@ subclass and ``take(True)`` is almost certainly a mistake.
"""
import math
-from typing import Any, Sequence
+from typing import Any, Optional, Sequence, Union
-def _require_int(value: Any, name: str, minimum: int) -> None:
+def _require_int(
+ value: Any, name: str, minimum: Optional[int] = None, *, maximum:
Optional[int] = None,
+ include_minimum: bool = True,
+) -> None:
+ """Require an integer, optionally constrained by lower and upper bounds."""
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
- if value < minimum:
- raise ValueError(f"{name} must be at least {minimum}, got {value}")
+ _require_range(value, name, minimum, maximum, include_minimum)
-def _require_number(value: Any, name: str, minimum: float) -> None:
+def _require_number(
+ value: Any, name: str, minimum: Optional[float] = None, *, maximum:
Optional[float] = None,
+ include_minimum: bool = True,
+) -> None:
+ """Require a finite number, optionally constrained by lower and upper
bounds."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
if not math.isfinite(value):
raise ValueError(f"{name} must be finite, got {value}")
- if value < minimum:
- raise ValueError(f"{name} must be at least {minimum}, got {value}")
+ _require_range(value, name, minimum, maximum, include_minimum)
+
+
+def _require_range(
+ value: Union[int, float], name: str, minimum: Optional[float], maximum:
Optional[float],
+ include_minimum: bool,
+) -> None:
+ if minimum is not None:
+ if include_minimum and value < minimum:
+ raise ValueError(f"{name} must be at least {minimum}, got {value}")
+ if not include_minimum and value <= minimum:
+ raise ValueError(f"{name} must be greater than {minimum}, got
{value}")
+ if maximum is not None and value > maximum:
+ raise ValueError(f"{name} must be at most {maximum}, got {value}")
def _require_non_empty_str(value: Any, name: str) -> None: