This is an automated email from the ASF dual-hosted git repository.
yuxiqian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git
The following commit(s) were added to refs/heads/master by this push:
new 53fe0f92a [FLINK-40411][runtime] Introduce OpenAI-compatible model
client (#4507)
53fe0f92a is described below
commit 53fe0f92aa0e6d7306c19c1d74bfd080e41efb30
Author: haruki <[email protected]>
AuthorDate: Tue Aug 18 18:45:43 2026 +0800
[FLINK-40411][runtime] Introduce OpenAI-compatible model client (#4507)
---
.github/workflows/modules.py | 1 +
docs/content.zh/docs/core-concept/ai-model.md | 161 +++++++++
docs/content.zh/docs/core-concept/transform.md | 71 +---
docs/content/docs/core-concept/ai-model.md | 161 +++++++++
docs/content/docs/core-concept/transform.md | 71 +---
.../cdc/composer/flink/FlinkPipelineComposer.java | 3 +-
.../flink/translator/TransformTranslator.java | 12 +-
.../flink/FlinkPipelineAiFunctionITCase.java | 89 +++++
.../pom.xml | 132 ++++++++
.../cdc/models/openai/ErrorHandlingStrategy.java | 25 ++
.../models/openai/OpenAiCompatibleModelClient.java | 358 +++++++++++++++++++++
.../openai/OpenAiCompatibleModelClientFactory.java | 90 ++++++
.../openai/OpenAiCompatibleModelOptions.java | 225 +++++++++++++
.../cdc/models/openai/OpenAiRequestParams.java | 194 +++++++++++
.../cdc/models/openai/RetryBackoffStrategy.java | 54 ++++
.../src/main/resources/META-INF/NOTICE | 34 ++
.../org.apache.flink.cdc.common.factories.Factory | 16 +
.../OpenAiCompatibleModelClientFactoryTest.java | 147 +++++++++
.../openai/OpenAiCompatibleModelClientTest.java | 292 +++++++++++++++++
.../cdc/models/openai/OpenAiRequestParamsTest.java | 125 +++++++
.../src/test/resources/log4j2-test.properties | 22 ++
flink-cdc-pipeline-model/pom.xml | 1 +
22 files changed, 2140 insertions(+), 144 deletions(-)
diff --git a/.github/workflows/modules.py b/.github/workflows/modules.py
index e80b2ccad..d92a4fc65 100755
--- a/.github/workflows/modules.py
+++ b/.github/workflows/modules.py
@@ -13,6 +13,7 @@ MODULES_CORE = [
"flink-cdc-common",
"flink-cdc-composer",
"flink-cdc-runtime",
+ "flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible",
"flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base",
"flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values"
]
diff --git a/docs/content.zh/docs/core-concept/ai-model.md
b/docs/content.zh/docs/core-concept/ai-model.md
new file mode 100644
index 000000000..9b8029326
--- /dev/null
+++ b/docs/content.zh/docs/core-concept/ai-model.md
@@ -0,0 +1,161 @@
+---
+title: "AI 模型"
+weight: 9
+type: docs
+---
+<!--
+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 模型
+
+AI 模型可用于 transform 表达式中的文本补全和 embedding。
+
+## OpenAI-compatible 模型客户端
+
+AI 模型客户端可供 transform 中的 `AI_COMPLETE` 和 `AI_EMBED` 函数引用。使用时,需要通过 `--jar` 将模型实现
JAR(例如 `flink-cdc-pipeline-model-openai-compatible`)添加到 Pipeline 命令中。
+
+OpenAI-compatible 客户端支持调用实现 OpenAI Chat Completions 和 Embeddings REST API 的服务。
+
+system prompt、函数 prompt 和输入文本均支持英文或中文内容。
+
+```yaml
+transform:
+ - source-table: db.\.*
+ projection: >-
+ *,
+ AI_COMPLETE('completion_model', content, '总结输入内容') AS summary,
+ AI_EMBED('embedding_model', content) AS embedding
+
+pipeline:
+ model:
+ - name: completion_model
+ type: openai-compatible
+ options:
+ model: gpt-4o-mini
+ endpoint: https://api.example.com/v1
+ api-key: <api-key>
+ system-prompt: 你是一个简洁的助手。
+ temperature: 0.2
+ max-tokens: 256
+ - name: embedding_model
+ type: openai-compatible
+ options:
+ model: text-embedding-3-small
+ endpoint: https://api.example.com/v1
+ api-key: <api-key>
+ dimension: 768
+```
+
+不要将 API Key 提交到代码仓库中,请通过部署环境的密钥管理机制提供。
+
+### OpenAI-compatible 配置项
+
+| 配置项 | 是否必填 | 说明 |
+|--------|----------|------|
+| `model` | 是 | 发送给服务端的模型名称;`model-name` 作为废弃别名仍可使用。 |
+| `endpoint` | 是 | OpenAI-compatible 服务的 Base URL。 |
+| `api-key` | 是 | 请求认证使用的 Bearer Token。 |
+| `system-prompt` | 否 | 添加在 `AI_COMPLETE` 生成的 system prompt 之前。 |
+| `user-prompt` | 否 | 在输入之后追加一条 user message。 |
+| `temperature`、`top-p`、`stop`、`max-tokens` | 否 | 常用文本生成参数。 |
+| `presence-penalty`、`frequency-penalty`、`n`、`seed` | 否 | 其他文本生成参数。 |
+| `response-format` | 否 | 支持 `json_object`;AI completion 的结果必须是合法 JSON。 |
+| `content-type` | 否 | `text`(默认)或 `image_url`。 |
+| `dimension` | 否 | 请求的 embedding 维度。 |
+| `extra-header`、`extra-body` | 否 | JSON 对象格式的厂商自定义请求头或请求体字段。 |
+| `error-handling-strategy` | 否 | `retry`(默认)、`failover` 或 `ignore`。 |
+| `retry-num` | 否 | 最大尝试次数,默认 `100`。 |
+| `retry-fallback-strategy` | 否 | 重试耗尽后的策略,可选 `failover`(默认)或 `ignore`。 |
+| `retry-backoff-strategy` | 否 | `fixed`(默认)或 `exponential`。 |
+| `retry-backoff-base-interval` | 否 | 重试基础间隔,默认 `1 s`。 |
+
+## 旧版 Embedding AI 模型(已废弃)
+
+> **已废弃:** 基于 `model-name` 和 `class-name` 的旧版模型 API 已废弃,并计划在未来移除。新 Pipeline
请使用上面基于 Factory 的 OpenAI-compatible 模型客户端。
+
+旧版 Embedding AI 模型可以在 transform 规则中使用。使用时,需要下载内置模型 JAR,并在 `flink-cdc.sh` 命令中添加
`--jar {$BUILT_IN_MODEL_PATH}`。
+
+如何定义一个 Embedding AI 模型:
+
+```yaml
+pipeline:
+ model:
+ - model-name: CHAT
+ class-name: OpenAIChatModel
+ openai.model: text-embedding-3-small
+ openai.host: https://xxxx
+ openai.apikey: abcd1234
+ openai.chat.prompt: please summary this
+ - model-name: GET_EMBEDDING
+ class-name: OpenAIEmbeddingModel
+ openai.model: text-embedding-3-small
+ openai.host: https://xxxx
+ openai.apikey: abcd1234
+```
+
+注意:
+
+* `model-name` 是所有支持模型通用的必填参数,表示在 `projection` 或 `filter` 中调用的函数名称。
+* `class-name` 是所有支持模型通用的必填参数,可用值参见[所有支持的模型](#所有支持的模型)。
+* `openai.model`、`openai.host`、`openai.apikey` 和 `openai.chat.prompt`
是由具体模型定义的配置项。
+
+如何使用一个 Embedding AI 模型:
+
+```yaml
+transform:
+ - source-table: db.\.*
+ projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb,
CHAT(page) as summary"
+ filter: inc(id) < 100
+pipeline:
+ model:
+ - model-name: CHAT
+ class-name: OpenAIChatModel
+ openai.model: gpt-4o-mini
+ openai.host: http://langchain4j.dev/demo/openai/v1
+ openai.apikey: demo
+ openai.chat.prompt: please summary this
+ - model-name: GET_EMBEDDING
+ class-name: OpenAIEmbeddingModel
+ openai.model: text-embedding-3-small
+ openai.host: http://langchain4j.dev/demo/openai/v1
+ openai.apikey: demo
+```
+
+这里,`GET_EMBEDDING` 通过 `model-name` 在 `pipeline` 中定义。
+
+### 所有支持的模型
+
+下面列出了所有支持的内置模型:
+
+#### OpenAIChatModel
+
+| 参数 | 类型 | 是否必填 | 含义 |
+|------|------|----------|------|
+| `openai.model` | STRING | 是 | 要调用的模型名称,例如 `gpt-4o-mini`。可用选项有
`gpt-4o-mini`、`gpt-4o`、`gpt-4-32k` 和 `gpt-3.5-turbo`。 |
+| `openai.host` | STRING | 是 | 模型服务器地址,例如
`http://langchain4j.dev/demo/openai/v1`。 |
+| `openai.apikey` | STRING | 是 | 模型服务器验证使用的 API Key,例如 `demo`。 |
+| `openai.chat.prompt` | STRING | 否 | 与 OpenAI 聊天的提示词,例如 `Please summarize
this`。 |
+
+#### OpenAIEmbeddingModel
+
+| 参数 | 类型 | 是否必填 | 含义 |
+|------|------|----------|------|
+| `openai.model` | STRING | 是 | 要调用的模型名称,例如 `text-embedding-3-small`。可用选项有
`text-embedding-3-small`、`text-embedding-3-large` 和 `text-embedding-ada-002`。 |
+| `openai.host` | STRING | 是 | 模型服务器地址,例如
`http://langchain4j.dev/demo/openai/v1`。 |
+| `openai.apikey` | STRING | 是 | 模型服务器验证使用的 API Key,例如 `demo`。 |
diff --git a/docs/content.zh/docs/core-concept/transform.md
b/docs/content.zh/docs/core-concept/transform.md
index 8636567cf..0409d7a6a 100644
--- a/docs/content.zh/docs/core-concept/transform.md
+++ b/docs/content.zh/docs/core-concept/transform.md
@@ -527,73 +527,4 @@ transform:
filter: inc(id) < 100
```
-## Embedding AI 模型
-
-内置 AI 模型可以在 transform 规则中使用。
-为了使用内置 AI 模型,你需要下载内置模型的 jar ,然后在 `flink-cdc.sh` 命令中添加 `--jar
{$BUILT_IN_MODEL_PATH}`。
-
-如何定义一个 Embedding AI 模型:
-
-```yaml
-pipeline:
- model:
- - model-name: CHAT
- class-name: OpenAIChatModel
- openai.model: gpt-4o-mini
- openai.host: https://xxxx
- openai.apikey: abcd1234
- openai.chat.prompt: please summary this
- - model-name: GET_EMBEDDING
- class-name: OpenAIEmbeddingModel
- openai.model: text-embedding-3-small
- openai.host: https://xxxx
- openai.apikey: abcd1234
-```
-注意:
-* `model-name` 是一个通用的必填参数,用于所有支持的模型,表示在 `projection` 或 `filter` 中调用的函数名称。
-* `class-name` 是一个通用的必填参数,用于所有支持的模型,可用值可以在[所有支持的模型](#all-support-models)中找到。
-* `openai.model`,`openai.host`,`openai.apiKey` 和 `openai.chat.prompt`
是在各个模型中特别的可选参数。
-
-如何使用一个 Embedding AI 模型:
-
-```yaml
-transform:
- - source-table: db.\.*
- projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb,
CHAT(page) as summary"
- filter: inc(id) < 100
-pipeline:
- model:
- - model-name: CHAT
- class-name: OpenAIChatModel
- openai.model: gpt-4o-mini
- openai.host: http://langchain4j.dev/demo/openai/v1
- openai.apikey: demo
- openai.chat.prompt: please summary this
- - model-name: GET_EMBEDDING
- class-name: OpenAIEmbeddingModel
- openai.model: text-embedding-3-small
- openai.host: http://langchain4j.dev/demo/openai/v1
- openai.apikey: demo
-```
-这里,GET_EMBEDDING 是通过 `model-name` 在 `pipeline` 中定义的。
-
-### 所有支持的模型
-
-下面列出了所有支持的模型:
-
-#### OpenAIChatModel
-
-| 参数 | 类型 | 是否必填 | 含义
|
-|--------------------|--------|----------|-----------------------------------------------------------------------------------------------------|
-| openai.model | STRING | 必填 | 要调用的模型名称,例如:"gpt-4o-mini",可用选项有
"gpt-4o-mini"、"gpt-4o"、"gpt-4-32k"、"gpt-3.5-turbo"。 |
-| openai.host | STRING | 必填 |
要连接的模型服务器地址,例如:`http://langchain4j.dev/demo/openai/v1`。
|
-| openai.apikey | STRING | 必填 | 模型服务器验证的 API Key,例如:"demo"。
|
-| openai.chat.prompt | STRING | 可选 | 与 OpenAI 聊天的提示词,例如:"Please summary
this"。 |
-
-#### OpenAIEmbeddingModel
-
-| 参数 | 类型 | 是否必填 | 含义
|
-|---------------|--------|----------|----------------------------------------------------------------------------------------------------------------------|
-| openai.model | STRING | 必填 |
要调用的模型名称,例如:"text-embedding-3-small",可用选项有
"text-embedding-3-small"、"text-embedding-3-large"、"text-embedding-ada-002"。 |
-| openai.host | STRING | 必填 |
要连接的模型服务器地址,例如:`http://langchain4j.dev/demo/openai/v1`。
|
-| openai.apikey | STRING | 必填 | 模型服务器验证的 API Key,例如:"demo"。
|
+有关 AI 模型函数和配置,请参见 [AI 模型]({{< ref "docs/core-concept/ai-model" >}})。
diff --git a/docs/content/docs/core-concept/ai-model.md
b/docs/content/docs/core-concept/ai-model.md
new file mode 100644
index 000000000..da3ef0f00
--- /dev/null
+++ b/docs/content/docs/core-concept/ai-model.md
@@ -0,0 +1,161 @@
+---
+title: "AI Model"
+weight: 9
+type: docs
+---
+<!--
+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 Model
+
+AI models can be used in transform expressions for text completion and
embedding.
+
+## OpenAI-compatible Model Client
+
+AI model clients can be referenced by the `AI_COMPLETE` and `AI_EMBED`
transform functions. Add the model implementation JAR, such as
`flink-cdc-pipeline-model-openai-compatible`, to the pipeline command with
`--jar`.
+
+The OpenAI-compatible client supports chat completions and text embeddings
against endpoints that implement the corresponding OpenAI REST APIs.
+
+System prompts, function prompts, and input text may contain either English or
Chinese content.
+
+```yaml
+transform:
+ - source-table: db.\.*
+ projection: >-
+ *,
+ AI_COMPLETE('completion_model', content, 'Summarize the input') AS
summary,
+ AI_EMBED('embedding_model', content) AS embedding
+
+pipeline:
+ model:
+ - name: completion_model
+ type: openai-compatible
+ options:
+ model: gpt-4o-mini
+ endpoint: https://api.example.com/v1
+ api-key: <api-key>
+ system-prompt: You are a concise assistant.
+ temperature: 0.2
+ max-tokens: 256
+ - name: embedding_model
+ type: openai-compatible
+ options:
+ model: text-embedding-3-small
+ endpoint: https://api.example.com/v1
+ api-key: <api-key>
+ dimension: 768
+```
+
+Do not store API keys in source control. Supply them through the
secret-management mechanism of your deployment environment.
+
+### OpenAI-compatible Options
+
+| Option | Required | Description |
+|--------|----------|-------------|
+| `model` | Yes | Model name sent to the endpoint. `model-name` is accepted as
a deprecated alias. |
+| `endpoint` | Yes | Base URL of the OpenAI-compatible endpoint. |
+| `api-key` | Yes | Bearer token used to authenticate requests. |
+| `system-prompt` | No | Prompt prepended to the system prompt generated by
`AI_COMPLETE`. |
+| `user-prompt` | No | Additional user message appended after the input. |
+| `temperature`, `top-p`, `stop`, `max-tokens` | No | Common generation
parameters. |
+| `presence-penalty`, `frequency-penalty`, `n`, `seed` | No | Additional
generation parameters. |
+| `response-format` | No | `json_object` is supported. AI completion results
must be valid JSON. |
+| `content-type` | No | `text` (default) or `image_url`. |
+| `dimension` | No | Requested embedding dimension. |
+| `extra-header`, `extra-body` | No | Provider-specific headers or body fields
encoded as JSON objects. |
+| `error-handling-strategy` | No | `retry` (default), `failover`, or `ignore`.
|
+| `retry-num` | No | Maximum number of attempts. Defaults to `100`. |
+| `retry-fallback-strategy` | No | `failover` (default) or `ignore` after
retries are exhausted. |
+| `retry-backoff-strategy` | No | `fixed` (default) or `exponential`. |
+| `retry-backoff-base-interval` | No | Base retry interval. Defaults to `1 s`.
|
+
+## Legacy Embedding AI Model (Deprecated)
+
+> **Deprecated:** The legacy model API based on `model-name` and `class-name`
is deprecated and planned for removal. Use the factory-based OpenAI-compatible
model client above for new pipelines.
+
+The legacy Embedding AI Model can be used in transform rules. To use it,
download the built-in model JAR and add `--jar {$BUILT_IN_MODEL_PATH}` to your
`flink-cdc.sh` command.
+
+How to define an Embedding AI Model:
+
+```yaml
+pipeline:
+ model:
+ - model-name: CHAT
+ class-name: OpenAIChatModel
+ openai.model: gpt-4o-mini
+ openai.host: https://xxxx
+ openai.apikey: abcd1234
+ openai.chat.prompt: please summary this
+ - model-name: GET_EMBEDDING
+ class-name: OpenAIEmbeddingModel
+ openai.model: text-embedding-3-small
+ openai.host: https://xxxx
+ openai.apikey: abcd1234
+```
+
+Note:
+
+* `model-name` is a common required parameter for all supported models. It
represents the function name called in `projection` or `filter`.
+* `class-name` is a common required parameter for all supported models.
Available values can be found in [All Supported Models](#all-supported-models).
+* `openai.model`, `openai.host`, `openai.apikey`, and `openai.chat.prompt` are
options defined by a specific model.
+
+How to use an Embedding AI Model:
+
+```yaml
+transform:
+ - source-table: db.\.*
+ projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb,
CHAT(page) as summary"
+ filter: inc(id) < 100
+pipeline:
+ model:
+ - model-name: CHAT
+ class-name: OpenAIChatModel
+ openai.model: gpt-4o-mini
+ openai.host: http://langchain4j.dev/demo/openai/v1
+ openai.apikey: demo
+ openai.chat.prompt: please summary this
+ - model-name: GET_EMBEDDING
+ class-name: OpenAIEmbeddingModel
+ openai.model: text-embedding-3-small
+ openai.host: http://langchain4j.dev/demo/openai/v1
+ openai.apikey: demo
+```
+
+Here, `GET_EMBEDDING` is defined through `model-name` in `pipeline`.
+
+### All Supported Models
+
+The following built-in models are provided:
+
+#### OpenAIChatModel
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `openai.model` | STRING | Yes | Name of the model to call, for example,
`gpt-4o-mini`. Available options are `gpt-4o-mini`, `gpt-4o`, `gpt-4-32k`, and
`gpt-3.5-turbo`. |
+| `openai.host` | STRING | Yes | Model server address, for example,
`http://langchain4j.dev/demo/openai/v1`. |
+| `openai.apikey` | STRING | Yes | API key for authenticating with the model
server, for example, `demo`. |
+| `openai.chat.prompt` | STRING | No | Prompt for chatting with OpenAI, for
example, `Please summarize this`. |
+
+#### OpenAIEmbeddingModel
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `openai.model` | STRING | Yes | Name of the model to call, for example,
`text-embedding-3-small`. Available options are `text-embedding-3-small`,
`text-embedding-3-large`, and `text-embedding-ada-002`. |
+| `openai.host` | STRING | Yes | Model server address, for example,
`http://langchain4j.dev/demo/openai/v1`. |
+| `openai.apikey` | STRING | Yes | API key for authenticating with the model
server, for example, `demo`. |
diff --git a/docs/content/docs/core-concept/transform.md
b/docs/content/docs/core-concept/transform.md
index a9d217ae7..8e2f9815c 100644
--- a/docs/content/docs/core-concept/transform.md
+++ b/docs/content/docs/core-concept/transform.md
@@ -532,73 +532,4 @@ transform:
filter: inc(id) < 100
```
-## Embedding AI Model
-
-Embedding AI Model can be used in transform rules.
-To use Embedding AI Model, you need to download the jar of build-in model, and
then add `--jar {$BUILT_IN_MODEL_PATH}` to your flink-cdc.sh command.
-
-How to define a Embedding AI Model:
-
-```yaml
-pipeline:
- model:
- - model-name: CHAT
- class-name: OpenAIChatModel
- openai.model: text-embedding-3-small
- openai.host: https://xxxx
- openai.apikey: abcd1234
- openai.chat.prompt: please summary this
- - model-name: GET_EMBEDDING
- class-name: OpenAIEmbeddingModel
- openai.model: text-embedding-3-small
- openai.host: https://xxxx
- openai.apikey: abcd1234
-```
-Note:
-* `model-name` is a common required parameter for all support models, which
represent the function name called in `projection` or `filter`.
-* `class-name` is a common required parameter for all support models,
available values can be found in [All Support models](#all-support-models).
-* `openai.model`, `openai.host`, `openai.apiKey` and `openai.chat.prompt` is
option parameters that defined in specific model.
-
-How to use a Embedding AI Model:
-
-```yaml
-transform:
- - source-table: db.\.*
- projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb,
CHAT(page) as summary"
- filter: inc(id) < 100
-pipeline:
- model:
- - model-name: CHAT
- class-name: OpenAIChatModel
- openai.model: gpt-4o-mini
- openai.host: http://langchain4j.dev/demo/openai/v1
- openai.apikey: demo
- openai.chat.prompt: please summary this
- - model-name: GET_EMBEDDING
- class-name: OpenAIEmbeddingModel
- openai.model: text-embedding-3-small
- openai.host: http://langchain4j.dev/demo/openai/v1
- openai.apikey: demo
-```
-Here, GET_EMBEDDING is defined though `model-name` in `pipeline`.
-
-### All Support models
-
-The following built-in models are provided:
-
-#### OpenAIChatModel
-
-| parameter | type | optional/required | meaning
|
-|--------------------|--------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------|
-| openai.model | STRING | required | Name of model to be
called, for example: "gpt-4o-mini", Available options are "gpt-4o-mini",
"gpt-4o", "gpt-4-32k", "gpt-3.5-turbo". |
-| openai.host | STRING | required | Host of the Model server
to be connected, for example: `http://langchain4j.dev/demo/openai/v1`.
|
-| openai.apikey | STRING | required | Api Key for verification
of the Model server, for example, "demo".
|
-| openai.chat.prompt | STRING | optional | Prompt for chatting with
OpenAI, for example: "Please summary this ".
|
-
-#### OpenAIEmbeddingModel
-
-| parameter | type | optional/required | meaning
|
-|---------------|--------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| openai.model | STRING | required | Name of model to be called, for
example: "text-embedding-3-small", Available options are
"text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002". |
-| openai.host | STRING | required | Host of the Model server to be
connected, for example: `http://langchain4j.dev/demo/openai/v1`.
|
-| openai.apikey | STRING | required | Api Key for verification of the
Model server, for example, "demo".
|
+For AI model functions and configuration, see [AI Model]({{< ref
"docs/core-concept/ai-model" >}}).
diff --git
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
index 4d843ac87..71fc865f9 100644
---
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
+++
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/FlinkPipelineComposer.java
@@ -200,7 +200,8 @@ public class FlinkPipelineComposer implements
PipelineComposer {
pipelineDef.getUdfs(),
pipelineDef.getModels(),
dataSource.supportedMetadataColumns(),
- operatorUidGenerator);
+ operatorUidGenerator,
+ env);
if (isParallelMetadataSource) {
// Translate a distributed topology for sources with distributed
tables
diff --git
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
index 0ccb3afd8..f24e1a99a 100644
---
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
+++
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/TransformTranslator.java
@@ -28,6 +28,7 @@ import
org.apache.flink.cdc.common.source.SupportedMetadataColumn;
import org.apache.flink.cdc.composer.definition.ModelDef;
import org.apache.flink.cdc.composer.definition.TransformDef;
import org.apache.flink.cdc.composer.definition.UdfDef;
+import org.apache.flink.cdc.composer.flink.FlinkEnvironmentUtils;
import org.apache.flink.cdc.composer.utils.FactoryDiscoveryUtils;
import org.apache.flink.cdc.runtime.operators.transform.PostTransformOperator;
import
org.apache.flink.cdc.runtime.operators.transform.PostTransformOperatorBuilder;
@@ -36,6 +37,7 @@ import
org.apache.flink.cdc.runtime.operators.transform.PreTransformOperatorBuil
import org.apache.flink.cdc.runtime.parser.TransformParser;
import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import java.util.Collections;
import java.util.LinkedHashMap;
@@ -111,7 +113,8 @@ public class TransformTranslator {
List<UdfDef> udfFunctions,
List<ModelDef> models,
SupportedMetadataColumn[] supportedMetadataColumns,
- OperatorUidGenerator operatorUidGenerator) {
+ OperatorUidGenerator operatorUidGenerator,
+ StreamExecutionEnvironment env) {
if (transforms.isEmpty()) {
return input;
}
@@ -138,7 +141,7 @@ public class TransformTranslator {
.filter(ModelDef::isLegacy)
.map(this::modelToUDFTuple)
.collect(Collectors.toList()));
- postTransformFunctionBuilder.addModelClients(loadModelClients(models));
+ postTransformFunctionBuilder.addModelClients(loadModelClients(models,
env));
return input.transform(
"Transform:Data", new EventTypeInfo(),
postTransformFunctionBuilder.build())
.uid(operatorUidGenerator.generateUid("post-transform"));
@@ -151,7 +154,8 @@ public class TransformTranslator {
model.getParameters());
}
- private Map<String, AiModelClient> loadModelClients(List<ModelDef> models)
{
+ private Map<String, AiModelClient> loadModelClients(
+ List<ModelDef> models, StreamExecutionEnvironment env) {
List<ModelDef> clientModels =
models.stream().filter(model ->
!model.isLegacy()).collect(Collectors.toList());
if (clientModels.isEmpty()) {
@@ -164,6 +168,8 @@ public class TransformTranslator {
AiModelClientFactory factory =
FactoryDiscoveryUtils.getFactoryByIdentifier(
model.getType(), AiModelClientFactory.class);
+ FactoryDiscoveryUtils.getJarPathByIdentifier(factory)
+ .ifPresent(jar -> FlinkEnvironmentUtils.addJar(env, jar));
FactoryHelper.createFactoryHelper(
factory,
new FactoryHelper.DefaultContext(
diff --git
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineAiFunctionITCase.java
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineAiFunctionITCase.java
index 8aabfaffb..f01777540 100644
---
a/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineAiFunctionITCase.java
+++
b/flink-cdc-composer/src/test/java/org/apache/flink/cdc/composer/flink/FlinkPipelineAiFunctionITCase.java
@@ -39,6 +39,8 @@ import
org.apache.flink.cdc.connectors.values.factory.ValuesDataFactory;
import org.apache.flink.cdc.connectors.values.sink.ValuesDataSinkOptions;
import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceHelper;
import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceOptions;
+import org.apache.flink.cdc.models.dummy.DummyModelClient;
+import org.apache.flink.cdc.models.dummy.DummyModelClientFactory;
import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.test.junit5.MiniClusterExtension;
@@ -47,14 +49,25 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
import java.io.PrintStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
import static
org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL;
+import static org.apache.flink.configuration.PipelineOptions.JARS;
import static org.assertj.core.api.Assertions.assertThat;
/** Integration test for AI functions in the Flink pipeline. */
@@ -83,6 +96,8 @@ class FlinkPipelineAiFunctionITCase {
private final PrintStream standardOut = System.out;
private final ByteArrayOutputStream outCaptor = new
ByteArrayOutputStream();
+ @TempDir Path tempDir;
+
@BeforeEach
void init() {
System.setOut(new PrintStream(outCaptor));
@@ -125,6 +140,19 @@ class FlinkPipelineAiFunctionITCase {
}
private String[] runAiFunctionTest(String projection, List<ModelDef>
models) throws Exception {
+ URL modelJar = createDummyModelJar().toUri().toURL();
+ ClassLoader originalClassLoader =
Thread.currentThread().getContextClassLoader();
+ try (URLClassLoader modelClassLoader =
+ new DummyModelClassLoader(modelJar, originalClassLoader)) {
+ Thread.currentThread().setContextClassLoader(modelClassLoader);
+ return runAiFunctionTest(projection, models, modelJar);
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ private String[] runAiFunctionTest(String projection, List<ModelDef>
models, URL modelJar)
+ throws Exception {
FlinkPipelineComposer composer = FlinkPipelineComposer.ofMiniCluster();
// Source: one table with a single row
@@ -190,8 +218,69 @@ class FlinkPipelineAiFunctionITCase {
// Execute & capture output
PipelineExecution execution = composer.compose(pipelineDef);
+ assertThat(composer.getEnv().getConfiguration().get(JARS))
+ .as("AI model provider JARs uploaded with the JobGraph")
+ .contains(modelJar.toString());
execution.execute();
return outCaptor.toString().trim().split("\n");
}
+
+ private Path createDummyModelJar() throws IOException {
+ Path modelJar = tempDir.resolve("dummy-model.jar");
+ try (JarOutputStream output = new
JarOutputStream(Files.newOutputStream(modelJar))) {
+ addClassToJar(DummyModelClient.class, output);
+ addClassToJar(DummyModelClientFactory.class, output);
+
+ output.putNextEntry(
+ new JarEntry(
+
"META-INF/services/org.apache.flink.cdc.common.factories.Factory"));
+ output.write(
+ (DummyModelClientFactory.class.getName() + "\n")
+ .getBytes(StandardCharsets.UTF_8));
+ output.closeEntry();
+ }
+ return modelJar;
+ }
+
+ private static void addClassToJar(Class<?> clazz, JarOutputStream output)
throws IOException {
+ String resourceName = clazz.getName().replace('.', '/') + ".class";
+ try (InputStream input =
clazz.getClassLoader().getResourceAsStream(resourceName)) {
+ assertThat(input).as("class resource %s",
resourceName).isNotNull();
+ output.putNextEntry(new JarEntry(resourceName));
+ input.transferTo(output);
+ output.closeEntry();
+ }
+ }
+
+ private static final class DummyModelClassLoader extends URLClassLoader {
+
+ private static final String DUMMY_MODEL_PACKAGE =
"org.apache.flink.cdc.models.dummy.";
+
+ private DummyModelClassLoader(URL modelJar, ClassLoader parent) {
+ super(new URL[] {modelJar}, parent);
+ }
+
+ @Override
+ protected Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
+ if (!name.startsWith(DUMMY_MODEL_PACKAGE)) {
+ return super.loadClass(name, resolve);
+ }
+
+ synchronized (getClassLoadingLock(name)) {
+ Class<?> clazz = findLoadedClass(name);
+ if (clazz == null) {
+ try {
+ clazz = findClass(name);
+ } catch (ClassNotFoundException e) {
+ clazz = super.loadClass(name, false);
+ }
+ }
+ if (resolve) {
+ resolveClass(clazz);
+ }
+ return clazz;
+ }
+ }
+ }
}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/pom.xml
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/pom.xml
new file mode 100644
index 000000000..3ece6e81d
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/pom.xml
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-cdc-pipeline-model-parent</artifactId>
+ <version>${revision}</version>
+ </parent>
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <artifactId>flink-cdc-pipeline-model-openai-compatible</artifactId>
+ <packaging>jar</packaging>
+
+ <properties>
+ <jackson.version>2.18.2</jackson.version>
+ <openai-java.version>4.32.0</openai-java.version>
+ <okhttp.version>4.12.0</okhttp.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-cdc-common</artifactId>
+ <version>${project.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>com.openai</groupId>
+ <artifactId>openai-java</artifactId>
+ <version>${openai-java.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.squareup.okhttp3</groupId>
+ <artifactId>mockwebserver</artifactId>
+ <version>${okhttp.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-shade-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>shade-flink</id>
+ <configuration>
+ <artifactSet>
+ <includes combine.children="append">
+ <include>com.fasterxml:*</include>
+
<include>com.fasterxml.jackson.core:*</include>
+
<include>com.fasterxml.jackson.datatype:*</include>
+
<include>com.fasterxml.jackson.module:*</include>
+ <include>com.github.victools:*</include>
+ <include>com.google.errorprone:*</include>
+ <include>com.openai:*</include>
+ <include>com.squareup.okhttp3:*</include>
+ <include>com.squareup.okio:*</include>
+ <include>io.swagger.core.v3:*</include>
+ <include>org.jetbrains:*</include>
+ <include>org.jetbrains.kotlin:*</include>
+ </includes>
+ </artifactSet>
+ <filters combine.children="append">
+ <filter>
+ <artifact>*:*</artifact>
+ <excludes>
+
<exclude>META-INF/versions/**/module-info.class</exclude>
+ </excludes>
+ </filter>
+ </filters>
+ <transformers>
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
+ </transformers>
+ <relocations>
+ <relocation>
+ <pattern>com.fasterxml</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.com.fasterxml</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>com.github.victools</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.com.github.victools</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>com.google.errorprone</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.com.google.errorprone</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>io.swagger</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.io.swagger</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>okhttp3</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.okhttp3</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>okio</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.okio</shadedPattern>
+ </relocation>
+ <relocation>
+
<pattern>org.jetbrains.annotations</pattern>
+
<shadedPattern>org.apache.flink.cdc.models.openai.shaded.org.jetbrains.annotations</shadedPattern>
+ </relocation>
+ </relocations>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/ErrorHandlingStrategy.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/ErrorHandlingStrategy.java
new file mode 100644
index 000000000..e4cf37c70
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/ErrorHandlingStrategy.java
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+/** Strategy for handling model request failures. */
+public enum ErrorHandlingStrategy {
+ RETRY,
+ FAILOVER,
+ IGNORE
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClient.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClient.java
new file mode 100644
index 000000000..5a1e9041c
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClient.java
@@ -0,0 +1,358 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.model.AiModelClient;
+import org.apache.flink.cdc.common.model.abilities.SupportsEmbedding;
+import org.apache.flink.cdc.common.model.abilities.SupportsTextGeneration;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.openai.client.OpenAIClient;
+import com.openai.client.okhttp.OpenAIOkHttpClient;
+import com.openai.core.JsonValue;
+import com.openai.errors.OpenAIIoException;
+import com.openai.errors.OpenAIRetryableException;
+import com.openai.errors.OpenAIServiceException;
+import com.openai.models.chat.completions.ChatCompletion;
+import com.openai.models.chat.completions.ChatCompletionContentPart;
+import com.openai.models.chat.completions.ChatCompletionContentPartImage;
+import com.openai.models.chat.completions.ChatCompletionCreateParams;
+import com.openai.models.embeddings.CreateEmbeddingResponse;
+import com.openai.models.embeddings.Embedding;
+import com.openai.models.embeddings.EmbeddingCreateParams;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/** AI model client that connects to an OpenAI-compatible endpoint. */
+public class OpenAiCompatibleModelClient
+ implements AiModelClient, SupportsTextGeneration, SupportsEmbedding {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(OpenAiCompatibleModelClient.class);
+
+ private static final long serialVersionUID = 1L;
+
+ private final String endpoint;
+ private final String apiKey;
+ private final String model;
+ @Nullable private final String configuredSystemPrompt;
+ private final OpenAiRequestParams params;
+
+ private transient OpenAIClient client;
+ private transient Map<String, List<String>> additionalHeaders;
+ private transient Map<String, JsonValue> additionalBody;
+
+ OpenAiCompatibleModelClient(
+ String endpoint,
+ String apiKey,
+ String model,
+ @Nullable String configuredSystemPrompt,
+ OpenAiRequestParams params) {
+ this.endpoint = endpoint;
+ this.apiKey = apiKey;
+ this.model = model;
+ this.configuredSystemPrompt = configuredSystemPrompt;
+ this.params = params;
+ }
+
+ @Override
+ public void open() {
+ additionalHeaders = parseHeaders(params.extraHeader);
+ additionalBody = parseBody(params.extraBody);
+ client =
+
OpenAIOkHttpClient.builder().baseUrl(endpoint).apiKey(apiKey).maxRetries(0).build();
+ LOG.info("Opened OpenAI-compatible model client. Endpoint: {} Model:
{}", endpoint, model);
+ }
+
+ @Override
+ public void close() {
+ if (client != null) {
+ client.close();
+ client = null;
+ }
+ }
+
+ @Override
+ public String generate(String systemPrompt, String userInput) {
+ return executeWithRetry("text completion", () ->
complete(systemPrompt, userInput));
+ }
+
+ @Override
+ public float[] embed(String text) {
+ return executeWithRetry("embedding", () -> createEmbedding(text));
+ }
+
+ private String complete(String systemPrompt, String userInput) {
+ ChatCompletionCreateParams.Builder builder =
+ ChatCompletionCreateParams.builder().model(model);
+ String effectiveSystemPrompt =
+ configuredSystemPrompt == null
+ ? systemPrompt
+ : configuredSystemPrompt + "\n" + systemPrompt;
+ builder.addSystemMessage(effectiveSystemPrompt);
+ if (params.contentType ==
OpenAiCompatibleModelOptions.ContentType.IMAGE_URL) {
+ ChatCompletionContentPartImage.ImageUrl imageUrl =
+
ChatCompletionContentPartImage.ImageUrl.builder().url(userInput).build();
+ ChatCompletionContentPartImage image =
+
ChatCompletionContentPartImage.builder().imageUrl(imageUrl).build();
+ builder.addUserMessageOfArrayOfContentParts(
+
Collections.singletonList(ChatCompletionContentPart.ofImageUrl(image)));
+ } else {
+ builder.addUserMessage(userInput);
+ }
+ if (params.userPrompt != null) {
+ builder.addUserMessage(params.userPrompt);
+ }
+ applyCompletionParams(builder);
+ builder.putAllAdditionalHeaders(headersOrEmpty());
+ builder.putAllAdditionalBodyProperties(bodyOrEmpty());
+
+ ChatCompletion completion =
currentClient().chat().completions().create(builder.build());
+ if (completion.choices().isEmpty()) {
+ throw new IllegalStateException(
+ "OpenAI-compatible text completion returned no choices.");
+ }
+ return completion
+ .choices()
+ .get(0)
+ .message()
+ .content()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "OpenAI-compatible text completion
returned no text content."));
+ }
+
+ private void applyCompletionParams(ChatCompletionCreateParams.Builder
builder) {
+ if (params.temperature != null) {
+ builder.temperature(params.temperature);
+ }
+ if (params.topP != null) {
+ builder.topP(params.topP);
+ }
+ if (params.stop != null) {
+ builder.stop(params.stop);
+ }
+ if (params.maxTokens != null) {
+ builder.maxTokens(params.maxTokens);
+ }
+ if (params.presencePenalty != null) {
+ builder.presencePenalty(params.presencePenalty);
+ }
+ if (params.frequencyPenalty != null) {
+ builder.frequencyPenalty(params.frequencyPenalty);
+ }
+ if (params.n != null) {
+ builder.n(params.n);
+ }
+ if (params.seed != null) {
+ builder.seed(params.seed);
+ }
+ if (params.responseFormat != null) {
+ builder.responseFormat(params.responseFormat.toResponseFormat());
+ }
+ }
+
+ private float[] createEmbedding(String text) {
+ EmbeddingCreateParams.Builder builder =
+ EmbeddingCreateParams.builder().model(model).input(text);
+ if (params.dimension != null) {
+ builder.dimensions(params.dimension);
+ }
+ builder.putAllAdditionalHeaders(headersOrEmpty());
+ builder.putAllAdditionalBodyProperties(bodyOrEmpty());
+
+ CreateEmbeddingResponse response =
currentClient().embeddings().create(builder.build());
+ List<Embedding> data = response.data();
+ if (data.isEmpty()) {
+ return new float[0];
+ }
+ List<Float> embedding = data.get(0).embedding();
+ float[] result = new float[embedding.size()];
+ for (int i = 0; i < result.length; i++) {
+ result[i] = embedding.get(i);
+ }
+ return result;
+ }
+
+ private <T> T executeWithRetry(String operation, Supplier<T> action) {
+ int maximumAttempts =
+ params.errorHandlingStrategy == ErrorHandlingStrategy.RETRY ?
params.retryNum : 1;
+ long intervalMillis = params.retryBackoffBaseIntervalMillis;
+ RuntimeException lastException = null;
+ int attempts = 0;
+ for (; attempts < maximumAttempts; attempts++) {
+ try {
+ return action.get();
+ } catch (RuntimeException e) {
+ lastException = e;
+ boolean hasAnotherAttempt = attempts + 1 < maximumAttempts;
+ if (!hasAnotherAttempt || !isRetryable(e)) {
+ attempts++;
+ break;
+ }
+ LOG.warn(
+ "OpenAI-compatible {} request failed on attempt {}.
Retrying in {} ms. Cause: {}",
+ operation,
+ attempts + 1,
+ intervalMillis,
+ e.toString());
+ sleepBeforeRetry(intervalMillis);
+ intervalMillis =
params.retryBackoffStrategy.nextInterval(intervalMillis);
+ }
+ }
+
+ ErrorHandlingStrategy finalStrategy =
+ params.errorHandlingStrategy == ErrorHandlingStrategy.RETRY
+ ? params.retryFallbackStrategy
+ : params.errorHandlingStrategy;
+ if (finalStrategy == ErrorHandlingStrategy.IGNORE) {
+ LOG.warn(
+ "OpenAI-compatible {} request failed after {} attempt(s).
Ignoring the input. Cause: {}",
+ operation,
+ attempts,
+ lastException);
+ return null;
+ }
+ throw new RuntimeException(
+ String.format(
+ "OpenAI-compatible %s request failed after %s
attempt(s).",
+ operation, attempts),
+ lastException);
+ }
+
+ private static void sleepBeforeRetry(long intervalMillis) {
+ try {
+ Thread.sleep(intervalMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while backing off an
OpenAI request.", e);
+ }
+ }
+
+ private static boolean isRetryable(RuntimeException exception) {
+ if (hasThrowable(exception, IOException.class)
+ || hasThrowable(exception, OpenAIIoException.class)
+ || hasThrowable(exception, OpenAIRetryableException.class)) {
+ return true;
+ }
+ for (Throwable throwable = exception; throwable != null; throwable =
throwable.getCause()) {
+ if (throwable instanceof OpenAIServiceException) {
+ int statusCode = ((OpenAIServiceException)
throwable).statusCode();
+ return statusCode == 408
+ || statusCode == 409
+ || statusCode == 429
+ || (statusCode >= 500 && statusCode < 600);
+ }
+ }
+ return false;
+ }
+
+ private OpenAIClient currentClient() {
+ if (client == null) {
+ throw new IllegalStateException("OpenAI-compatible model client
has not been opened.");
+ }
+ return client;
+ }
+
+ private Map<String, List<String>> headersOrEmpty() {
+ return additionalHeaders == null ? Collections.emptyMap() :
additionalHeaders;
+ }
+
+ private Map<String, JsonValue> bodyOrEmpty() {
+ return additionalBody == null ? Collections.emptyMap() :
additionalBody;
+ }
+
+ private static Map<String, List<String>> parseHeaders(@Nullable String
headerJson) {
+ if (headerJson == null || headerJson.trim().isEmpty()) {
+ return Collections.emptyMap();
+ }
+ JsonNode root = parseJsonObject(headerJson, "extra-header");
+ Map<String, List<String>> headers = new HashMap<>();
+ for (Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
fields.hasNext(); ) {
+ Map.Entry<String, JsonNode> field = fields.next();
+ List<String> values = new ArrayList<>();
+ if (field.getValue().isArray()) {
+ for (JsonNode value : field.getValue()) {
+ values.add(headerValue(field.getKey(), value));
+ }
+ } else {
+ values.add(headerValue(field.getKey(), field.getValue()));
+ }
+ headers.put(field.getKey(), values);
+ }
+ return headers;
+ }
+
+ private static String headerValue(String name, JsonNode value) {
+ if (!value.isValueNode() || value.isNull()) {
+ throw new IllegalArgumentException(
+ String.format("Header '%s' must contain a scalar JSON
value.", name));
+ }
+ return value.asText();
+ }
+
+ private static Map<String, JsonValue> parseBody(@Nullable String bodyJson)
{
+ if (bodyJson == null || bodyJson.trim().isEmpty()) {
+ return Collections.emptyMap();
+ }
+ JsonNode root = parseJsonObject(bodyJson, "extra-body");
+ Map<String, JsonValue> body = new HashMap<>();
+ for (Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
fields.hasNext(); ) {
+ Map.Entry<String, JsonNode> field = fields.next();
+ body.put(field.getKey(), JsonValue.fromJsonNode(field.getValue()));
+ }
+ return body;
+ }
+
+ private static JsonNode parseJsonObject(String json, String option) {
+ try {
+ JsonNode root = new ObjectMapper().readTree(json);
+ if (root == null || !root.isObject()) {
+ throw new IllegalArgumentException(
+ String.format("Option '%s' must be a JSON object.",
option));
+ }
+ return root;
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException(
+ String.format("Option '%s' contains invalid JSON.",
option), e);
+ }
+ }
+
+ private static boolean hasThrowable(
+ Throwable exception, Class<? extends Throwable> targetClass) {
+ for (Throwable throwable = exception; throwable != null; throwable =
throwable.getCause()) {
+ if (targetClass.isInstance(throwable)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactory.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactory.java
new file mode 100644
index 000000000..576488b43
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactory.java
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.factories.FactoryHelper;
+import org.apache.flink.cdc.common.model.AiModelClient;
+import org.apache.flink.cdc.common.model.AiModelClientFactory;
+import org.apache.flink.cdc.common.model.ModelContext;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** SPI factory for {@link OpenAiCompatibleModelClient}. */
+public class OpenAiCompatibleModelClientFactory implements
AiModelClientFactory {
+
+ private static final Set<ConfigOption<?>> REQUIRED_OPTIONS =
+ Set.of(
+ OpenAiCompatibleModelOptions.MODEL,
+ OpenAiCompatibleModelOptions.ENDPOINT,
+ OpenAiCompatibleModelOptions.API_KEY);
+
+ private static final Set<ConfigOption<?>> OPTIONAL_OPTIONS =
+ OpenAiCompatibleModelOptions.ALL_OPTIONS.stream()
+ .filter(option -> !REQUIRED_OPTIONS.contains(option))
+ .collect(Collectors.toSet());
+
+ @Override
+ public String identifier() {
+ return "openai-compatible";
+ }
+
+ @Override
+ public Set<ConfigOption<?>> requiredOptions() {
+ return REQUIRED_OPTIONS;
+ }
+
+ @Override
+ public Set<ConfigOption<?>> optionalOptions() {
+ return OPTIONAL_OPTIONS;
+ }
+
+ @Override
+ public AiModelClient createClient(ModelContext context) {
+ validate(context);
+ Configuration options = Configuration.fromMap(context.getOptions());
+ String endpoint =
+
requireNonBlank(options.get(OpenAiCompatibleModelOptions.ENDPOINT), "endpoint");
+ String apiKey =
+
requireNonBlank(options.get(OpenAiCompatibleModelOptions.API_KEY), "api-key");
+ String model =
requireNonBlank(options.get(OpenAiCompatibleModelOptions.MODEL), "model");
+ String systemPrompt =
options.get(OpenAiCompatibleModelOptions.SYSTEM_PROMPT);
+ OpenAiRequestParams params = OpenAiRequestParams.fromOptions(options);
+ return new OpenAiCompatibleModelClient(endpoint, apiKey, model,
systemPrompt, params);
+ }
+
+ void validate(ModelContext context) {
+ FactoryHelper.createFactoryHelper(
+ this,
+ new FactoryHelper.DefaultContext(
+ Configuration.fromMap(context.getOptions()),
+ new Configuration(),
+ context.getClassLoader()))
+ .validate();
+ }
+
+ private static String requireNonBlank(String value, String option) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ String.format("Option '%s' must not be blank.", option));
+ }
+ return value;
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelOptions.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelOptions.java
new file mode 100644
index 000000000..3f71787cf
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelOptions.java
@@ -0,0 +1,225 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.configuration.ConfigOptions;
+
+import com.openai.models.ResponseFormatJsonObject;
+import com.openai.models.ResponseFormatText;
+import com.openai.models.chat.completions.ChatCompletionCreateParams;
+
+import java.time.Duration;
+import java.util.Set;
+
+/** Config options accepted by the {@code openai-compatible} pipeline model. */
+public class OpenAiCompatibleModelOptions {
+
+ public static final ConfigOption<String> MODEL =
+ ConfigOptions.key("model")
+ .stringType()
+ .noDefaultValue()
+ .withDeprecatedKeys("model-name")
+ .withDescription("Name of the model to invoke.");
+
+ public static final ConfigOption<String> ENDPOINT =
+ ConfigOptions.key("endpoint")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Base URL of the OpenAI-compatible
endpoint.");
+
+ public static final ConfigOption<String> API_KEY =
+ ConfigOptions.key("api-key")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("API key used to authenticate against the
endpoint.");
+
+ public static final ConfigOption<String> SYSTEM_PROMPT =
+ ConfigOptions.key("system-prompt")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("System prompt prepended to every text
completion request.");
+
+ public static final ConfigOption<String> USER_PROMPT =
+ ConfigOptions.key("user-prompt")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Additional user prompt appended after
the input.");
+
+ public static final ConfigOption<Double> TEMPERATURE =
+ ConfigOptions.key("temperature")
+ .doubleType()
+ .noDefaultValue()
+ .withDescription("Sampling temperature.");
+
+ public static final ConfigOption<Double> TOP_P =
+ ConfigOptions.key("top-p")
+ .doubleType()
+ .noDefaultValue()
+ .withDescription("Nucleus sampling probability mass.");
+
+ public static final ConfigOption<String> STOP =
+ ConfigOptions.key("stop")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Stop sequence that ends generation.");
+
+ public static final ConfigOption<Integer> MAX_TOKENS =
+ ConfigOptions.key("max-tokens")
+ .intType()
+ .noDefaultValue()
+ .withDescription("Maximum number of tokens to generate.");
+
+ public static final ConfigOption<Double> PRESENCE_PENALTY =
+ ConfigOptions.key("presence-penalty")
+ .doubleType()
+ .noDefaultValue()
+ .withDescription("Presence penalty applied during
generation.");
+
+ public static final ConfigOption<Double> FREQUENCY_PENALTY =
+ ConfigOptions.key("frequency-penalty")
+ .doubleType()
+ .noDefaultValue()
+ .withDescription("Frequency penalty applied during
generation.");
+
+ public static final ConfigOption<Long> N =
+ ConfigOptions.key("n")
+ .longType()
+ .noDefaultValue()
+ .withDescription("Number of chat completion choices to
generate.");
+
+ public static final ConfigOption<Long> SEED =
+ ConfigOptions.key("seed")
+ .longType()
+ .noDefaultValue()
+ .withDescription("Seed for deterministic sampling.");
+
+ public static final ConfigOption<ResponseFormat> RESPONSE_FORMAT =
+ ConfigOptions.key("response-format")
+ .enumType(ResponseFormat.class)
+ .noDefaultValue()
+ .withDescription("Response format of the chat
completion.");
+
+ public static final ConfigOption<ContentType> CONTENT_TYPE =
+ ConfigOptions.key("content-type")
+ .enumType(ContentType.class)
+ .defaultValue(ContentType.TEXT)
+ .withDescription("Content type of the model input.");
+
+ public static final ConfigOption<String> EXTRA_HEADER =
+ ConfigOptions.key("extra-header")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Additional HTTP headers as a JSON
object.");
+
+ public static final ConfigOption<String> EXTRA_BODY =
+ ConfigOptions.key("extra-body")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Additional request-body properties as a
JSON object.");
+
+ public static final ConfigOption<Integer> DIMENSION =
+ ConfigOptions.key("dimension")
+ .intType()
+ .noDefaultValue()
+ .withDescription("Number of dimensions for the embedding
output.");
+
+ public static final ConfigOption<ErrorHandlingStrategy>
ERROR_HANDLING_STRATEGY =
+ ConfigOptions.key("error-handling-strategy")
+ .enumType(ErrorHandlingStrategy.class)
+ .defaultValue(ErrorHandlingStrategy.RETRY)
+ .withDescription("Strategy applied when a request fails.");
+
+ public static final ConfigOption<Integer> RETRY_NUM =
+ ConfigOptions.key("retry-num")
+ .intType()
+ .defaultValue(100)
+ .withDescription("Maximum number of attempts when retry is
enabled.");
+
+ public static final ConfigOption<ErrorHandlingStrategy>
RETRY_FALLBACK_STRATEGY =
+ ConfigOptions.key("retry-fallback-strategy")
+ .enumType(ErrorHandlingStrategy.class)
+ .defaultValue(ErrorHandlingStrategy.FAILOVER)
+ .withDescription("Strategy applied after retries are
exhausted.");
+
+ public static final ConfigOption<RetryBackoffStrategy>
RETRY_BACKOFF_STRATEGY =
+ ConfigOptions.key("retry-backoff-strategy")
+ .enumType(RetryBackoffStrategy.class)
+ .defaultValue(RetryBackoffStrategy.FIXED)
+ .withDescription("Backoff strategy between retry
attempts.");
+
+ public static final ConfigOption<Duration> RETRY_BACKOFF_BASE_INTERVAL =
+ ConfigOptions.key("retry-backoff-base-interval")
+ .durationType()
+ .defaultValue(Duration.ofSeconds(1))
+ .withDescription("Base interval between retry attempts.");
+
+ public static final Set<ConfigOption<?>> ALL_OPTIONS =
+ Set.of(
+ MODEL,
+ ENDPOINT,
+ API_KEY,
+ SYSTEM_PROMPT,
+ USER_PROMPT,
+ TEMPERATURE,
+ TOP_P,
+ STOP,
+ MAX_TOKENS,
+ PRESENCE_PENALTY,
+ FREQUENCY_PENALTY,
+ N,
+ SEED,
+ RESPONSE_FORMAT,
+ CONTENT_TYPE,
+ EXTRA_HEADER,
+ EXTRA_BODY,
+ DIMENSION,
+ ERROR_HANDLING_STRATEGY,
+ RETRY_NUM,
+ RETRY_FALLBACK_STRATEGY,
+ RETRY_BACKOFF_STRATEGY,
+ RETRY_BACKOFF_BASE_INTERVAL);
+
+ /** Format of a text completion response. */
+ public enum ResponseFormat {
+ TEXT {
+ @Override
+ ChatCompletionCreateParams.ResponseFormat toResponseFormat() {
+ return ChatCompletionCreateParams.ResponseFormat.ofText(
+ ResponseFormatText.builder().build());
+ }
+ },
+ JSON_OBJECT {
+ @Override
+ ChatCompletionCreateParams.ResponseFormat toResponseFormat() {
+ return ChatCompletionCreateParams.ResponseFormat.ofJsonObject(
+ ResponseFormatJsonObject.builder().build());
+ }
+ };
+
+ abstract ChatCompletionCreateParams.ResponseFormat toResponseFormat();
+ }
+
+ /** Content type of the user input. */
+ public enum ContentType {
+ TEXT,
+ IMAGE_URL
+ }
+
+ private OpenAiCompatibleModelOptions() {}
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiRequestParams.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiRequestParams.java
new file mode 100644
index 000000000..cfb5bd967
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/OpenAiRequestParams.java
@@ -0,0 +1,194 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.time.Duration;
+
+/** Parsed request and retry parameters for the OpenAI-compatible model
client. */
+class OpenAiRequestParams implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Nullable final String userPrompt;
+ @Nullable final Double temperature;
+ @Nullable final Double topP;
+ @Nullable final String stop;
+ @Nullable final Integer maxTokens;
+ @Nullable final Double presencePenalty;
+ @Nullable final Double frequencyPenalty;
+ @Nullable final Long n;
+ @Nullable final Long seed;
+ @Nullable final OpenAiCompatibleModelOptions.ResponseFormat responseFormat;
+ final OpenAiCompatibleModelOptions.ContentType contentType;
+ @Nullable final String extraHeader;
+ @Nullable final String extraBody;
+ @Nullable final Integer dimension;
+ final ErrorHandlingStrategy errorHandlingStrategy;
+ final int retryNum;
+ final ErrorHandlingStrategy retryFallbackStrategy;
+ final RetryBackoffStrategy retryBackoffStrategy;
+ final long retryBackoffBaseIntervalMillis;
+
+ private OpenAiRequestParams(
+ @Nullable String userPrompt,
+ @Nullable Double temperature,
+ @Nullable Double topP,
+ @Nullable String stop,
+ @Nullable Integer maxTokens,
+ @Nullable Double presencePenalty,
+ @Nullable Double frequencyPenalty,
+ @Nullable Long n,
+ @Nullable Long seed,
+ @Nullable OpenAiCompatibleModelOptions.ResponseFormat
responseFormat,
+ OpenAiCompatibleModelOptions.ContentType contentType,
+ @Nullable String extraHeader,
+ @Nullable String extraBody,
+ @Nullable Integer dimension,
+ ErrorHandlingStrategy errorHandlingStrategy,
+ int retryNum,
+ ErrorHandlingStrategy retryFallbackStrategy,
+ RetryBackoffStrategy retryBackoffStrategy,
+ long retryBackoffBaseIntervalMillis) {
+ this.userPrompt = userPrompt;
+ this.temperature = temperature;
+ this.topP = topP;
+ this.stop = stop;
+ this.maxTokens = maxTokens;
+ this.presencePenalty = presencePenalty;
+ this.frequencyPenalty = frequencyPenalty;
+ this.n = n;
+ this.seed = seed;
+ this.responseFormat = responseFormat;
+ this.contentType = contentType;
+ this.extraHeader = extraHeader;
+ this.extraBody = extraBody;
+ this.dimension = dimension;
+ this.errorHandlingStrategy = errorHandlingStrategy;
+ this.retryNum = retryNum;
+ this.retryFallbackStrategy = retryFallbackStrategy;
+ this.retryBackoffStrategy = retryBackoffStrategy;
+ this.retryBackoffBaseIntervalMillis = retryBackoffBaseIntervalMillis;
+ }
+
+ static OpenAiRequestParams fromOptions(Configuration options) {
+ String userPrompt =
options.get(OpenAiCompatibleModelOptions.USER_PROMPT);
+ Double temperature =
options.get(OpenAiCompatibleModelOptions.TEMPERATURE);
+ Double topP = options.get(OpenAiCompatibleModelOptions.TOP_P);
+ String stop = options.get(OpenAiCompatibleModelOptions.STOP);
+ Integer maxTokens =
options.get(OpenAiCompatibleModelOptions.MAX_TOKENS);
+ Double presencePenalty =
options.get(OpenAiCompatibleModelOptions.PRESENCE_PENALTY);
+ Double frequencyPenalty =
options.get(OpenAiCompatibleModelOptions.FREQUENCY_PENALTY);
+ Long n = options.get(OpenAiCompatibleModelOptions.N);
+ Long seed = options.get(OpenAiCompatibleModelOptions.SEED);
+ OpenAiCompatibleModelOptions.ResponseFormat responseFormat =
+ options.get(OpenAiCompatibleModelOptions.RESPONSE_FORMAT);
+ OpenAiCompatibleModelOptions.ContentType contentType =
+ options.get(OpenAiCompatibleModelOptions.CONTENT_TYPE);
+ String extraHeader =
options.get(OpenAiCompatibleModelOptions.EXTRA_HEADER);
+ String extraBody =
options.get(OpenAiCompatibleModelOptions.EXTRA_BODY);
+ Integer dimension =
options.get(OpenAiCompatibleModelOptions.DIMENSION);
+ ErrorHandlingStrategy errorHandlingStrategy =
+
options.get(OpenAiCompatibleModelOptions.ERROR_HANDLING_STRATEGY);
+ int retryNum = options.get(OpenAiCompatibleModelOptions.RETRY_NUM);
+ ErrorHandlingStrategy retryFallbackStrategy =
+
options.get(OpenAiCompatibleModelOptions.RETRY_FALLBACK_STRATEGY);
+ RetryBackoffStrategy retryBackoffStrategy =
+
options.get(OpenAiCompatibleModelOptions.RETRY_BACKOFF_STRATEGY);
+ long retryBackoffBaseIntervalMillis =
+
options.get(OpenAiCompatibleModelOptions.RETRY_BACKOFF_BASE_INTERVAL).toMillis();
+
+ validateRange("temperature", temperature, 0.0d, 2.0d);
+ validateRange("top-p", topP, 0.0d, 1.0d);
+ validateRange("presence-penalty", presencePenalty, -2.0d, 2.0d);
+ validateRange("frequency-penalty", frequencyPenalty, -2.0d, 2.0d);
+ validatePositive("max-tokens", maxTokens);
+ validatePositive("n", n);
+ validatePositive("dimension", dimension);
+ if (responseFormat ==
OpenAiCompatibleModelOptions.ResponseFormat.TEXT) {
+ throw new IllegalArgumentException(
+ "Only 'json_object' is supported for option
'response-format', because the "
+ + "built-in AI completion function parses model
output as JSON.");
+ }
+ if (retryNum < 1) {
+ throw new IllegalArgumentException("Option 'retry-num' must be at
least 1.");
+ }
+ if (retryFallbackStrategy == ErrorHandlingStrategy.RETRY) {
+ throw new IllegalArgumentException("Option
'retry-fallback-strategy' cannot be retry.");
+ }
+ if (retryBackoffBaseIntervalMillis < 0) {
+ throw new IllegalArgumentException(
+ "Option 'retry-backoff-base-interval' cannot be
negative.");
+ }
+ validateRetryDelay(retryBackoffStrategy,
retryBackoffBaseIntervalMillis, retryNum);
+
+ return new OpenAiRequestParams(
+ userPrompt,
+ temperature,
+ topP,
+ stop,
+ maxTokens,
+ presencePenalty,
+ frequencyPenalty,
+ n,
+ seed,
+ responseFormat,
+ contentType,
+ extraHeader,
+ extraBody,
+ dimension,
+ errorHandlingStrategy,
+ retryNum,
+ retryFallbackStrategy,
+ retryBackoffStrategy,
+ retryBackoffBaseIntervalMillis);
+ }
+
+ private static void validateRange(
+ String option, @Nullable Double value, double minimum, double
maximum) {
+ if (value != null && (value < minimum || value > maximum)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Option '%s' must be between %s and %s.", option,
minimum, maximum));
+ }
+ }
+
+ private static void validatePositive(String option, @Nullable Number
value) {
+ if (value != null && value.longValue() < 1) {
+ throw new IllegalArgumentException(
+ String.format("Option '%s' must be at least 1.", option));
+ }
+ }
+
+ private static void validateRetryDelay(
+ RetryBackoffStrategy strategy, long baseIntervalMillis, int
retryNum) {
+ try {
+ strategy.minimumTotalDelay(baseIntervalMillis, retryNum);
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Total retry delay is too large. Base interval:
%s, strategy: %s, attempts: %s.",
+ Duration.ofMillis(baseIntervalMillis), strategy,
retryNum),
+ e);
+ }
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/RetryBackoffStrategy.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/RetryBackoffStrategy.java
new file mode 100644
index 000000000..9a6c5307d
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/java/org/apache/flink/cdc/models/openai/RetryBackoffStrategy.java
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+/** Strategy for calculating the delay between retry attempts. */
+public enum RetryBackoffStrategy {
+ FIXED {
+ @Override
+ long nextInterval(long currentIntervalMillis) {
+ return currentIntervalMillis;
+ }
+
+ @Override
+ long minimumTotalDelay(long baseIntervalMillis, int attempts) {
+ return Math.multiplyExact(baseIntervalMillis, Math.max(0, attempts
- 1));
+ }
+ },
+
+ EXPONENTIAL {
+ @Override
+ long nextInterval(long currentIntervalMillis) {
+ return Math.multiplyExact(currentIntervalMillis, 2L);
+ }
+
+ @Override
+ long minimumTotalDelay(long baseIntervalMillis, int attempts) {
+ int delays = Math.max(0, attempts - 1);
+ if (delays >= Long.SIZE - 1) {
+ throw new ArithmeticException("Too many exponential retry
attempts");
+ }
+ long multiplier = Math.subtractExact(1L << delays, 1L);
+ return Math.multiplyExact(baseIntervalMillis, multiplier);
+ }
+ };
+
+ abstract long nextInterval(long currentIntervalMillis);
+
+ abstract long minimumTotalDelay(long baseIntervalMillis, int attempts);
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/NOTICE
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/NOTICE
new file mode 100644
index 000000000..b5ee44f11
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,34 @@
+flink-cdc-pipeline-model-openai-compatible
+Copyright 2024-2026 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+This project bundles the following dependencies under the Apache Software
License 2.0
+(https://www.apache.org/licenses/LICENSE-2.0):
+
+- com.openai:openai-java:4.32.0
+- com.openai:openai-java-client-okhttp:4.32.0
+- com.openai:openai-java-core:4.32.0
+- com.fasterxml.jackson.core:jackson-annotations:2.18.2
+- com.fasterxml.jackson.core:jackson-core:2.18.2
+- com.fasterxml.jackson.core:jackson-databind:2.18.2
+- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2
+- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2
+- com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2
+- com.fasterxml:classmate:1.7.0
+- com.github.victools:jsonschema-generator:4.38.0
+- com.github.victools:jsonschema-module-jackson:4.38.0
+- com.github.victools:jsonschema-module-swagger-2:4.38.0
+- com.google.errorprone:error_prone_annotations:2.33.0
+- com.squareup.okhttp3:logging-interceptor:4.12.0
+- com.squareup.okhttp3:okhttp:4.12.0
+- com.squareup.okio:okio:3.6.0
+- com.squareup.okio:okio-jvm:3.6.0
+- io.swagger.core.v3:swagger-annotations:2.2.31
+- org.jetbrains:annotations:13.0
+- org.jetbrains.kotlin:kotlin-stdlib:1.8.0
+- org.jetbrains.kotlin:kotlin-stdlib-common:1.8.0
+- org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0
+- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0
+- org.jetbrains.kotlin:kotlin-reflect:1.8.10
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
new file mode 100644
index 000000000..017156db4
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
@@ -0,0 +1,16 @@
+# 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.
+
+org.apache.flink.cdc.models.openai.OpenAiCompatibleModelClientFactory
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactoryTest.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactoryTest.java
new file mode 100644
index 000000000..9265683d9
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientFactoryTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.factories.Factory;
+import org.apache.flink.cdc.common.model.AiModelClient;
+import org.apache.flink.cdc.common.model.ModelContext;
+import org.apache.flink.cdc.common.model.abilities.SupportsEmbedding;
+import org.apache.flink.cdc.common.model.abilities.SupportsTextGeneration;
+import org.apache.flink.table.api.ValidationException;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.stream.StreamSupport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link OpenAiCompatibleModelClientFactory}. */
+class OpenAiCompatibleModelClientFactoryTest {
+
+ private final OpenAiCompatibleModelClientFactory factory =
+ new OpenAiCompatibleModelClientFactory();
+
+ @Test
+ void testIdentifierAndOptions() {
+ assertThat(factory.identifier()).isEqualTo("openai-compatible");
+ assertThat(factory.requiredOptions())
+ .extracting(option -> option.key())
+ .containsExactlyInAnyOrder("model", "endpoint", "api-key");
+ assertThat(factory.optionalOptions())
+ .extracting(option -> option.key())
+ .contains(
+ "system-prompt",
+ "temperature",
+ "top-p",
+ "retry-num",
+ "retry-backoff-strategy",
+ "dimension")
+ .doesNotContain("model", "endpoint", "api-key");
+ }
+
+ @Test
+ void testFactoryIsDiscoverable() {
+ assertThat(
+
StreamSupport.stream(ServiceLoader.load(Factory.class).spliterator(), false)
+
.filter(OpenAiCompatibleModelClientFactory.class::isInstance))
+ .hasSize(1);
+ }
+
+ @Test
+ void testCreateClient() {
+ AiModelClient client = factory.createClient(context(validOptions()));
+
+ assertThat(client)
+ .isInstanceOf(OpenAiCompatibleModelClient.class)
+ .isInstanceOf(SupportsTextGeneration.class)
+ .isInstanceOf(SupportsEmbedding.class);
+ }
+
+ @Test
+ void testDeprecatedModelNameAlias() {
+ Map<String, String> options = validOptions();
+ options.put("model-name", options.remove("model"));
+
+ assertThat(factory.createClient(context(options)))
+ .isInstanceOf(OpenAiCompatibleModelClient.class);
+ }
+
+ @Test
+ void testMissingRequiredOptionIsRejected() {
+ Map<String, String> options = validOptions();
+ options.remove("api-key");
+
+ assertThatThrownBy(() -> factory.createClient(context(options)))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("required options")
+ .hasMessageContaining("api-key");
+ }
+
+ @Test
+ void testUnknownOptionIsRejected() {
+ Map<String, String> options = validOptions();
+ options.put("unknown-option", "value");
+
+ assertThatThrownBy(() -> factory.createClient(context(options)))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("Unsupported options")
+ .hasMessageContaining("unknown-option");
+ }
+
+ @Test
+ void testBlankOptionIsRejected() {
+ Map<String, String> options = validOptions();
+ options.put("endpoint", " ");
+
+ assertThatThrownBy(() -> factory.createClient(context(options)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("endpoint")
+ .hasMessageContaining("must not be blank");
+ }
+
+ private static Map<String, String> validOptions() {
+ Map<String, String> options = new HashMap<>();
+ options.put("endpoint", "https://api.example.com/v1");
+ options.put("api-key", "sk-test");
+ options.put("model", "gpt-test");
+ return options;
+ }
+
+ private static ModelContext context(Map<String, String> options) {
+ return new ModelContext() {
+ @Override
+ public String getModelName() {
+ return "test-model";
+ }
+
+ @Override
+ public Map<String, String> getOptions() {
+ return options;
+ }
+
+ @Override
+ public ClassLoader getClassLoader() {
+ return Thread.currentThread().getContextClassLoader();
+ }
+ };
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientTest.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientTest.java
new file mode 100644
index 000000000..e8352a94e
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiCompatibleModelClientTest.java
@@ -0,0 +1,292 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.model.ModelContext;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** HTTP-level tests for {@link OpenAiCompatibleModelClient}. */
+class OpenAiCompatibleModelClientTest {
+
+ private static final String COMPLETION_RESPONSE =
+ "{"
+ + "\"id\":\"chatcmpl-test\","
+ + "\"object\":\"chat.completion\","
+ + "\"created\":123,"
+ + "\"model\":\"test-model\","
+ + "\"choices\":[{"
+ + "\"index\":0,"
+ + "\"message\":{\"role\":\"assistant\","
+ + "\"content\":\"{\\\"result\\\":\\\"done\\\"}\"},"
+ + "\"finish_reason\":\"stop\"}],"
+ +
"\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":3,\"total_tokens\":8}"
+ + "}";
+
+ private static final String EMBEDDING_RESPONSE =
+ "{"
+ + "\"object\":\"list\","
+ + "\"data\":[{\"object\":\"embedding\",\"index\":0,"
+ + "\"embedding\":[0.1,-0.2,0.3]}],"
+ + "\"model\":\"test-model\","
+ + "\"usage\":{\"prompt_tokens\":3,\"total_tokens\":3}"
+ + "}";
+
+ private static final String ERROR_RESPONSE =
+ "{\"error\":{\"message\":\"request failed\","
+ +
"\"type\":\"test_error\",\"param\":null,\"code\":\"test\"}}";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private MockWebServer server;
+ private OpenAiCompatibleModelClient client;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ if (client != null) {
+ client.close();
+ }
+ server.shutdown();
+ }
+
+ @Test
+ void testTextCompletionWithCommonRequestParameters() throws Exception {
+ server.enqueue(jsonResponse(200, COMPLETION_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("system-prompt", "configured prompt");
+ options.put("user-prompt", "follow up");
+ options.put("temperature", "0.7");
+ options.put("top-p", "0.8");
+ options.put("stop", "END");
+ options.put("max-tokens", "256");
+ options.put("presence-penalty", "1.2");
+ options.put("frequency-penalty", "0.4");
+ options.put("n", "2");
+ options.put("seed", "123");
+ options.put("response-format", "json_object");
+ options.put("extra-header", "{\"X-Test\":\"header-value\"}");
+ options.put("extra-body", "{\"vendor_flag\":true}");
+ client = createAndOpenClient(options);
+
+ assertThat(client.generate("runtime prompt", "input text"))
+ .isEqualTo("{\"result\":\"done\"}");
+
+ RecordedRequest request = server.takeRequest();
+ assertThat(request.getPath()).isEqualTo("/v1/chat/completions");
+ assertThat(request.getHeader("Authorization")).isEqualTo("Bearer
sk-test");
+ assertThat(request.getHeader("X-Test")).isEqualTo("header-value");
+ JsonNode body = objectMapper.readTree(request.getBody().readUtf8());
+ assertThat(body.path("model").asText()).isEqualTo("test-model");
+ assertThat(body.at("/messages/0/role").asText()).isEqualTo("system");
+ assertThat(body.at("/messages/0/content").asText())
+ .isEqualTo("configured prompt\nruntime prompt");
+ assertThat(body.at("/messages/1/content").asText()).isEqualTo("input
text");
+ assertThat(body.at("/messages/2/content").asText()).isEqualTo("follow
up");
+ assertThat(body.path("temperature").asDouble()).isEqualTo(0.7d);
+ assertThat(body.path("top_p").asDouble()).isEqualTo(0.8d);
+ assertThat(body.path("stop").asText()).isEqualTo("END");
+ assertThat(body.path("max_tokens").asInt()).isEqualTo(256);
+ assertThat(body.path("presence_penalty").asDouble()).isEqualTo(1.2d);
+ assertThat(body.path("frequency_penalty").asDouble()).isEqualTo(0.4d);
+ assertThat(body.path("n").asLong()).isEqualTo(2L);
+ assertThat(body.path("seed").asLong()).isEqualTo(123L);
+
assertThat(body.at("/response_format/type").asText()).isEqualTo("json_object");
+ assertThat(body.path("vendor_flag").asBoolean()).isTrue();
+ }
+
+ @Test
+ void testTextCompletionPreservesChinesePromptsAndInput() throws Exception {
+ server.enqueue(jsonResponse(200, COMPLETION_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("system-prompt", "你是一个简洁的助手。");
+ options.put("user-prompt", "请直接回答。");
+ client = createAndOpenClient(options);
+
+ assertThat(client.generate("总结输入内容",
"包含中文的输入文本")).isEqualTo("{\"result\":\"done\"}");
+
+ JsonNode body =
objectMapper.readTree(server.takeRequest().getBody().readUtf8());
+
assertThat(body.at("/messages/0/content").asText()).isEqualTo("你是一个简洁的助手。\n总结输入内容");
+
assertThat(body.at("/messages/1/content").asText()).isEqualTo("包含中文的输入文本");
+
assertThat(body.at("/messages/2/content").asText()).isEqualTo("请直接回答。");
+ }
+
+ @Test
+ void testImageUrlCompletion() throws Exception {
+ server.enqueue(jsonResponse(200, COMPLETION_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("content-type", "image_url");
+ client = createAndOpenClient(options);
+
+ assertThat(client.generate("describe the image",
"https://example.com/image.png"))
+ .contains("done");
+
+ JsonNode body =
objectMapper.readTree(server.takeRequest().getBody().readUtf8());
+
assertThat(body.at("/messages/1/content/0/type").asText()).isEqualTo("image_url");
+ assertThat(body.at("/messages/1/content/0/image_url/url").asText())
+ .isEqualTo("https://example.com/image.png");
+ }
+
+ @Test
+ void testTextEmbedding() throws Exception {
+ server.enqueue(jsonResponse(200, EMBEDDING_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("dimension", "3");
+ options.put("extra-header", "{\"X-Embedding\":\"enabled\"}");
+ options.put("extra-body", "{\"vendor_flag\":true}");
+ client = createAndOpenClient(options);
+
+ assertThat(client.embed("embed this")).containsExactly(0.1f, -0.2f,
0.3f);
+
+ RecordedRequest request = server.takeRequest();
+ assertThat(request.getPath()).isEqualTo("/v1/embeddings");
+ assertThat(request.getHeader("X-Embedding")).isEqualTo("enabled");
+ JsonNode body = objectMapper.readTree(request.getBody().readUtf8());
+ assertThat(body.path("model").asText()).isEqualTo("test-model");
+ assertThat(body.path("input").asText()).isEqualTo("embed this");
+ assertThat(body.path("dimensions").asInt()).isEqualTo(3);
+ assertThat(body.path("vendor_flag").asBoolean()).isTrue();
+ }
+
+ @Test
+ void testRetryableHttpErrorIsRetried() {
+ server.enqueue(jsonResponse(429, ERROR_RESPONSE));
+ server.enqueue(jsonResponse(200, COMPLETION_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("retry-num", "2");
+ options.put("retry-backoff-base-interval", "1 ms");
+ client = createAndOpenClient(options);
+
+ assertThat(client.generate("system", "input")).contains("done");
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ void testNonRetryableHttpErrorFailsImmediately() {
+ server.enqueue(jsonResponse(400, ERROR_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("retry-num", "3");
+ options.put("retry-backoff-base-interval", "1 ms");
+ client = createAndOpenClient(options);
+
+ assertThatThrownBy(() -> client.generate("system", "input"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("failed after 1 attempt");
+ assertThat(server.getRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testIgnoreFallbackReturnsNullAfterRetries() {
+ server.enqueue(jsonResponse(500, ERROR_RESPONSE));
+ server.enqueue(jsonResponse(500, ERROR_RESPONSE));
+ Map<String, String> options = baseOptions();
+ options.put("retry-num", "2");
+ options.put("retry-backoff-base-interval", "1 ms");
+ options.put("retry-fallback-strategy", "ignore");
+ client = createAndOpenClient(options);
+
+ assertThat(client.generate("system", "input")).isNull();
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ void testInvalidAdditionalHeadersFailOnOpen() {
+ Map<String, String> options = baseOptions();
+ options.put("extra-header", "not-json");
+ client = createClient(options);
+
+ assertThatThrownBy(client::open)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("extra-header")
+ .hasMessageContaining("invalid JSON");
+ }
+
+ @Test
+ void testRequestBeforeOpenFailsWithContext() {
+ client = createClient(baseOptions());
+
+ assertThatThrownBy(() -> client.embed("input"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("embedding request failed")
+ .hasRootCauseMessage("OpenAI-compatible model client has not
been opened.");
+ }
+
+ private OpenAiCompatibleModelClient createAndOpenClient(Map<String,
String> options) {
+ OpenAiCompatibleModelClient result = createClient(options);
+ result.open();
+ return result;
+ }
+
+ private OpenAiCompatibleModelClient createClient(Map<String, String>
options) {
+ return (OpenAiCompatibleModelClient)
+ new
OpenAiCompatibleModelClientFactory().createClient(context(options));
+ }
+
+ private Map<String, String> baseOptions() {
+ Map<String, String> options = new HashMap<>();
+ options.put("endpoint", server.url("/v1").toString());
+ options.put("api-key", "sk-test");
+ options.put("model", "test-model");
+ return options;
+ }
+
+ private static ModelContext context(Map<String, String> options) {
+ return new ModelContext() {
+ @Override
+ public String getModelName() {
+ return "test-model-definition";
+ }
+
+ @Override
+ public Map<String, String> getOptions() {
+ return options;
+ }
+
+ @Override
+ public ClassLoader getClassLoader() {
+ return Thread.currentThread().getContextClassLoader();
+ }
+ };
+ }
+
+ private static MockResponse jsonResponse(int statusCode, String body) {
+ return new MockResponse()
+ .setResponseCode(statusCode)
+ .addHeader("Content-Type", "application/json")
+ .setBody(body);
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiRequestParamsTest.java
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiRequestParamsTest.java
new file mode 100644
index 000000000..95f2d00a9
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/java/org/apache/flink/cdc/models/openai/OpenAiRequestParamsTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.models.openai;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link OpenAiRequestParams}. */
+class OpenAiRequestParamsTest {
+
+ @Test
+ void testParseRequestAndRetryOptions() {
+ Map<String, String> options = new HashMap<>();
+ options.put("user-prompt", "follow up");
+ options.put("temperature", "0.5");
+ options.put("top-p", "0.7");
+ options.put("stop", "END");
+ options.put("max-tokens", "256");
+ options.put("presence-penalty", "1.2");
+ options.put("frequency-penalty", "0.4");
+ options.put("n", "2");
+ options.put("seed", "123");
+ options.put("response-format", "json_object");
+ options.put("content-type", "image_url");
+ options.put("extra-header", "{\"x-test\":\"value\"}");
+ options.put("extra-body", "{\"vendor\":true}");
+ options.put("dimension", "768");
+ options.put("error-handling-strategy", "retry");
+ options.put("retry-num", "3");
+ options.put("retry-fallback-strategy", "ignore");
+ options.put("retry-backoff-strategy", "exponential");
+ options.put("retry-backoff-base-interval", "2 s");
+
+ OpenAiRequestParams params =
+
OpenAiRequestParams.fromOptions(Configuration.fromMap(options));
+
+ assertThat(params.userPrompt).isEqualTo("follow up");
+ assertThat(params.temperature).isEqualTo(0.5d);
+ assertThat(params.topP).isEqualTo(0.7d);
+ assertThat(params.stop).isEqualTo("END");
+ assertThat(params.maxTokens).isEqualTo(256);
+ assertThat(params.presencePenalty).isEqualTo(1.2d);
+ assertThat(params.frequencyPenalty).isEqualTo(0.4d);
+ assertThat(params.n).isEqualTo(2L);
+ assertThat(params.seed).isEqualTo(123L);
+ assertThat(params.responseFormat)
+
.isEqualTo(OpenAiCompatibleModelOptions.ResponseFormat.JSON_OBJECT);
+ assertThat(params.contentType)
+ .isEqualTo(OpenAiCompatibleModelOptions.ContentType.IMAGE_URL);
+ assertThat(params.extraHeader).contains("x-test");
+ assertThat(params.extraBody).contains("vendor");
+ assertThat(params.dimension).isEqualTo(768);
+
assertThat(params.errorHandlingStrategy).isEqualTo(ErrorHandlingStrategy.RETRY);
+ assertThat(params.retryNum).isEqualTo(3);
+
assertThat(params.retryFallbackStrategy).isEqualTo(ErrorHandlingStrategy.IGNORE);
+
assertThat(params.retryBackoffStrategy).isEqualTo(RetryBackoffStrategy.EXPONENTIAL);
+ assertThat(params.retryBackoffBaseIntervalMillis).isEqualTo(2000L);
+ }
+
+ @Test
+ void testRetryDefaults() {
+ OpenAiRequestParams params =
+ OpenAiRequestParams.fromOptions(Configuration.fromMap(new
HashMap<>()));
+
+
assertThat(params.contentType).isEqualTo(OpenAiCompatibleModelOptions.ContentType.TEXT);
+
assertThat(params.errorHandlingStrategy).isEqualTo(ErrorHandlingStrategy.RETRY);
+ assertThat(params.retryNum).isEqualTo(100);
+
assertThat(params.retryFallbackStrategy).isEqualTo(ErrorHandlingStrategy.FAILOVER);
+
assertThat(params.retryBackoffStrategy).isEqualTo(RetryBackoffStrategy.FIXED);
+ assertThat(params.retryBackoffBaseIntervalMillis).isEqualTo(1000L);
+ }
+
+ @Test
+ void testInvalidParameterRangesAreRejected() {
+ assertThatThrownBy(() -> parseOption("temperature", "2.1"))
+ .hasMessageContaining("temperature");
+ assertThatThrownBy(() -> parseOption("top-p",
"-0.1")).hasMessageContaining("top-p");
+ assertThatThrownBy(() -> parseOption("dimension",
"0")).hasMessageContaining("dimension");
+ assertThatThrownBy(() -> parseOption("retry-num",
"0")).hasMessageContaining("retry-num");
+ }
+
+ @Test
+ void testInvalidRetrySettingsAreRejected() {
+ assertThatThrownBy(() -> parseOption("retry-fallback-strategy",
"retry"))
+ .hasMessageContaining("cannot be retry");
+
+ Map<String, String> options = new HashMap<>();
+ options.put("retry-backoff-strategy", "exponential");
+ options.put("retry-num", "100");
+ assertThatThrownBy(() ->
OpenAiRequestParams.fromOptions(Configuration.fromMap(options)))
+ .hasMessageContaining("Total retry delay is too large");
+ }
+
+ @Test
+ void testTextResponseFormatIsRejected() {
+ assertThatThrownBy(() -> parseOption("response-format", "text"))
+ .hasMessageContaining("Only 'json_object' is supported");
+ }
+
+ private static void parseOption(String key, String value) {
+ OpenAiRequestParams.fromOptions(Configuration.fromMap(Map.of(key,
value)));
+ }
+}
diff --git
a/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/resources/log4j2-test.properties
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/resources/log4j2-test.properties
new file mode 100644
index 000000000..4a3eb1046
--- /dev/null
+++
b/flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible/src/test/resources/log4j2-test.properties
@@ -0,0 +1,22 @@
+# 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.
+
+rootLogger.level = INFO
+rootLogger.appenderRef.console.ref = ConsoleAppender
+
+appender.console.name = ConsoleAppender
+appender.console.type = CONSOLE
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss,SSS} %-5p %-60c %x - %m%n
diff --git a/flink-cdc-pipeline-model/pom.xml b/flink-cdc-pipeline-model/pom.xml
index 1a3e398b7..02b7d4fa0 100644
--- a/flink-cdc-pipeline-model/pom.xml
+++ b/flink-cdc-pipeline-model/pom.xml
@@ -31,6 +31,7 @@ limitations under the License.
<modules>
<module>flink-cdc-pipeline-model-legacy</module>
<module>flink-cdc-pipeline-model-dummy</module>
+ <module>flink-cdc-pipeline-model-openai-compatible</module>
</modules>
</project>