Feat/agent thinking switch (#15446)

### What problem does this PR solve?

This PR adds an Agent LLM setting to control thinking mode for official
providers that expose a thinking switch.

Related to #12842.  
Closes #15445.

Some providers expose thinking controls through provider-specific
request fields, but Agent LLM settings did not have a unified option for
users to enable or disable thinking mode.

This PR adds a `Thinking` selector with:

- System default
- Enabled
- Disabled
<img width="452" height="278" alt="8566b0b4-0546-4c8a-913d-f9bbd38319f6"
src="https://github.com/user-attachments/assets/25b497f7-1ba0-4bfe-940d-6fe79287d6ab"
/>
<img width="471" height="971" alt="8a0a6bee-f45f-48d5-bd83-17af260de3db"
src="https://github.com/user-attachments/assets/41ad43c1-5087-48f1-bf37-f2ca14c2be2f"
/>
Initial support is limited to the verified official providers:

- Qwen / DashScope: `enable_thinking`
- Kimi / Moonshot: `thinking.type`
- GLM / ZHIPU-AI: `thinking.type`

For LiteLLM-based providers, provider-specific fields are forwarded
through `extra_body` before `drop_params` filtering so the request
parameters are preserved.



### Type of change

- [x] New Feature (non-breaking change which adds functionality)

---------

Co-authored-by: jiashi <jiashi19@outlook.com>
Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
jiashi19
2026-06-28 12:02:55 +08:00
committed by yzc
parent 6a4de82a80
commit 0d7ad0ed0c
9 changed files with 367 additions and 29 deletions

View File

@@ -95,10 +95,12 @@ ALLOWED_GEN_CONF_KEYS = frozenset(
# LiteLLM additionally understands reasoning-control parameters that the
# model-family policies may inject into `gen_conf` (e.g. `thinking` for
# Anthropic / Kimi reasoning models, `reasoning_effort` for OpenAI o-series).
# Anthropic / Kimi reasoning models, `enable_thinking` for Qwen models,
# `reasoning_effort` for OpenAI o-series).
LITELLM_ALLOWED_GEN_CONF_KEYS = ALLOWED_GEN_CONF_KEYS | frozenset(
{
"thinking",
"enable_thinking",
"reasoning_effort",
"extra_body",
}
@@ -117,9 +119,43 @@ def _apply_model_family_policies(
sanitized_gen_conf = deepcopy(gen_conf) if gen_conf else {}
sanitized_kwargs = dict(request_kwargs) if request_kwargs else {}
# Qwen3 family disables thinking by extra_body on non-stream chat requests.
def _thinking_type():
val = sanitized_gen_conf.get("thinking")
if isinstance(val, dict):
val = val.get("type")
enable_thinking = sanitized_gen_conf.get("enable_thinking")
if isinstance(val, str) and val in {"enabled", "disabled"}:
return val
if isinstance(enable_thinking, bool):
return "enabled" if enable_thinking else "disabled"
return None
def _pop_thinking_controls():
sanitized_gen_conf.pop("thinking", None)
sanitized_gen_conf.pop("enable_thinking", None)
def _merge_extra_body(target: dict, extra: dict) -> None:
body = target.get("extra_body")
if not isinstance(body, dict):
body = {}
body.update(extra)
target["extra_body"] = body
thinking_type = _thinking_type()
# Qwen3 keeps RAGFlow's system default of disabling thinking unless explicitly overridden.
if "qwen3" in model_name_lower:
sanitized_kwargs["extra_body"] = {"enable_thinking": False}
_pop_thinking_controls()
enable_thinking = thinking_type == "enabled" if thinking_type else False
if backend == "litellm" and provider in {
SupportedLiteLLMProvider.Tongyi_Qianwen,
SupportedLiteLLMProvider.Dashscope,
}:
sanitized_gen_conf["enable_thinking"] = enable_thinking
else:
_merge_extra_body(sanitized_kwargs, {"enable_thinking": enable_thinking})
if backend == "base":
return sanitized_gen_conf, sanitized_kwargs
@@ -137,27 +173,50 @@ def _apply_model_family_policies(
if provider == SupportedLiteLLMProvider.HunYuan:
for key in ("presence_penalty", "frequency_penalty"):
sanitized_gen_conf.pop(key, None)
elif "kimi-k2.5" in model_name_lower or "kimi-k2.6" in model_name_lower:
reasoning = sanitized_gen_conf.pop("reasoning", None)
thinking = {"type": "enabled"}
if reasoning is not None:
thinking = {"type": "enabled"} if reasoning else {"type": "disabled"}
elif not isinstance(thinking, dict) or thinking.get("type") not in {"enabled", "disabled"}:
thinking = {"type": "disabled"}
sanitized_gen_conf["thinking"] = thinking
elif provider == SupportedLiteLLMProvider.Moonshot:
if thinking_type:
_pop_thinking_controls()
sanitized_gen_conf["thinking"] = {"type": thinking_type}
thinking_enabled = thinking.get("type") == "enabled"
sanitized_gen_conf["temperature"] = 1.0 if thinking_enabled else 0.6
sanitized_gen_conf["top_p"] = 0.95
sanitized_gen_conf["n"] = 1
sanitized_gen_conf["presence_penalty"] = 0.0
sanitized_gen_conf["frequency_penalty"] = 0.0
if thinking_type or "kimi-k2.5" in model_name_lower or "kimi-k2.6" in model_name_lower:
sanitized_gen_conf.pop("temperature", None)
sanitized_gen_conf["top_p"] = 0.95
sanitized_gen_conf["n"] = 1
sanitized_gen_conf["presence_penalty"] = 0.0
sanitized_gen_conf["frequency_penalty"] = 0.0
elif (
provider == SupportedLiteLLMProvider.ZHIPU_AI
and "glm" in model_name_lower
and thinking_type
):
_pop_thinking_controls()
sanitized_gen_conf["thinking"] = {"type": thinking_type}
return sanitized_gen_conf, sanitized_kwargs
return sanitized_gen_conf, sanitized_kwargs
def _move_litellm_provider_body_fields(provider: SupportedLiteLLMProvider | str | None, completion_args: dict) -> dict:
provider_body_fields = {
SupportedLiteLLMProvider.Tongyi_Qianwen: {"enable_thinking"},
SupportedLiteLLMProvider.Dashscope: {"enable_thinking"},
SupportedLiteLLMProvider.Moonshot: {"thinking"},
SupportedLiteLLMProvider.ZHIPU_AI: {"thinking"},
}.get(provider, set())
body = completion_args.get("extra_body")
if not isinstance(body, dict):
body = {}
moved = False
for key in provider_body_fields:
if key in completion_args:
body[key] = completion_args.pop(key)
moved = True
if moved or body:
completion_args["extra_body"] = body
return completion_args
class Base(ABC):
def __init__(self, key, model_name, base_url, **kwargs):
timeout = int(os.environ.get("LLM_TIMEOUT_SECONDS", 600))
@@ -197,12 +256,6 @@ class Base(ABC):
return LLMErrorCode.ERROR_GENERIC
def _clean_conf(self, gen_conf):
gen_conf, _ = _apply_model_family_policies(
self.model_name,
backend="base",
gen_conf=gen_conf,
)
if "max_tokens" in gen_conf:
del gen_conf["max_tokens"]
@@ -213,10 +266,17 @@ class Base(ABC):
logging.info("[HISTORY STREAMLY]" + json.dumps(history, ensure_ascii=False, indent=4))
reasoning_start = False
gen_conf, extra_request_kwargs = _apply_model_family_policies(
self.model_name,
backend="base",
gen_conf=gen_conf,
request_kwargs={},
)
request_kwargs = {"model": self.model_name, "messages": history, "stream": True, **gen_conf}
stop = kwargs.get("stop")
if stop:
request_kwargs["stop"] = stop
request_kwargs.update(extra_request_kwargs)
response = await self.async_client.chat.completions.create(**request_kwargs)
async for resp in response:
@@ -407,6 +467,12 @@ class Base(ABC):
async def async_chat_with_tools(self, system: str, history: list, gen_conf: dict | None = None):
gen_conf = dict(gen_conf or {})
gen_conf = self._clean_conf(gen_conf)
gen_conf, extra_request_kwargs = _apply_model_family_policies(
self.model_name,
backend="base",
gen_conf=gen_conf,
request_kwargs={},
)
if system and history and history[0].get("role") != "system":
history.insert(0, {"role": "system", "content": system})
@@ -418,7 +484,7 @@ class Base(ABC):
try:
for _ in range(self.max_rounds + 1):
logging.info(f"{self.tools=}")
response = await self.async_client.chat.completions.create(model=self.model_name, messages=history, tools=self.tools, tool_choice="auto", **gen_conf)
response = await self.async_client.chat.completions.create(model=self.model_name, messages=history, tools=self.tools, tool_choice="auto", **gen_conf, **extra_request_kwargs)
tk_count += total_token_count_from_response(response)
if not response.choices or not response.choices[0].message:
raise Exception(f"500 response structure error. Response: {response}")
@@ -473,6 +539,12 @@ class Base(ABC):
async def async_chat_streamly_with_tools(self, system: str, history: list, gen_conf: dict | None = None):
gen_conf = dict(gen_conf or {})
gen_conf = self._clean_conf(gen_conf)
gen_conf, extra_request_kwargs = _apply_model_family_policies(
self.model_name,
backend="base",
gen_conf=gen_conf,
request_kwargs={},
)
tools = self.tools
if system and history and history[0].get("role") != "system":
history.insert(0, {"role": "system", "content": system})
@@ -487,7 +559,7 @@ class Base(ABC):
reasoning_start = False
logging.info(f"[ToolLoop] round={_round} model={self.model_name} tools={[t['function']['name'] for t in tools]}")
response = await self.async_client.chat.completions.create(model=self.model_name, messages=history, stream=True, tools=tools, tool_choice="auto", **gen_conf)
response = await self.async_client.chat.completions.create(model=self.model_name, messages=history, stream=True, tools=tools, tool_choice="auto", **gen_conf, **extra_request_kwargs)
final_tool_calls = {}
answer = ""
@@ -573,7 +645,15 @@ class Base(ABC):
logging.warning(f"Exceed max rounds: {self.max_rounds}")
history.append({"role": "user", "content": f"Exceed max rounds: {self.max_rounds}"})
response = await self.async_client.chat.completions.create(model=self.model_name, messages=history, stream=True, tools=tools, tool_choice="auto", **gen_conf)
response = await self.async_client.chat.completions.create(
model=self.model_name,
messages=history,
stream=True,
tools=tools,
tool_choice="auto",
**gen_conf,
**extra_request_kwargs,
)
async for resp in response:
if not hasattr(resp, "choices") or not resp.choices:
@@ -619,9 +699,10 @@ class Base(ABC):
return final_ans.strip(), tol_token
_, kwargs = _apply_model_family_policies(
gen_conf, kwargs = _apply_model_family_policies(
self.model_name,
backend="base",
gen_conf=gen_conf,
request_kwargs=kwargs,
)
@@ -2080,6 +2161,7 @@ class LiteLLMBase(ABC):
api_base = completion_args.get("api_base", self.base_url)
separator = "&" if "?" in api_base else "?"
completion_args["api_base"] = f"{api_base}{separator}GroupId={self.group_id}"
_move_litellm_provider_body_fields(self.provider, completion_args)
if extra_headers:
completion_args["extra_headers"] = extra_headers
return completion_args