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 36d7515fe7 [Fix][Core] Fix SeaTunnel CLI reasoning replay for tool
calls (#10902)
36d7515fe7 is described below
commit 36d7515fe7be40bf300bd01a6ef6558398613906
Author: yzeng1618 <[email protected]>
AuthorDate: Sat May 23 09:34:02 2026 +0800
[Fix][Core] Fix SeaTunnel CLI reasoning replay for tool calls (#10902)
Co-authored-by: zengyi <[email protected]>
---
seatunnel-cli/README.md | 10 +
seatunnel-cli/README.zh-CN.md | 10 +
seatunnel-cli/env.example.sh | 1 +
seatunnel-cli/seatunnel_cli/cli.py | 4 +-
seatunnel-cli/seatunnel_cli/llm_provider.py | 263 +++++++++++++++++++++++-
seatunnel-cli/seatunnel_cli/skills.py | 4 +-
seatunnel-cli/tests/test_llm_provider_openai.py | 258 +++++++++++++++++++++++
7 files changed, 537 insertions(+), 13 deletions(-)
diff --git a/seatunnel-cli/README.md b/seatunnel-cli/README.md
index f2e5055417..f268e3ff12 100644
--- a/seatunnel-cli/README.md
+++ b/seatunnel-cli/README.md
@@ -101,6 +101,9 @@ export
ANTHROPIC_SMALL_FAST_MODEL='us.anthropic.claude-haiku-4-5-20251001-v1:0'
# export AWS_SECRET_ACCESS_KEY=...
```
+The Bedrock provider preserves Claude `reasoningContent` blocks in streamed
+Converse responses when Bedrock returns them.
+
#### Option B: Anthropic API
```bash
@@ -112,6 +115,9 @@ export ANTHROPIC_MODEL=claude-sonnet-4-20250514
export ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5-20251001
```
+The Anthropic provider preserves Claude thinking blocks (`thinking`,
`signature`, and
+`redacted_thinking`) in assistant history when the API returns them.
+
#### Option C: OpenAI API
```bash
@@ -124,6 +130,9 @@ export OPENAI_SMALL_FAST_MODEL=gpt-4o-mini
# Custom base URL for compatible APIs (Azure OpenAI, local models, etc.)
# export OPENAI_BASE_URL=https://your-endpoint.openai.azure.com/
+
+# Keep enabled for compatible reasoning models that require reasoning_content
replay
+# export OPENAI_ECHO_REASONING_CONTENT=true
```
### SEATUNNEL_HOME
@@ -173,6 +182,7 @@ When the engine is running, the CLI operates in **cluster
mode** with live conne
| `ANTHROPIC_API_KEY` | Anthropic | -- | Anthropic API key |
| `OPENAI_API_KEY` | OpenAI | -- | OpenAI API key |
| `OPENAI_BASE_URL` | No | -- | Custom endpoint for OpenAI-compatible APIs |
+| `OPENAI_ECHO_REASONING_CONTENT` | No | `true` | Preserve and replay
`reasoning_content` for OpenAI-compatible reasoning models such as DeepSeek or
GLM thinking mode |
| `ANTHROPIC_MODEL` | No | Provider default | Override primary model ID |
| `ANTHROPIC_SMALL_FAST_MODEL` | No | Provider default | Override fast model
ID |
| `OPENAI_MODEL` | No | `gpt-4o` | Primary model for OpenAI provider |
diff --git a/seatunnel-cli/README.zh-CN.md b/seatunnel-cli/README.zh-CN.md
index ef3cb39880..f59ea644ff 100644
--- a/seatunnel-cli/README.zh-CN.md
+++ b/seatunnel-cli/README.zh-CN.md
@@ -101,6 +101,9 @@ export
ANTHROPIC_SMALL_FAST_MODEL='us.anthropic.claude-haiku-4-5-20251001-v1:0'
# export AWS_SECRET_ACCESS_KEY=...
```
+当 Bedrock 流式 Converse 响应返回 Claude `reasoningContent` blocks 时,
+Bedrock provider 会在 assistant 历史中保留并回传。
+
#### 方案 B:Anthropic API
```bash
@@ -112,6 +115,9 @@ export ANTHROPIC_MODEL=claude-sonnet-4-20250514
export ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5-20251001
```
+当 API 返回 Claude thinking blocks 时,Anthropic provider 会在 assistant 历史中保留并回传
+`thinking`、`signature` 和 `redacted_thinking`。
+
#### 方案 C:OpenAI API
```bash
@@ -124,6 +130,9 @@ export OPENAI_SMALL_FAST_MODEL=gpt-4o-mini
# 兼容 API 的自定义端点(Azure OpenAI、本地模型等)
# export OPENAI_BASE_URL=https://your-endpoint.openai.azure.com/
+
+# 对需要 reasoning_content 回放的兼容推理模型保持开启
+# export OPENAI_ECHO_REASONING_CONTENT=true
```
### SEATUNNEL_HOME
@@ -173,6 +182,7 @@ export SEATUNNEL_API_BASE=http://localhost:5801 # 默认值
| `ANTHROPIC_API_KEY` | Anthropic 必需 | -- | Anthropic API 密钥 |
| `OPENAI_API_KEY` | OpenAI 必需 | -- | OpenAI API 密钥 |
| `OPENAI_BASE_URL` | 否 | -- | OpenAI 兼容 API 的自定义端点 |
+| `OPENAI_ECHO_REASONING_CONTENT` | 否 | `true` | 为 DeepSeek、GLM 思考模式等 OpenAI
兼容推理模型保留并回传 `reasoning_content` |
| `ANTHROPIC_MODEL` | 否 | 提供商默认值 | 覆盖主模型 ID |
| `ANTHROPIC_SMALL_FAST_MODEL` | 否 | 提供商默认值 | 覆盖快速模型 ID |
| `OPENAI_MODEL` | 否 | `gpt-4o` | OpenAI 提供商的主模型 |
diff --git a/seatunnel-cli/env.example.sh b/seatunnel-cli/env.example.sh
index 797b8d8b94..180f2d9161 100644
--- a/seatunnel-cli/env.example.sh
+++ b/seatunnel-cli/env.example.sh
@@ -37,6 +37,7 @@
# export OPENAI_MODEL=gpt-4o # optional override
# export OPENAI_SMALL_FAST_MODEL=gpt-4o-mini # optional override
# export OPENAI_BASE_URL= # optional: for Azure,
DeepSeek, local models, etc.
+# export OPENAI_ECHO_REASONING_CONTENT=true # optional: keep true for
reasoning models that require replay
# ─── Option C: AWS Bedrock (AI_PROVIDER=bedrock) ───
# export AWS_REGION=us-east-1
diff --git a/seatunnel-cli/seatunnel_cli/cli.py
b/seatunnel-cli/seatunnel_cli/cli.py
index 709d52ef04..e357d99783 100644
--- a/seatunnel-cli/seatunnel_cli/cli.py
+++ b/seatunnel-cli/seatunnel_cli/cli.py
@@ -39,7 +39,7 @@ from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from . import __version__, get_data_dir
-from .llm_provider import create_provider
+from .llm_provider import create_provider, format_llm_error
from .agents import Orchestrator
@@ -928,7 +928,7 @@ class SeaTunnelCLI:
result = self.orchestrator.process_user_input(user_input)
except Exception as e:
self._stop_live()
- self.console.print(f"\n[error]Error: {e}[/error]")
+ self.console.print(f"\n[error]Error:
{format_llm_error(e)}[/error]")
import traceback
self.console.print(f"[dim]{traceback.format_exc()}[/dim]")
return None
diff --git a/seatunnel-cli/seatunnel_cli/llm_provider.py
b/seatunnel-cli/seatunnel_cli/llm_provider.py
index d5663bbfd2..ef91498c0b 100644
--- a/seatunnel-cli/seatunnel_cli/llm_provider.py
+++ b/seatunnel-cli/seatunnel_cli/llm_provider.py
@@ -36,6 +36,40 @@ from typing import Generator
logger = logging.getLogger(__name__)
+
+def _env_bool(name: str, default: bool) -> bool:
+ """Read a boolean environment flag."""
+ value = os.environ.get(name)
+ if value is None:
+ return default
+ return value.strip().lower() not in {"0", "false", "no", "off"}
+
+
+def format_llm_error(error: Exception) -> str:
+ """Return an actionable error message for common provider protocol
issues."""
+ message = str(error)
+ lower = message.lower()
+ if "reasoning_content" in lower and "passed back" in lower:
+ return (
+ f"{message}\n\n"
+ "Hint: This looks like an OpenAI-compatible reasoning model "
+ "requiring reasoning_content replay after a tool call. SeaTunnel
CLI "
+ "preserves and sends reasoning_content by default for the OpenAI "
+ "provider. If this session was created before the fix, start a
fresh "
+ "session with /new. Also make sure OPENAI_ECHO_REASONING_CONTENT
is "
+ "not set to false."
+ )
+ if "thinking" in lower and "signature" in lower:
+ return (
+ f"{message}\n\n"
+ "Hint: This looks like a Claude thinking blocks replay issue. "
+ "SeaTunnel CLI preserves Anthropic thinking/signature blocks and "
+ "Bedrock reasoningContent blocks in assistant history. If this "
+ "session was created before the fix, start a fresh session with
/new."
+ )
+ return message
+
+
# ─── Common internal message format ───
# We reuse the Bedrock Converse API message shape as our internal format:
#
@@ -49,6 +83,11 @@ logger = logging.getLogger(__name__)
# "stopReason": "end_turn" | "tool_use",
# }
#
+# Provider-owned reasoning state is preserved as opaque replay data:
+# {"reasoningContent": "..."} # OpenAI-compatible
+# {"reasoningContent": {"reasoningText": {...}}} # Bedrock Converse
+# {"anthropicThinking": {"thinking": "...", ...}} # Anthropic Messages
+#
# Tools follow the Bedrock Converse toolSpec shape:
# {"toolSpec": {"name": "...", "description": "...", "inputSchema": {"json":
{...}}}}
@@ -130,17 +169,112 @@ class LLMProvider(abc.ABC):
"""Reconstruct a full internal-format response from collected stream
events."""
content: list[dict] = []
current_text = ""
+ current_reasoning = ""
+ current_anthropic_thinking: dict | None = None
+ current_bedrock_reasoning: dict | None = None
current_tool: dict | None = None
stop_reason = "end_turn"
+ def flush_text() -> None:
+ nonlocal current_text
+ if current_text:
+ content.append({"text": current_text})
+ current_text = ""
+
+ def flush_reasoning() -> None:
+ nonlocal current_reasoning
+ if current_reasoning:
+ content.append({"reasoningContent": current_reasoning})
+ current_reasoning = ""
+
+ def flush_anthropic_thinking() -> None:
+ nonlocal current_anthropic_thinking
+ if current_anthropic_thinking:
+ block = {
+ "thinking": current_anthropic_thinking.get("thinking", ""),
+ }
+ signature = current_anthropic_thinking.get("signature", "")
+ if signature:
+ block["signature"] = signature
+ if block["thinking"] or signature:
+ content.append({"anthropicThinking": block})
+ current_anthropic_thinking = None
+
+ def flush_bedrock_reasoning() -> None:
+ nonlocal current_bedrock_reasoning
+ if current_bedrock_reasoning:
+ reasoning_text = current_bedrock_reasoning.get("reasoningText")
+ if isinstance(reasoning_text, dict) and not
reasoning_text.get("signature"):
+ reasoning_text.pop("signature", None)
+ content.append({"reasoningContent": current_bedrock_reasoning})
+ current_bedrock_reasoning = None
+
+ def flush_model_state() -> None:
+ flush_reasoning()
+ flush_anthropic_thinking()
+ flush_bedrock_reasoning()
+
for event in events:
etype = event.get("type", "")
if etype == "text_delta":
+ flush_model_state()
current_text += event["text"]
+ elif etype == "reasoning_delta":
+ flush_text()
+ flush_anthropic_thinking()
+ flush_bedrock_reasoning()
+ current_reasoning += event["text"]
+ elif etype == "thinking_start":
+ flush_text()
+ flush_reasoning()
+ flush_bedrock_reasoning()
+ flush_anthropic_thinking()
+ current_anthropic_thinking = {
+ "thinking": event.get("thinking", ""),
+ "signature": event.get("signature", ""),
+ }
+ elif etype == "thinking_delta":
+ flush_text()
+ flush_reasoning()
+ flush_bedrock_reasoning()
+ if current_anthropic_thinking is None:
+ current_anthropic_thinking = {"thinking": "", "signature":
""}
+ current_anthropic_thinking["thinking"] += event.get("text", "")
+ elif etype == "signature_delta":
+ if current_anthropic_thinking is None:
+ current_anthropic_thinking = {"thinking": "", "signature":
""}
+ current_anthropic_thinking["signature"] +=
event.get("signature", "")
+ elif etype == "thinking_stop":
+ flush_anthropic_thinking()
+ elif etype == "redacted_thinking":
+ flush_text()
+ flush_model_state()
+ content.append({
+ "anthropicRedactedThinking": {"data": event.get("data",
"")}
+ })
+ elif etype == "bedrock_reasoning_delta":
+ flush_text()
+ flush_reasoning()
+ flush_anthropic_thinking()
+ redacted = event.get("redacted_content")
+ if redacted is not None:
+ flush_bedrock_reasoning()
+ content.append({"reasoningContent": {"redactedContent":
redacted}})
+ else:
+ if (
+ current_bedrock_reasoning is None
+ or "reasoningText" not in current_bedrock_reasoning
+ ):
+ current_bedrock_reasoning = {
+ "reasoningText": {"text": "", "signature": ""}
+ }
+ reasoning_text = current_bedrock_reasoning["reasoningText"]
+ reasoning_text["text"] += event.get("text", "")
+ if event.get("signature"):
+ reasoning_text["signature"] += event["signature"]
elif etype == "tool_start":
- if current_text:
- content.append({"text": current_text})
- current_text = ""
+ flush_text()
+ flush_model_state()
current_tool = {
"toolUseId": event["tool_use_id"],
"name": event["name"],
@@ -166,8 +300,8 @@ class LLMProvider(abc.ABC):
elif etype == "message_stop":
stop_reason = event.get("stop_reason", "end_turn")
- if current_text:
- content.append({"text": current_text})
+ flush_text()
+ flush_model_state()
return {
"output": {"message": {"role": "assistant", "content": content}},
@@ -263,7 +397,21 @@ class BedrockProvider(LLMProvider):
yield {"type": "tool_start", "tool_use_id":
current_tool_id, "name": tu.get("name", "")}
elif "contentBlockDelta" in event:
delta = event["contentBlockDelta"].get("delta", {})
- if "text" in delta:
+ if "reasoningContent" in delta:
+ rc = delta["reasoningContent"]
+ reasoning_text = rc.get("reasoningText", {})
+ if reasoning_text:
+ yield {
+ "type": "bedrock_reasoning_delta",
+ "text": reasoning_text.get("text", ""),
+ "signature": reasoning_text.get("signature", ""),
+ }
+ if "redactedContent" in rc:
+ yield {
+ "type": "bedrock_reasoning_delta",
+ "redacted_content": rc["redactedContent"],
+ }
+ elif "text" in delta:
yield {"type": "text_delta", "text": delta["text"]}
elif "toolUse" in delta:
yield {"type": "tool_input_delta", "tool_use_id":
current_tool_id or "", "delta": delta["toolUse"].get("input", "")}
@@ -357,14 +505,29 @@ class AnthropicProvider(LLMProvider):
kwargs["tools"] = self._to_anthropic_tools(tools)
current_tool_id = None
+ block_types: dict[int, str] = {}
with self._client.messages.stream(**kwargs) as stream:
for event in stream:
etype = getattr(event, "type", "")
if etype == "content_block_start":
+ index = getattr(event, "index", 0)
block = getattr(event, "content_block", None)
- if block and getattr(block, "type", "") == "tool_use":
+ block_type = getattr(block, "type", "") if block else ""
+ block_types[index] = block_type
+ if block_type == "tool_use":
current_tool_id = getattr(block, "id", "")
yield {"type": "tool_start", "tool_use_id":
current_tool_id, "name": getattr(block, "name", "")}
+ elif block_type == "thinking":
+ yield {
+ "type": "thinking_start",
+ "thinking": getattr(block, "thinking", ""),
+ "signature": getattr(block, "signature", ""),
+ }
+ elif block_type == "redacted_thinking":
+ yield {
+ "type": "redacted_thinking",
+ "data": getattr(block, "data", ""),
+ }
elif etype == "content_block_delta":
delta = getattr(event, "delta", None)
if delta:
@@ -373,10 +536,18 @@ class AnthropicProvider(LLMProvider):
yield {"type": "text_delta", "text":
getattr(delta, "text", "")}
elif dt == "input_json_delta":
yield {"type": "tool_input_delta", "tool_use_id":
current_tool_id or "", "delta": getattr(delta, "partial_json", "")}
+ elif dt == "thinking_delta":
+ yield {"type": "thinking_delta", "text":
getattr(delta, "thinking", "")}
+ elif dt == "signature_delta":
+ yield {"type": "signature_delta", "signature":
getattr(delta, "signature", "")}
elif etype == "content_block_stop":
- if current_tool_id:
+ index = getattr(event, "index", 0)
+ block_type = block_types.pop(index, "")
+ if block_type == "tool_use" and current_tool_id:
yield {"type": "tool_stop", "tool_use_id":
current_tool_id}
current_tool_id = None
+ elif block_type == "thinking":
+ yield {"type": "thinking_stop"}
elif etype == "message_stop":
msg = getattr(event, "message", None)
sr = getattr(msg, "stop_reason", "end_turn") if msg else
"end_turn"
@@ -393,6 +564,19 @@ class AnthropicProvider(LLMProvider):
for block in content:
if "text" in block:
anthropic_content.append({"type": "text", "text":
block["text"]})
+ elif "anthropicThinking" in block:
+ thinking = dict(block["anthropicThinking"])
+ anthropic_content.append({
+ "type": "thinking",
+ "thinking": thinking.get("thinking", ""),
+ "signature": thinking.get("signature", ""),
+ })
+ elif "anthropicRedactedThinking" in block:
+ redacted = dict(block["anthropicRedactedThinking"])
+ anthropic_content.append({
+ "type": "redacted_thinking",
+ "data": redacted.get("data", ""),
+ })
elif "toolUse" in block:
tu = block["toolUse"]
anthropic_content.append({
@@ -431,7 +615,20 @@ class AnthropicProvider(LLMProvider):
"""Convert Anthropic API response to internal format."""
content = []
for block in response.content:
- if block.type == "text":
+ if block.type == "thinking":
+ content.append({
+ "anthropicThinking": {
+ "thinking": getattr(block, "thinking", ""),
+ "signature": getattr(block, "signature", ""),
+ }
+ })
+ elif block.type == "redacted_thinking":
+ content.append({
+ "anthropicRedactedThinking": {
+ "data": getattr(block, "data", ""),
+ }
+ })
+ elif block.type == "text":
content.append({"text": block.text})
elif block.type == "tool_use":
content.append({
@@ -478,6 +675,7 @@ class OpenAIProvider(LLMProvider):
self._client = openai.OpenAI(**client_kwargs)
self._model_id = os.environ.get("OPENAI_MODEL", "gpt-4o")
self._fast_model_id = os.environ.get("OPENAI_SMALL_FAST_MODEL",
"gpt-4o-mini")
+ self._echo_reasoning_content =
_env_bool("OPENAI_ECHO_REASONING_CONTENT", True)
@property
def provider_name(self) -> str:
@@ -543,6 +741,10 @@ class OpenAIProvider(LLMProvider):
choice = chunk.choices[0]
delta = choice.delta
+ reasoning_content = self._get_openai_field(delta,
"reasoning_content")
+ if reasoning_content:
+ yield {"type": "reasoning_delta", "text": reasoning_content}
+
if delta and delta.content:
yield {"type": "text_delta", "text": delta.content}
@@ -564,6 +766,7 @@ class OpenAIProvider(LLMProvider):
def _to_openai_messages(self, messages: list[dict], system: str = "") ->
list[dict]:
"""Convert internal message format to OpenAI API format."""
result = []
+ echo_reasoning = getattr(self, "_echo_reasoning_content", True)
if system:
result.append({"role": "system", "content": system})
@@ -591,10 +794,13 @@ class OpenAIProvider(LLMProvider):
if has_tool_use:
text_parts = []
+ reasoning_parts = []
tool_calls = []
for block in content:
if "text" in block:
text_parts.append(block["text"])
+ elif "reasoningContent" in block and
isinstance(block["reasoningContent"], str):
+ reasoning_parts.append(block["reasoningContent"])
elif "toolUse" in block:
tu = block["toolUse"]
tool_calls.append({
@@ -608,6 +814,8 @@ class OpenAIProvider(LLMProvider):
msg_dict = {"role": "assistant"}
if text_parts:
msg_dict["content"] = "\n".join(text_parts)
+ if echo_reasoning and reasoning_parts:
+ msg_dict["reasoning_content"] = "\n".join(reasoning_parts)
if tool_calls:
msg_dict["tool_calls"] = tool_calls
result.append(msg_dict)
@@ -615,8 +823,21 @@ class OpenAIProvider(LLMProvider):
# Regular text message
text_parts = [block["text"] for block in content if "text" in
block]
+ reasoning_parts = [
+ block["reasoningContent"]
+ for block in content
+ if "reasoningContent" in block and
isinstance(block["reasoningContent"], str)
+ ]
if text_parts:
- result.append({"role": role, "content": "\n".join(text_parts)})
+ msg_dict = {"role": role, "content": "\n".join(text_parts)}
+ if echo_reasoning and role == "assistant" and reasoning_parts:
+ msg_dict["reasoning_content"] = "\n".join(reasoning_parts)
+ result.append(msg_dict)
+ elif echo_reasoning and role == "assistant" and reasoning_parts:
+ result.append({
+ "role": role,
+ "reasoning_content": "\n".join(reasoning_parts),
+ })
return result
@@ -643,6 +864,10 @@ class OpenAIProvider(LLMProvider):
message = choice.message
content = []
+ reasoning_content = OpenAIProvider._get_openai_field(message,
"reasoning_content")
+ if reasoning_content:
+ content.append({"reasoningContent": reasoning_content})
+
if message.content:
content.append({"text": message.content})
@@ -665,6 +890,24 @@ class OpenAIProvider(LLMProvider):
"stopReason": stop_reason,
}
+ @staticmethod
+ def _get_openai_field(obj, field_name: str):
+ """Read SDK fields, including provider-specific extra fields."""
+ if obj is None:
+ return None
+ if isinstance(obj, dict):
+ return obj.get(field_name)
+
+ value = getattr(obj, field_name, None)
+ if value is not None:
+ return value
+
+ model_extra = getattr(obj, "model_extra", None)
+ if isinstance(model_extra, dict):
+ return model_extra.get(field_name)
+
+ return None
+
# ─── Config file ───
diff --git a/seatunnel-cli/seatunnel_cli/skills.py
b/seatunnel-cli/seatunnel_cli/skills.py
index 72d1e1940e..60fcdf9d58 100644
--- a/seatunnel-cli/seatunnel_cli/skills.py
+++ b/seatunnel-cli/seatunnel_cli/skills.py
@@ -188,7 +188,9 @@ def _try_parse_json_plan(plan_text: str) -> StructuredPlan
| None:
src = p.get("source", {})
sink = p.get("sink", {})
tables = p.get("tables", [])
- if isinstance(tables, str):
+ if tables is None:
+ tables = []
+ elif isinstance(tables, str):
tables = [tables]
pipelines.append(PipelineSlot(
pipeline_id=p.get("id", f"pipeline_{len(pipelines) + 1}"),
diff --git a/seatunnel-cli/tests/test_llm_provider_openai.py
b/seatunnel-cli/tests/test_llm_provider_openai.py
new file mode 100644
index 0000000000..f44c995b82
--- /dev/null
+++ b/seatunnel-cli/tests/test_llm_provider_openai.py
@@ -0,0 +1,258 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import unittest
+from types import SimpleNamespace
+
+from seatunnel_cli.llm_provider import (
+ LLMProvider,
+ OpenAIProvider,
+ format_llm_error,
+)
+
+
+class _FakeOpenAICompletions:
+ def __init__(self, stream):
+ self.stream = stream
+ self.kwargs = None
+
+ def create(self, **kwargs):
+ self.kwargs = kwargs
+ return self.stream
+
+
+class _FakeOpenAIClient:
+ def __init__(self, stream):
+ self.completions = _FakeOpenAICompletions(stream)
+ self.chat = SimpleNamespace(completions=self.completions)
+
+
+def _chunk(delta, finish_reason=None):
+ return SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ delta=delta,
+ finish_reason=finish_reason,
+ )
+ ]
+ )
+
+
+class OpenAIProviderReasoningContentTest(unittest.TestCase):
+ def test_openai_stream_preserves_reasoning_content_delta(self):
+ provider = OpenAIProvider.__new__(OpenAIProvider)
+ provider._model_id = "reasoning-model"
+ provider._client = _FakeOpenAIClient(
+ [
+ _chunk(
+ SimpleNamespace(
+ content=None,
+ reasoning_content="inspect connector metadata",
+ tool_calls=None,
+ )
+ ),
+ _chunk(SimpleNamespace(content="PLAN: use Jdbc",
tool_calls=None)),
+ _chunk(
+ SimpleNamespace(content=None, tool_calls=None),
+ finish_reason="stop",
+ ),
+ ]
+ )
+
+ events = list(
+ provider.chat_stream(
+ messages=[
+ {
+ "role": "user",
+ "content": [{"text": "sync oracle to iceberg"}],
+ }
+ ]
+ )
+ )
+ response = LLMProvider.collect_stream(events)
+
+ self.assertEqual(
+ response["output"]["message"]["content"],
+ [
+ {"reasoningContent": "inspect connector metadata"},
+ {"text": "PLAN: use Jdbc"},
+ ],
+ )
+
+ def test_openai_messages_send_reasoning_content_back_to_api(self):
+ provider = OpenAIProvider.__new__(OpenAIProvider)
+
+ messages = [
+ {
+ "role": "assistant",
+ "content": [
+ {"reasoningContent": "need source and sink connector
metadata"},
+ {"text": "PLAN: inspect connectors"},
+ {
+ "toolUse": {
+ "toolUseId": "tool-1",
+ "name": "get_connector_info",
+ "input": {"name": "Jdbc", "type": "source"},
+ }
+ },
+ ],
+ }
+ ]
+
+ self.assertEqual(
+ provider._to_openai_messages(messages),
+ [
+ {
+ "role": "assistant",
+ "content": "PLAN: inspect connectors",
+ "reasoning_content": "need source and sink connector
metadata",
+ "tool_calls": [
+ {
+ "id": "tool-1",
+ "type": "function",
+ "function": {
+ "name": "get_connector_info",
+ "arguments": '{"name": "Jdbc", "type":
"source"}',
+ },
+ }
+ ],
+ }
+ ],
+ )
+
+ def test_openai_messages_skip_empty_reasoning_content_for_tool_calls(self):
+ provider = OpenAIProvider.__new__(OpenAIProvider)
+
+ messages = [
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "toolUse": {
+ "toolUseId": "tool-1",
+ "name": "get_connector_info",
+ "input": {"name": "Doris", "connector_type":
"sink"},
+ }
+ },
+ ],
+ }
+ ]
+
+ self.assertEqual(
+ provider._to_openai_messages(messages),
+ [
+ {
+ "role": "assistant",
+ "tool_calls": [
+ {
+ "id": "tool-1",
+ "type": "function",
+ "function": {
+ "name": "get_connector_info",
+ "arguments": '{"name": "Doris",
"connector_type": "sink"}',
+ },
+ }
+ ],
+ }
+ ],
+ )
+
+ def test_openai_messages_can_disable_reasoning_content_echo(self):
+ provider = OpenAIProvider.__new__(OpenAIProvider)
+ provider._echo_reasoning_content = False
+
+ messages = [
+ {
+ "role": "assistant",
+ "content": [
+ {"reasoningContent": "provider-specific hidden state"},
+ {"text": "done"},
+ ],
+ }
+ ]
+
+ self.assertEqual(
+ provider._to_openai_messages(messages),
+ [{"role": "assistant", "content": "done"}],
+ )
+
+ def test_openai_messages_ignore_bedrock_reasoning_content_blocks(self):
+ provider = OpenAIProvider.__new__(OpenAIProvider)
+
+ messages = [
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "reasoningContent": {
+ "reasoningText": {
+ "text": "provider-specific hidden state",
+ "signature": "sig-1",
+ }
+ }
+ },
+ {"text": "done"},
+ ],
+ }
+ ]
+
+ self.assertEqual(
+ provider._to_openai_messages(messages),
+ [{"role": "assistant", "content": "done"}],
+ )
+
+ def
test_openai_response_preserves_reasoning_content_from_extra_fields(self):
+ message = SimpleNamespace(
+ content="done",
+ model_extra={"reasoning_content": "checked config shape"},
+ tool_calls=None,
+ )
+ response = SimpleNamespace(
+ choices=[SimpleNamespace(message=message, finish_reason="stop")]
+ )
+
+ self.assertEqual(
+ OpenAIProvider._from_openai_response(response),
+ {
+ "output": {
+ "message": {
+ "role": "assistant",
+ "content": [
+ {"reasoningContent": "checked config shape"},
+ {"text": "done"},
+ ],
+ }
+ },
+ "stopReason": "end_turn",
+ },
+ )
+
+ def test_reasoning_content_error_gets_actionable_hint(self):
+ err = Exception(
+ "Error code: 400 - Param Incorrect: "
+ "The reasoning_content in the thinking mode must be passed back to
the API."
+ )
+
+ message = format_llm_error(err)
+
+ self.assertIn("reasoning_content", message)
+ self.assertIn("OpenAI-compatible reasoning model", message)
+ self.assertIn("OPENAI_ECHO_REASONING_CONTENT", message)
+
+
+if __name__ == "__main__":
+ unittest.main()