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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 99a84bc9ec [Fix][CLI] Fix Bedrock compatibility: temperature rejection 
and engine template variables (#11509)
99a84bc9ec is described below

commit 99a84bc9ecf8f43557db941b5054d1a0a1361cbe
Author: SEZ <[email protected]>
AuthorDate: Thu Jul 23 23:17:53 2026 +0800

    [Fix][CLI] Fix Bedrock compatibility: temperature rejection and engine 
template variables (#11509)
    
    Co-authored-by: SEZ9 <[email protected]>
---
 seatunnel-cli/README.md                            |  19 +++
 seatunnel-cli/seatunnel_cli/agents.py              |  27 +++-
 seatunnel-cli/seatunnel_cli/llm_provider.py        |  84 +++++++---
 seatunnel-cli/tests/test_llm_provider_bedrock.py   | 177 +++++++++++++++++++++
 .../tests/test_validate_hocon_placeholders.py      | 105 ++++++++++++
 5 files changed, 385 insertions(+), 27 deletions(-)

diff --git a/seatunnel-cli/README.md b/seatunnel-cli/README.md
index 17f81c02af..dc285199be 100644
--- a/seatunnel-cli/README.md
+++ b/seatunnel-cli/README.md
@@ -105,6 +105,12 @@ export 
ANTHROPIC_SMALL_FAST_MODEL='us.anthropic.claude-haiku-4-5-20251001-v1:0'
 The Bedrock provider preserves Claude `reasoningContent` blocks in streamed
 Converse responses when Bedrock returns them.
 
+> **Note:** Some newer Claude models on Bedrock reject the `temperature`
+> inference parameter. When a model returns this rejection, the provider
+> automatically retries the request without `temperature` and remembers the
+> model for the rest of the session, so subsequent calls skip the parameter.
+> For these models any configured temperature value is not applied.
+
 #### Option B: Anthropic API
 
 ```bash
@@ -395,6 +401,19 @@ Memory is stored locally at `.data/memory.json` 
(co-located with the CLI package
 | **Auto-fix** | LLM-powered | Up to 3 rounds of automatic error correction 
during generation |
 | **Auto-repair** | LLM-powered | Automatic diagnosis and config patching on 
`/check` or `/run` failure |
 
+Local validation flags unresolved `${VAR}` placeholders as missing environment
+variables, with a field-aware exemption for SeaTunnel's engine-resolved file
+sink template placeholders (see the
+[LocalFile sink 
docs](https://seatunnel.apache.org/docs/connectors/sink/LocalFile)):
+
+| Field | Engine placeholders allowed |
+|-------|-----------------------------|
+| `file_name_expression` | `${now}`, `${uuid}`, `${transactionId}` |
+| `partition_dir_expression` | `${k0}`, `${v0}`, `${k1}`, `${v1}`, ... |
+
+The same names used in any other field (URLs, credentials, paths) are treated
+as regular environment variables and reported if unset.
+
 ## Connector Metadata
 
 The CLI ships with `connector_metadata.json`, exported from the SeaTunnel 
engine via runtime reflection. It includes source, sink, and transform plugin 
option rules, conditional options, and value constraints. No extra steps needed.
diff --git a/seatunnel-cli/seatunnel_cli/agents.py 
b/seatunnel-cli/seatunnel_cli/agents.py
index 89c5010a4c..14caee8a04 100644
--- a/seatunnel-cli/seatunnel_cli/agents.py
+++ b/seatunnel-cli/seatunnel_cli/agents.py
@@ -526,12 +526,31 @@ def validate_hocon(config_str: str) -> str:
 
     # Check for unresolved ${ENV_VAR} placeholders — these will be passed as 
literal
     # strings to connectors at runtime, causing authentication/connection 
failures.
+    # SeaTunnel's engine resolves certain placeholders itself, but only in
+    # specific file sink fields (see docs/en/connectors/sink/LocalFile.md):
+    #   file_name_expression     -> ${now}, ${uuid}, ${transactionId}
+    #   partition_dir_expression -> ${k0}=${v0}/... (kN = partition field,
+    #                               vN = partition value)
+    # The exemption is field-aware so that the same names used in unrelated
+    # fields (URLs, credentials, ...) are still diagnosed as env vars.
+    engine_template_fields = {
+        "file_name_expression": re.compile(r"now|uuid|transactionId"),
+        "partition_dir_expression": re.compile(r"[kv]\d+"),
+    }
     env_var_pattern = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}')
+    # HOCON accepts both `key = value` and `key : value` separators
+    field_pattern = re.compile(r'^\s*"?([\w.]+)"?\s*[=:]')
     unresolved_vars = set()
-    for m in env_var_pattern.finditer(config_str):
-        var_name = m.group(1)
-        if not os.environ.get(var_name):
-            unresolved_vars.add(var_name)
+    for line in config_str.splitlines():
+        field_match = field_pattern.match(line)
+        allowed = engine_template_fields.get(
+            field_match.group(1)) if field_match else None
+        for m in env_var_pattern.finditer(line):
+            var_name = m.group(1)
+            if allowed and allowed.fullmatch(var_name):
+                continue
+            if not os.environ.get(var_name):
+                unresolved_vars.add(var_name)
     if unresolved_vars:
         var_list = ", ".join(sorted(unresolved_vars))
         errors.append(
diff --git a/seatunnel-cli/seatunnel_cli/llm_provider.py 
b/seatunnel-cli/seatunnel_cli/llm_provider.py
index ef91498c0b..8d547666bc 100644
--- a/seatunnel-cli/seatunnel_cli/llm_provider.py
+++ b/seatunnel-cli/seatunnel_cli/llm_provider.py
@@ -29,6 +29,11 @@ Selection: set AI_PROVIDER env var, or run --init for 
interactive setup.
 """
 
 import abc
+
+try:
+    import botocore.exceptions
+except ImportError:  # boto3/botocore optional (bedrock extra)
+    botocore = None
 import json
 import logging
 import os
@@ -330,6 +335,43 @@ class BedrockProvider(LLMProvider):
         if endpoint_url:
             client_kwargs["endpoint_url"] = endpoint_url
         self.client = session.client(**client_kwargs)
+        # Models that rejected `temperature` (newer Claude models deprecate 
it).
+        # Populated on first rejection so subsequent calls skip the retry.
+        self._no_temperature_models: set[str] = set()
+
+    def _build_kwargs(self, model_id, messages, system, temperature, 
max_tokens, tools) -> dict:
+        inference_config = {"maxTokens": max_tokens}
+        if model_id not in self._no_temperature_models:
+            inference_config["temperature"] = temperature
+        kwargs = {
+            "modelId": model_id,
+            "messages": messages,
+            "inferenceConfig": inference_config,
+        }
+        if system:
+            kwargs["system"] = [{"text": system}]
+        if tools:
+            kwargs["toolConfig"] = {"tools": tools}
+        return kwargs
+
+    @staticmethod
+    def _is_temperature_rejection(error: Exception) -> bool:
+        """True if this is Bedrock's structured rejection of `temperature`.
+
+        Checks the botocore ClientError code (precise) and only uses the
+        message to discriminate that `temperature` is the rejected parameter
+        (tolerant to wording changes such as "deprecated" vs "not supported").
+        """
+        try:
+            from botocore.exceptions import ClientError
+        except ImportError:
+            return False
+        if not isinstance(error, ClientError):
+            return False
+        code = error.response.get("Error", {}).get("Code", "")
+        if code != "ValidationException":
+            return False
+        return "temperature" in str(error).lower()
 
     @property
     def provider_name(self) -> str:
@@ -353,18 +395,15 @@ class BedrockProvider(LLMProvider):
         tools: list[dict] | None = None,
     ) -> dict:
         model_id = model or self._model_id
-        kwargs = {
-            "modelId": model_id,
-            "messages": messages,
-            "inferenceConfig": {"temperature": temperature, "maxTokens": 
max_tokens},
-        }
-        if system:
-            kwargs["system"] = [{"text": system}]
-        if tools:
-            kwargs["toolConfig"] = {"tools": tools}
-
-        response = self.client.converse(**kwargs)
-        return response
+        kwargs = self._build_kwargs(model_id, messages, system, temperature, 
max_tokens, tools)
+        try:
+            return self.client.converse(**kwargs)
+        except botocore.exceptions.ClientError as e:
+            if self._is_temperature_rejection(e):
+                self._no_temperature_models.add(model_id)
+                kwargs["inferenceConfig"].pop("temperature", None)
+                return self.client.converse(**kwargs)
+            raise
 
     def chat_stream(
         self,
@@ -376,17 +415,16 @@ class BedrockProvider(LLMProvider):
         tools: list[dict] | None = None,
     ) -> Generator[dict, None, None]:
         model_id = model or self._model_id
-        kwargs = {
-            "modelId": model_id,
-            "messages": messages,
-            "inferenceConfig": {"temperature": temperature, "maxTokens": 
max_tokens},
-        }
-        if system:
-            kwargs["system"] = [{"text": system}]
-        if tools:
-            kwargs["toolConfig"] = {"tools": tools}
-
-        response = self.client.converse_stream(**kwargs)
+        kwargs = self._build_kwargs(model_id, messages, system, temperature, 
max_tokens, tools)
+        try:
+            response = self.client.converse_stream(**kwargs)
+        except botocore.exceptions.ClientError as e:
+            if self._is_temperature_rejection(e):
+                self._no_temperature_models.add(model_id)
+                kwargs["inferenceConfig"].pop("temperature", None)
+                response = self.client.converse_stream(**kwargs)
+            else:
+                raise
         current_tool_id = None
         for event in response.get("stream", []):
             if "contentBlockStart" in event:
diff --git a/seatunnel-cli/tests/test_llm_provider_bedrock.py 
b/seatunnel-cli/tests/test_llm_provider_bedrock.py
new file mode 100644
index 0000000000..b73589c4fb
--- /dev/null
+++ b/seatunnel-cli/tests/test_llm_provider_bedrock.py
@@ -0,0 +1,177 @@
+#
+# 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.
+#
+
+"""Regression tests for the BedrockProvider temperature-rejection retry
+(chat and chat_stream paths)."""
+
+import copy
+from unittest import mock
+
+import pytest
+
+botocore = pytest.importorskip("botocore")
+from botocore.exceptions import ClientError  # noqa: E402
+
+OK_RESPONSE = {
+    "output": {"message": {"role": "assistant", "content": [{"text": "OK"}]}},
+    "stopReason": "end_turn",
+}
+
+OK_STREAM = {"stream": []}
+
+
+def _temperature_rejection() -> ClientError:
+    return ClientError(
+        error_response={
+            "Error": {
+                "Code": "ValidationException",
+                "Message": "The model returned the following errors: "
+                           "`temperature` is deprecated for this model.",
+            }
+        },
+        operation_name="Converse",
+    )
+
+
+def _other_validation_error() -> ClientError:
+    return ClientError(
+        error_response={
+            "Error": {
+                "Code": "ValidationException",
+                "Message": "The provided model identifier is invalid.",
+            }
+        },
+        operation_name="Converse",
+    )
+
+
+def _throttling_error() -> ClientError:
+    return ClientError(
+        error_response={
+            "Error": {"Code": "ThrottlingException",
+                      "Message": "Too many requests, temperature unrelated"}
+        },
+        operation_name="Converse",
+    )
+
+
+def _make_provider(converse_effects=None, stream_effects=None):
+    """Build a BedrockProvider with a stubbed boto3 client.
+
+    The retry path mutates the kwargs dict in place before the second call,
+    so mock's recorded call_args (which stores references) can't be used to
+    inspect the first call's inferenceConfig. Record deep snapshots instead.
+    """
+    with mock.patch.dict("sys.modules", {"boto3": mock.MagicMock()}):
+        from seatunnel_cli.llm_provider import BedrockProvider
+        provider = BedrockProvider.__new__(BedrockProvider)
+        provider._model_id = "test-model"
+        provider._fast_model_id = "test-model"
+        provider._no_temperature_models = set()
+        provider.client = mock.MagicMock()
+        provider.converse_snapshots = []
+        provider.stream_snapshots = []
+
+        def _run(effects, snapshots):
+            def call(**kwargs):
+                snapshots.append(copy.deepcopy(kwargs))
+                effect = effects.pop(0)
+                if isinstance(effect, Exception):
+                    raise effect
+                return effect
+            return call
+
+        if converse_effects is not None:
+            provider.client.converse.side_effect = _run(
+                list(converse_effects), provider.converse_snapshots)
+        if stream_effects is not None:
+            provider.client.converse_stream.side_effect = _run(
+                list(stream_effects), provider.stream_snapshots)
+        return provider
+
+
+# ── chat (non-streaming) ──
+
+def test_chat_temperature_rejection_retries_without_temperature():
+    provider = _make_provider(
+        converse_effects=[_temperature_rejection(), OK_RESPONSE, OK_RESPONSE])
+
+    resp = provider.chat([{"role": "user", "content": [{"text": "hi"}]}])
+    assert resp["stopReason"] == "end_turn"
+
+    snaps = provider.converse_snapshots
+    assert len(snaps) == 2
+    assert "temperature" in snaps[0]["inferenceConfig"]
+    assert "temperature" not in snaps[1]["inferenceConfig"]
+    assert "test-model" in provider._no_temperature_models
+
+    # model id cached: the next call must not send temperature at all
+    provider.chat([{"role": "user", "content": [{"text": "hi"}]}])
+    assert "temperature" not in 
provider.converse_snapshots[2]["inferenceConfig"]
+
+
+def test_chat_other_validation_errors_are_not_swallowed():
+    provider = _make_provider(converse_effects=[_other_validation_error()])
+    with pytest.raises(ClientError):
+        provider.chat([{"role": "user", "content": [{"text": "hi"}]}])
+    assert len(provider.converse_snapshots) == 1
+    assert not provider._no_temperature_models
+
+
+def test_chat_non_validation_client_errors_are_not_swallowed():
+    provider = _make_provider(converse_effects=[_throttling_error()])
+    with pytest.raises(ClientError):
+        provider.chat([{"role": "user", "content": [{"text": "hi"}]}])
+    assert len(provider.converse_snapshots) == 1
+    assert not provider._no_temperature_models
+
+
+def test_chat_non_client_errors_propagate():
+    provider = _make_provider(converse_effects=[RuntimeError("boom")])
+    with pytest.raises(RuntimeError):
+        provider.chat([{"role": "user", "content": [{"text": "hi"}]}])
+    assert not provider._no_temperature_models
+
+
+# ── chat_stream (the CLI's interactive path — scenario from #11508) ──
+
+def test_chat_stream_temperature_rejection_retries_without_temperature():
+    provider = _make_provider(
+        stream_effects=[_temperature_rejection(), OK_STREAM, OK_STREAM])
+
+    events = list(provider.chat_stream(
+        [{"role": "user", "content": [{"text": "hi"}]}]))
+    assert events == []  # empty stream consumed without error
+
+    snaps = provider.stream_snapshots
+    assert len(snaps) == 2
+    assert "temperature" in snaps[0]["inferenceConfig"]
+    assert "temperature" not in snaps[1]["inferenceConfig"]
+    assert "test-model" in provider._no_temperature_models
+
+    # cached: next stream call must not send temperature
+    list(provider.chat_stream([{"role": "user", "content": [{"text": "hi"}]}]))
+    assert "temperature" not in provider.stream_snapshots[2]["inferenceConfig"]
+
+
+def test_chat_stream_other_errors_are_not_swallowed():
+    provider = _make_provider(stream_effects=[_throttling_error()])
+    with pytest.raises(ClientError):
+        list(provider.chat_stream(
+            [{"role": "user", "content": [{"text": "hi"}]}]))
+    assert len(provider.stream_snapshots) == 1
+    assert not provider._no_temperature_models
diff --git a/seatunnel-cli/tests/test_validate_hocon_placeholders.py 
b/seatunnel-cli/tests/test_validate_hocon_placeholders.py
new file mode 100644
index 0000000000..4e9a32a634
--- /dev/null
+++ b/seatunnel-cli/tests/test_validate_hocon_placeholders.py
@@ -0,0 +1,105 @@
+#
+# 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.
+#
+
+"""Regression tests for field-aware engine placeholder handling in 
validate_hocon."""
+
+import os
+from unittest import mock
+
+import pytest
+
+from seatunnel_cli.agents import validate_hocon
+
+
+def _config_with_sink(sink_body: str) -> str:
+    return f"""
+env {{ parallelism = 1
+  job.mode = "BATCH" }}
+source {{ FakeSource {{ row.num = 5
+  schema {{ fields {{ id = "bigint" }} }}
+  plugin_output = "f" }} }}
+sink {{ LocalFile {{ plugin_input = "f"
+  path = "/tmp/out"
+  {sink_body} }} }}
+"""
+
+
+# ── engine placeholders accepted only in their own fields ──
+
+def test_file_name_expression_engine_placeholders_accepted():
+    for expr in ("${now}", "${uuid}", "${transactionId}", 
"out_${uuid}_${now}"):
+        config = _config_with_sink(f'file_name_expression = "{expr}"')
+        result = validate_hocon(config)
+        assert "Unresolved environment variables" not in result, expr
+
+
+def test_partition_dir_expression_engine_placeholders_accepted():
+    config = _config_with_sink(
+        'partition_dir_expression = "${k0}=${v0}/${k1}=${v1}"')
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" not in result
+
+
+# ── the same names in unrelated fields are still diagnosed ──
+
[email protected]("field_line, var", [
+    ('url = "jdbc:mysql://${now}:3306/db"', "now"),
+    ('password = "${uuid}"', "uuid"),
+    ('topic = "${transactionId}"', "transactionId"),
+    ('path = "/data/${k0}"', "k0"),
+])
+def test_engine_placeholder_names_rejected_outside_their_fields(field_line, 
var):
+    assert var not in os.environ
+    config = _config_with_sink(field_line)
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" in result
+    assert var in result
+
+
+def test_unset_env_var_still_rejected_in_expression_fields():
+    # A non-engine placeholder inside file_name_expression is still an env var
+    assert "MY_UNSET_PREFIX" not in os.environ
+    config = _config_with_sink(
+        'file_name_expression = "${MY_UNSET_PREFIX}_${now}"')
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" in result
+    assert "MY_UNSET_PREFIX" in result
+
+
+def test_set_env_var_accepted_anywhere():
+    with mock.patch.dict(os.environ, {"MYSQL_PASSWORD": "x"}):
+        config = _config_with_sink('password = "${MYSQL_PASSWORD}"')
+        result = validate_hocon(config)
+        assert "Unresolved environment variables" not in result
+
+# ── HOCON colon separator (key : value) is equally valid ──
+
+def test_colon_separator_engine_placeholders_accepted():
+    config = _config_with_sink('file_name_expression: "${now}"')
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" not in result
+
+    config = _config_with_sink('partition_dir_expression: "${k0}=${v0}"')
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" not in result
+
+
+def test_colon_separator_still_rejects_env_vars_elsewhere():
+    assert "now" not in os.environ
+    config = _config_with_sink('topic: "${now}"')
+    result = validate_hocon(config)
+    assert "Unresolved environment variables" in result

Reply via email to