From 54b8d0286e5054882737c15566737c2f1e8da7f8 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 16:15:19 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs(/naga):=20=E4=BC=98=E5=8C=96=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Undefined/skills/commands/naga/README.md | 66 +++++++++++++++++++ .../skills/commands/naga/config.json | 5 +- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/Undefined/skills/commands/naga/README.md diff --git a/src/Undefined/skills/commands/naga/README.md b/src/Undefined/skills/commands/naga/README.md new file mode 100644 index 00000000..a7c285a5 --- /dev/null +++ b/src/Undefined/skills/commands/naga/README.md @@ -0,0 +1,66 @@ +# /naga 命令说明 + +## 这是什么? + +NagaAgent 是一个可以接入 Undefined 的外部 AI 助手。 +通过 `/naga` 命令,你可以把自己的 NagaAgent 绑定到 QQ 群,绑定之后,NagaAgent 里的特定功能就可以向你发送消息。 + +## 普通用户 + +普通用户只需要用到一个子命令:`bind`(绑定)。 + +### 如何绑定? + +1. 在**群聊**中发送:`/naga bind <你的naga_id>` +2. 系统会提示"申请已提交,等待超管审核" +3. 超级管理员审核通过后,你会收到私聊通知 +4. 绑定完成!你的 NagaAgent 即可开始使用 + +### 注意事项 + +- `naga_id` 是你在 NagaAgent 中设置的标识,不是 QQ 号 +- 每个 `naga_id` 只能绑定一次,不能重复申请 +- 如果已在审核队列中,无需重复提交 + +## 管理员命令(仅超级管理员) + +以下命令仅超级管理员可使用,用于管理所有绑定: + +| 子命令 | 用法 | 说明 | +|--------|------|------| +| approve | `/naga approve ` | 通过绑定申请,系统会自动生成 Token 并通知申请人 | +| reject | `/naga reject ` | 拒绝绑定申请,申请人会收到私聊通知 | +| revoke | `/naga revoke ` | 吊销已有绑定,该 NagaAgent 将无法继续使用 | +| list | `/naga list` | 查看所有活跃的绑定(含使用次数) | +| pending | `/naga pending` | 查看等待审核的申请列表 | +| info | `/naga info ` | 查看指定绑定的详细信息(Token、使用次数、创建时间等) | + +## 完整示例 + +``` +# 普通用户:在群聊中提交绑定申请 +/naga bind my-naga-001 + +# 超级管理员:查看待审核列表 +/naga pending + +# 超级管理员:通过申请 +/naga approve my-naga-001 + +# 超级管理员:查看绑定详情 +/naga info my-naga-001 + +# 超级管理员:吊销绑定 +/naga revoke my-naga-001 +``` + +## 常见问题 + +**Q: 提示"Naga 集成未启用"?** +A: 请联系管理员开启相关配置开关。 + +**Q: 在群里发了 /naga bind 没有任何反应?** +A: 该群可能不在白名单中,请联系管理员添加。 + +**Q: 绑定通过后 NagaAgent 怎么用?** +A: 请参考 NagaAgent 相关文档,填入QQ号以及其他几个参数即可完成对接。 \ No newline at end of file diff --git a/src/Undefined/skills/commands/naga/config.json b/src/Undefined/skills/commands/naga/config.json index 5086e70f..2f840a79 100644 --- a/src/Undefined/skills/commands/naga/config.json +++ b/src/Undefined/skills/commands/naga/config.json @@ -1,7 +1,8 @@ { "name": "naga", - "description": "NagaAgent 集成管理", - "usage": "/naga [参数]", + "description": "NagaAgent 联动命令", + "usage": "/naga <子命令> [参数]", + "example": "/naga bind my-naga", "permission": "public", "allow_in_private": true, "show_in_help": true, From b76c077df1cdf27dc8c9cea16d27f803cf2b53c3 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 18:23:53 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(ai):=20=E6=94=AF=E6=8C=81=20thinking?= =?UTF-8?q?=20effort=20=E5=8F=8C=E6=A8=A1=E5=BC=8F=20+=20adaptive=20thinki?= =?UTF-8?q?ng=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Chat Completions 和 Responses API 统一 thinking 参数构建逻辑: - 新增 thinking_effort / thinking_effort_style 配置字段,支持 OpenAI(reasoning.effort)和 Anthropic(output_config.effort)两种传参风格 - thinking_effort 非空时使用 adaptive 模式,否则回退 legacy budget_tokens 模式 - WebUI reasoning_effort 改为手动输入,新增 thinking_effort_style 下拉选项 Co-Authored-By: Claude Opus 4.6 --- config.toml.example | 30 +++++ src/Undefined/ai/llm.py | 26 +++- src/Undefined/ai/transports/__init__.py | 8 ++ .../ai/transports/openai_transport.py | 47 +++++++ src/Undefined/api/app.py | 1 + src/Undefined/config/loader.py | 94 +++++++++++++ src/Undefined/config/models.py | 10 ++ src/Undefined/webui/static/js/config-form.js | 20 ++- tests/test_llm_request_params.py | 127 ++++++++++++++++++ tests/test_runtime_api_probes.py | 2 + 10 files changed, 357 insertions(+), 8 deletions(-) diff --git a/config.toml.example b/config.toml.example index b186b773..03049bfb 100644 --- a/config.toml.example +++ b/config.toml.example @@ -107,6 +107,12 @@ thinking_budget_tokens = 20000 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true +# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 +# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. +thinking_effort = "" +# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +thinking_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -167,6 +173,12 @@ thinking_budget_tokens = 20000 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true +# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 +# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. +thinking_effort = "" +# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +thinking_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -220,6 +232,12 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true +# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 +# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. +thinking_effort = "" +# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +thinking_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -270,6 +288,12 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true +# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 +# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. +thinking_effort = "" +# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +thinking_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -333,6 +357,12 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true +# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 +# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. +thinking_effort = "" +# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +thinking_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index 41d93b61..6ff5e0dc 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -25,7 +25,10 @@ API_MODE_RESPONSES, build_responses_request_body, get_api_mode, + get_effort_payload, + get_effort_style, get_reasoning_payload, + get_thinking_payload, normalize_responses_result, ) from Undefined.ai.retrieval import RetrievalRequester @@ -117,8 +120,10 @@ "tool_choice", "stream", "stream_options", + "thinking", "reasoning", "reasoning_effort", + "output_config", } ) | _SDK_REQUEST_OPTION_FIELDS @@ -139,6 +144,7 @@ "thinking", "reasoning", "reasoning_effort", + "output_config", } ) | _SDK_REQUEST_OPTION_FIELDS @@ -1453,6 +1459,7 @@ def build_request_body( extra_kwargs.pop("thinking", None) extra_kwargs.pop("reasoning", None) extra_kwargs.pop("reasoning_effort", None) + extra_kwargs.pop("output_config", None) return build_responses_request_body( model_config, messages, @@ -1481,18 +1488,23 @@ def build_request_body( extra_kwargs.pop("reasoning", None) extra_kwargs.pop("reasoning_effort", None) + extra_kwargs.pop("output_config", None) - if getattr(model_config, "thinking_enabled", False): - thinking_param: dict[str, Any] = {"type": "enabled"} - if getattr(model_config, "thinking_include_budget", True): - thinking_param["budget_tokens"] = getattr( - model_config, "thinking_budget_tokens", 0 - ) - body["thinking"] = thinking_param + thinking = get_thinking_payload(model_config) + if thinking is not None: + body["thinking"] = thinking if reasoning_payload is not None: body["reasoning"] = reasoning_payload + effort_payload = get_effort_payload(model_config) + if effort_payload is not None: + style = get_effort_style(model_config) + if style == "anthropic": + body["output_config"] = effort_payload + else: + body.setdefault("reasoning", {}).update(effort_payload) + if tools: body["tools"] = tools thinking_active = "thinking" in body diff --git a/src/Undefined/ai/transports/__init__.py b/src/Undefined/ai/transports/__init__.py index 9f33ccf8..88be6250 100644 --- a/src/Undefined/ai/transports/__init__.py +++ b/src/Undefined/ai/transports/__init__.py @@ -5,10 +5,14 @@ API_MODE_RESPONSES, build_responses_request_body, get_api_mode, + get_effort_payload, + get_effort_style, get_reasoning_payload, + get_thinking_payload, normalize_api_mode, normalize_reasoning_effort, normalize_responses_result, + normalize_thinking_effort, ) __all__ = [ @@ -16,8 +20,12 @@ "API_MODE_RESPONSES", "build_responses_request_body", "get_api_mode", + "get_effort_payload", + "get_effort_style", "get_reasoning_payload", + "get_thinking_payload", "normalize_api_mode", "normalize_reasoning_effort", "normalize_responses_result", + "normalize_thinking_effort", ] diff --git a/src/Undefined/ai/transports/openai_transport.py b/src/Undefined/ai/transports/openai_transport.py index 5c6d7e7e..98fd4569 100644 --- a/src/Undefined/ai/transports/openai_transport.py +++ b/src/Undefined/ai/transports/openai_transport.py @@ -39,6 +39,43 @@ def get_reasoning_payload(model_config: Any) -> dict[str, Any] | None: } +_VALID_THINKING_EFFORT_STYLES = {"openai", "anthropic"} + + +def normalize_thinking_effort(value: Any) -> str: + return str(value or "").strip().lower() + + +def get_thinking_payload(model_config: Any) -> dict[str, Any] | None: + """构建 thinking 请求参数。thinking_effort 非空时用 adaptive,否则回退 legacy budget。""" + effort = normalize_thinking_effort(getattr(model_config, "thinking_effort", "")) + if effort: + return {"type": "adaptive"} + if not bool(getattr(model_config, "thinking_enabled", False)): + return None + param: dict[str, Any] = {"type": "enabled"} + if bool(getattr(model_config, "thinking_include_budget", True)): + param["budget_tokens"] = int(getattr(model_config, "thinking_budget_tokens", 0)) + return param + + +def get_effort_payload(model_config: Any) -> dict[str, Any] | None: + """构建 effort 请求参数(仅在 thinking_effort 非空时生效)。""" + effort = normalize_thinking_effort(getattr(model_config, "thinking_effort", "")) + if not effort: + return None + return {"effort": effort} + + +def get_effort_style(model_config: Any) -> str: + style = ( + str(getattr(model_config, "thinking_effort_style", "openai") or "openai") + .strip() + .lower() + ) + return style if style in _VALID_THINKING_EFFORT_STYLES else "openai" + + def _stringify_content(value: Any) -> str: if value is None: return "" @@ -337,6 +374,16 @@ def build_responses_request_body( reasoning = get_reasoning_payload(model_config) if reasoning is not None: body["reasoning"] = reasoning + thinking = get_thinking_payload(model_config) + if thinking is not None: + body["thinking"] = thinking + effort_payload = get_effort_payload(model_config) + if effort_payload is not None: + style = get_effort_style(model_config) + if style == "anthropic": + body["output_config"] = effort_payload + else: + body.setdefault("reasoning", {}).update(effort_payload) if tools: normalized_tools = _normalize_responses_tools(tools, internal_to_api) normalized_tool_choice, selected_tool_name = _normalize_responses_tool_choice( diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index ede39a3c..87154c94 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -638,6 +638,7 @@ async def _internal_probe_handler(self, request: web.Request) -> Response: "api_url": _mask_url(getattr(mcfg, "api_url", "")), "api_mode": getattr(mcfg, "api_mode", "chat_completions"), "thinking_enabled": getattr(mcfg, "thinking_enabled", False), + "thinking_effort": getattr(mcfg, "thinking_effort", ""), "thinking_tool_call_compat": getattr( mcfg, "thinking_tool_call_compat", True ), diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 2d36a049..28b0ae06 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -258,6 +258,18 @@ def _get_value( _VALID_API_MODES = {"chat_completions", "responses"} _VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} +_VALID_THINKING_EFFORT_STYLES = {"openai", "anthropic"} + + +def _resolve_thinking_effort(value: Any, default: str = "") -> str: + return _coerce_str(value, default).strip().lower() + + +def _resolve_thinking_effort_style(value: Any, default: str = "openai") -> str: + style = _coerce_str(value, default).strip().lower() + if style not in _VALID_THINKING_EFFORT_STYLES: + return default + return style def _resolve_thinking_compat_flags( @@ -1599,6 +1611,14 @@ def _parse_model_pool( item.get("thinking_include_budget"), primary_config.thinking_include_budget, ), + thinking_effort=_resolve_thinking_effort( + item.get("thinking_effort"), + primary_config.thinking_effort, + ), + thinking_effort_style=_resolve_thinking_effort_style( + item.get("thinking_effort_style"), + primary_config.thinking_effort_style, + ), thinking_tool_call_compat=_coerce_bool( item.get("thinking_tool_call_compat"), primary_config.thinking_tool_call_compat, @@ -1785,6 +1805,20 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: 20000, ), thinking_include_budget=thinking_include_budget, + thinking_effort=_resolve_thinking_effort( + _get_value( + data, + ("models", "chat", "thinking_effort"), + "CHAT_MODEL_THINKING_EFFORT", + ), + ), + thinking_effort_style=_resolve_thinking_effort_style( + _get_value( + data, + ("models", "chat", "thinking_effort_style"), + "CHAT_MODEL_THINKING_EFFORT_STYLE", + ), + ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, responses_force_stateless_replay=responses_force_stateless_replay, @@ -1877,6 +1911,20 @@ def _parse_vision_model_config(data: dict[str, Any]) -> VisionModelConfig: 20000, ), thinking_include_budget=thinking_include_budget, + thinking_effort=_resolve_thinking_effort( + _get_value( + data, + ("models", "vision", "thinking_effort"), + "VISION_MODEL_THINKING_EFFORT", + ), + ), + thinking_effort_style=_resolve_thinking_effort_style( + _get_value( + data, + ("models", "vision", "thinking_effort_style"), + "VISION_MODEL_THINKING_EFFORT_STYLE", + ), + ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, responses_force_stateless_replay=responses_force_stateless_replay, @@ -1983,6 +2031,20 @@ def _parse_security_model_config( 0, ), thinking_include_budget=thinking_include_budget, + thinking_effort=_resolve_thinking_effort( + _get_value( + data, + ("models", "security", "thinking_effort"), + "SECURITY_MODEL_THINKING_EFFORT", + ), + ), + thinking_effort_style=_resolve_thinking_effort_style( + _get_value( + data, + ("models", "security", "thinking_effort_style"), + "SECURITY_MODEL_THINKING_EFFORT_STYLE", + ), + ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, responses_force_stateless_replay=responses_force_stateless_replay, @@ -2002,6 +2064,8 @@ def _parse_security_model_config( thinking_enabled=False, thinking_budget_tokens=0, thinking_include_budget=True, + thinking_effort="", + thinking_effort_style="openai", thinking_tool_call_compat=chat_model.thinking_tool_call_compat, responses_tool_choice_compat=chat_model.responses_tool_choice_compat, responses_force_stateless_replay=chat_model.responses_force_stateless_replay, @@ -2092,6 +2156,20 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: 0, ), thinking_include_budget=thinking_include_budget, + thinking_effort=_resolve_thinking_effort( + _get_value( + data, + ("models", "agent", "thinking_effort"), + "AGENT_MODEL_THINKING_EFFORT", + ), + ), + thinking_effort_style=_resolve_thinking_effort_style( + _get_value( + data, + ("models", "agent", "thinking_effort_style"), + "AGENT_MODEL_THINKING_EFFORT_STYLE", + ), + ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, responses_force_stateless_replay=responses_force_stateless_replay, @@ -2268,6 +2346,22 @@ def _parse_historian_model_config( h.get("thinking_budget_tokens"), fallback.thinking_budget_tokens ), thinking_include_budget=thinking_include_budget, + thinking_effort=_resolve_thinking_effort( + _get_value( + {"models": {"historian": h}}, + ("models", "historian", "thinking_effort"), + "HISTORIAN_MODEL_THINKING_EFFORT", + ), + fallback.thinking_effort, + ), + thinking_effort_style=_resolve_thinking_effort_style( + _get_value( + {"models": {"historian": h}}, + ("models", "historian", "thinking_effort_style"), + "HISTORIAN_MODEL_THINKING_EFFORT_STYLE", + ), + fallback.thinking_effort_style, + ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, responses_force_stateless_replay=responses_force_stateless_replay, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 9454a9b9..b36a80b7 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -38,6 +38,8 @@ class ModelPoolEntry: thinking_enabled: bool = False thinking_budget_tokens: int = 0 thinking_include_budget: bool = True + thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) + thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = True responses_tool_choice_compat: bool = False responses_force_stateless_replay: bool = False @@ -68,6 +70,8 @@ class ChatModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 20000 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens + thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) + thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -93,6 +97,8 @@ class VisionModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 20000 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens + thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) + thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -118,6 +124,8 @@ class SecurityModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 0 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens + thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) + thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -169,6 +177,8 @@ class AgentModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 0 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens + thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) + thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js index 328b6cf6..5b4919a5 100644 --- a/src/Undefined/webui/static/js/config-form.js +++ b/src/Undefined/webui/static/js/config-form.js @@ -251,7 +251,7 @@ function isLongText(value) { const FIELD_SELECT_OPTIONS = { api_mode: ["chat_completions", "responses"], - reasoning_effort: ["none", "minimal", "low", "medium", "high", "xhigh"], + thinking_effort_style: ["openai", "anthropic"], }; function getFieldSelectOptions(path) { @@ -905,6 +905,22 @@ function buildAotTemplate(path, arr) { ) { template.reasoning_effort = "medium"; } + if ( + !Object.prototype.hasOwnProperty.call( + template, + "thinking_effort", + ) + ) { + template.thinking_effort = ""; + } + if ( + !Object.prototype.hasOwnProperty.call( + template, + "thinking_effort_style", + ) + ) { + template.thinking_effort_style = "openai"; + } } return template; } @@ -914,6 +930,8 @@ function buildAotTemplate(path, arr) { api_key: "", api_mode: "chat_completions", thinking_tool_call_compat: true, + thinking_effort: "", + thinking_effort_style: "openai", responses_tool_choice_compat: false, responses_force_stateless_replay: false, reasoning_enabled: false, diff --git a/tests/test_llm_request_params.py b/tests/test_llm_request_params.py index 3c2cb7b5..a6801b93 100644 --- a/tests/test_llm_request_params.py +++ b/tests/test_llm_request_params.py @@ -928,3 +928,130 @@ async def test_responses_tools_and_tool_choice_use_sanitized_api_names() -> None ) await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_thinking_effort_anthropic_style_chat_completions() -> None: + """thinking_effort + anthropic style → adaptive thinking + output_config.effort.""" + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeClient() + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.anthropic.com/v1", + api_key="sk-test", + model_name="claude-test", + max_tokens=4096, + thinking_effort="max", + thinking_effort_style="anthropic", + ) + + await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + call_type="chat", + ) + + kw = fake_client.chat.completions.last_kwargs + assert kw is not None + assert kw["extra_body"]["thinking"] == {"type": "adaptive"} + assert kw["extra_body"]["output_config"] == {"effort": "max"} + assert "reasoning" not in kw.get("extra_body", {}) + + await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_thinking_effort_openai_style_responses() -> None: + """thinking_effort + openai style → adaptive thinking + reasoning.effort.""" + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeClient( + responses=[ + { + "id": "resp_1", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + } + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=4096, + api_mode="responses", + thinking_effort="high", + thinking_effort_style="openai", + ) + + await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + call_type="chat", + ) + + kw = fake_client.responses.last_kwargs + assert kw is not None + assert kw["extra_body"]["thinking"] == {"type": "adaptive"} + assert kw["reasoning"] == {"effort": "high"} + assert "output_config" not in kw and "output_config" not in kw.get("extra_body", {}) + + await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_thinking_enabled_legacy_budget_tokens() -> None: + """thinking_enabled=True + no effort → legacy budget_tokens mode.""" + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeClient() + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=4096, + thinking_enabled=True, + thinking_budget_tokens=8000, + ) + + await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + call_type="chat", + ) + + kw = fake_client.chat.completions.last_kwargs + assert kw is not None + assert kw["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 8000} + + await requester._http_client.aclose() diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index 6e48a663..9fc629c8 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -26,6 +26,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> api_url="https://api.example.com/v1", api_mode="responses", thinking_enabled=False, + thinking_effort="", thinking_tool_call_compat=True, responses_tool_choice_compat=False, responses_force_stateless_replay=False, @@ -57,6 +58,7 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> "api_url": "https://api.example.com/...", "api_mode": "responses", "thinking_enabled": False, + "thinking_effort": "", "thinking_tool_call_compat": True, "responses_tool_choice_compat": False, "responses_force_stateless_replay": False, From c0c9f2a314643ddcdcdb780d1e324721361ae25c Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 19:00:58 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor(ai):=20=E5=90=88=E5=B9=B6=20thinki?= =?UTF-8?q?ng=5Feffort=20=E5=88=B0=20reasoning=5Feffort=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=20reasoning=5Feffort=5Fstyle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除独立的 thinking_effort 字段,复用 reasoning_effort 控制 adaptive thinking: reasoning_enabled=True 时自动启用 adaptive 模式并根据 reasoning_effort_style 决定 effort 传参路径(openai → reasoning / anthropic → output_config)。 同时移除 reasoning_effort 的固定值校验,允许用户填写任意值。 Co-Authored-By: Claude Opus 4.6 --- config.toml.example | 65 +++++-------- src/Undefined/ai/llm.py | 7 +- src/Undefined/ai/transports/__init__.py | 2 - .../ai/transports/openai_transport.py | 37 +++---- src/Undefined/api/app.py | 1 - src/Undefined/config/loader.py | 97 +++++-------------- src/Undefined/config/models.py | 15 +-- src/Undefined/webui/static/js/config-form.js | 17 +--- tests/test_llm_request_params.py | 20 ++-- tests/test_runtime_api_probes.py | 2 - 10 files changed, 89 insertions(+), 174 deletions(-) diff --git a/config.toml.example b/config.toml.example index 03049bfb..c6705d68 100644 --- a/config.toml.example +++ b/config.toml.example @@ -95,8 +95,8 @@ api_mode = "chat_completions" # zh: 是否启用 reasoning.effort。 # en: Enable reasoning.effort. reasoning_enabled = false -# zh: reasoning.effort 档位:none / minimal / low / medium / high / xhigh。 -# en: reasoning.effort level: none / minimal / low / medium / high / xhigh. +# zh: reasoning effort 档位。 +# en: reasoning effort level. reasoning_effort = "medium" # zh: 是否启用 thinking(思维链)。 # en: Enable thinking (reasoning). @@ -107,12 +107,9 @@ thinking_budget_tokens = 20000 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true -# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 -# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. -thinking_effort = "" -# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 -# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). -thinking_effort_style = "openai" +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -161,8 +158,8 @@ api_mode = "chat_completions" # zh: 是否启用 reasoning.effort。 # en: Enable reasoning.effort. reasoning_enabled = false -# zh: reasoning.effort 档位:none / minimal / low / medium / high / xhigh。 -# en: reasoning.effort level: none / minimal / low / medium / high / xhigh. +# zh: reasoning effort 档位。 +# en: reasoning effort level. reasoning_effort = "medium" # zh: 是否启用 thinking(思维链)。 # en: Enable thinking (reasoning). @@ -173,12 +170,9 @@ thinking_budget_tokens = 20000 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true -# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 -# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. -thinking_effort = "" -# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 -# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). -thinking_effort_style = "openai" +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -220,8 +214,8 @@ api_mode = "chat_completions" # zh: 是否启用 reasoning.effort。 # en: Enable reasoning.effort. reasoning_enabled = false -# zh: reasoning.effort 档位:none / minimal / low / medium / high / xhigh。 -# en: reasoning.effort level: none / minimal / low / medium / high / xhigh. +# zh: reasoning effort 档位。 +# en: reasoning effort level. reasoning_effort = "medium" # zh: 是否启用 thinking(思维链)。 # en: Enable thinking (reasoning). @@ -232,12 +226,9 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true -# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 -# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. -thinking_effort = "" -# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 -# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). -thinking_effort_style = "openai" +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -276,8 +267,8 @@ api_mode = "chat_completions" # zh: 是否启用 reasoning.effort。 # en: Enable reasoning.effort. reasoning_enabled = false -# zh: reasoning.effort 档位:none / minimal / low / medium / high / xhigh。 -# en: reasoning.effort level: none / minimal / low / medium / high / xhigh. +# zh: reasoning effort 档位。 +# en: reasoning effort level. reasoning_effort = "medium" # zh: 是否启用 thinking(思维链)。 # en: Enable thinking (reasoning). @@ -288,12 +279,9 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true -# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 -# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. -thinking_effort = "" -# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 -# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). -thinking_effort_style = "openai" +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true @@ -345,8 +333,8 @@ api_mode = "chat_completions" # zh: 是否启用 reasoning.effort。 # en: Enable reasoning.effort. reasoning_enabled = false -# zh: reasoning.effort 档位:none / minimal / low / medium / high / xhigh。 -# en: reasoning.effort level: none / minimal / low / medium / high / xhigh. +# zh: reasoning effort 档位。 +# en: reasoning effort level. reasoning_effort = "medium" # zh: 是否启用 thinking(思维链)。 # en: Enable thinking (reasoning). @@ -357,12 +345,9 @@ thinking_budget_tokens = 0 # zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 # en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). thinking_include_budget = true -# zh: thinking effort 档位,如 low / medium / high / max。留空表示不启用。 -# en: Thinking effort level, e.g. low / medium / high / max. Empty = disabled. -thinking_effort = "" -# zh: thinking effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 -# en: Thinking effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). -thinking_effort_style = "openai" +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" # zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 # en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. thinking_tool_call_compat = true diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index 6ff5e0dc..d578b5e4 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -1494,16 +1494,15 @@ def build_request_body( if thinking is not None: body["thinking"] = thinking - if reasoning_payload is not None: - body["reasoning"] = reasoning_payload - effort_payload = get_effort_payload(model_config) if effort_payload is not None: style = get_effort_style(model_config) if style == "anthropic": body["output_config"] = effort_payload else: - body.setdefault("reasoning", {}).update(effort_payload) + body["reasoning"] = effort_payload + elif reasoning_payload is not None: + body["reasoning"] = reasoning_payload if tools: body["tools"] = tools diff --git a/src/Undefined/ai/transports/__init__.py b/src/Undefined/ai/transports/__init__.py index 88be6250..74f527fc 100644 --- a/src/Undefined/ai/transports/__init__.py +++ b/src/Undefined/ai/transports/__init__.py @@ -12,7 +12,6 @@ normalize_api_mode, normalize_reasoning_effort, normalize_responses_result, - normalize_thinking_effort, ) __all__ = [ @@ -27,5 +26,4 @@ "normalize_api_mode", "normalize_reasoning_effort", "normalize_responses_result", - "normalize_thinking_effort", ] diff --git a/src/Undefined/ai/transports/openai_transport.py b/src/Undefined/ai/transports/openai_transport.py index 98fd4569..ba0b0999 100644 --- a/src/Undefined/ai/transports/openai_transport.py +++ b/src/Undefined/ai/transports/openai_transport.py @@ -6,7 +6,6 @@ API_MODE_CHAT_COMPLETIONS = "chat_completions" API_MODE_RESPONSES = "responses" _VALID_API_MODES = {API_MODE_CHAT_COMPLETIONS, API_MODE_RESPONSES} -_VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} def normalize_api_mode(value: Any, default: str = API_MODE_CHAT_COMPLETIONS) -> str: @@ -23,10 +22,7 @@ def get_api_mode(model_config: Any) -> str: def normalize_reasoning_effort(value: Any, default: str = "medium") -> str: - text = str(value or default).strip().lower() - if text not in _VALID_REASONING_EFFORTS: - return default - return text + return str(value or default).strip().lower() def get_reasoning_payload(model_config: Any) -> dict[str, Any] | None: @@ -39,17 +35,12 @@ def get_reasoning_payload(model_config: Any) -> dict[str, Any] | None: } -_VALID_THINKING_EFFORT_STYLES = {"openai", "anthropic"} - - -def normalize_thinking_effort(value: Any) -> str: - return str(value or "").strip().lower() +_VALID_REASONING_EFFORT_STYLES = {"openai", "anthropic"} def get_thinking_payload(model_config: Any) -> dict[str, Any] | None: - """构建 thinking 请求参数。thinking_effort 非空时用 adaptive,否则回退 legacy budget。""" - effort = normalize_thinking_effort(getattr(model_config, "thinking_effort", "")) - if effort: + """构建 thinking 请求参数。reasoning_enabled 启用时用 adaptive,否则回退 legacy budget。""" + if bool(getattr(model_config, "reasoning_enabled", False)): return {"type": "adaptive"} if not bool(getattr(model_config, "thinking_enabled", False)): return None @@ -60,8 +51,10 @@ def get_thinking_payload(model_config: Any) -> dict[str, Any] | None: def get_effort_payload(model_config: Any) -> dict[str, Any] | None: - """构建 effort 请求参数(仅在 thinking_effort 非空时生效)。""" - effort = normalize_thinking_effort(getattr(model_config, "thinking_effort", "")) + """构建 effort 请求参数(仅在 reasoning_enabled 启用时生效)。""" + if not bool(getattr(model_config, "reasoning_enabled", False)): + return None + effort = str(getattr(model_config, "reasoning_effort", "") or "").strip().lower() if not effort: return None return {"effort": effort} @@ -69,11 +62,11 @@ def get_effort_payload(model_config: Any) -> dict[str, Any] | None: def get_effort_style(model_config: Any) -> str: style = ( - str(getattr(model_config, "thinking_effort_style", "openai") or "openai") + str(getattr(model_config, "reasoning_effort_style", "openai") or "openai") .strip() .lower() ) - return style if style in _VALID_THINKING_EFFORT_STYLES else "openai" + return style if style in _VALID_REASONING_EFFORT_STYLES else "openai" def _stringify_content(value: Any) -> str: @@ -372,18 +365,18 @@ def build_responses_request_body( "max_output_tokens": max_tokens, } reasoning = get_reasoning_payload(model_config) - if reasoning is not None: - body["reasoning"] = reasoning thinking = get_thinking_payload(model_config) - if thinking is not None: - body["thinking"] = thinking effort_payload = get_effort_payload(model_config) if effort_payload is not None: style = get_effort_style(model_config) if style == "anthropic": body["output_config"] = effort_payload else: - body.setdefault("reasoning", {}).update(effort_payload) + body["reasoning"] = effort_payload + elif reasoning is not None: + body["reasoning"] = reasoning + if thinking is not None: + body["thinking"] = thinking if tools: normalized_tools = _normalize_responses_tools(tools, internal_to_api) normalized_tool_choice, selected_tool_name = _normalize_responses_tool_choice( diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 87154c94..ede39a3c 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -638,7 +638,6 @@ async def _internal_probe_handler(self, request: web.Request) -> Response: "api_url": _mask_url(getattr(mcfg, "api_url", "")), "api_mode": getattr(mcfg, "api_mode", "chat_completions"), "thinking_enabled": getattr(mcfg, "thinking_enabled", False), - "thinking_effort": getattr(mcfg, "thinking_effort", ""), "thinking_tool_call_compat": getattr( mcfg, "thinking_tool_call_compat", True ), diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 28b0ae06..7ff8f5f2 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -257,17 +257,12 @@ def _get_value( _VALID_API_MODES = {"chat_completions", "responses"} -_VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} -_VALID_THINKING_EFFORT_STYLES = {"openai", "anthropic"} +_VALID_REASONING_EFFORT_STYLES = {"openai", "anthropic"} -def _resolve_thinking_effort(value: Any, default: str = "") -> str: - return _coerce_str(value, default).strip().lower() - - -def _resolve_thinking_effort_style(value: Any, default: str = "openai") -> str: +def _resolve_reasoning_effort_style(value: Any, default: str = "openai") -> str: style = _coerce_str(value, default).strip().lower() - if style not in _VALID_THINKING_EFFORT_STYLES: + if style not in _VALID_REASONING_EFFORT_STYLES: return default return style @@ -323,10 +318,7 @@ def _resolve_api_mode( def _resolve_reasoning_effort(value: Any, default: str = "medium") -> str: - effort = _coerce_str(value, default).strip().lower() - if effort not in _VALID_REASONING_EFFORTS: - return default - return effort + return _coerce_str(value, default).strip().lower() def _resolve_responses_tool_choice_compat( @@ -1611,13 +1603,9 @@ def _parse_model_pool( item.get("thinking_include_budget"), primary_config.thinking_include_budget, ), - thinking_effort=_resolve_thinking_effort( - item.get("thinking_effort"), - primary_config.thinking_effort, - ), - thinking_effort_style=_resolve_thinking_effort_style( - item.get("thinking_effort_style"), - primary_config.thinking_effort_style, + reasoning_effort_style=_resolve_reasoning_effort_style( + item.get("reasoning_effort_style"), + primary_config.reasoning_effort_style, ), thinking_tool_call_compat=_coerce_bool( item.get("thinking_tool_call_compat"), @@ -1805,18 +1793,11 @@ def _parse_chat_model_config(data: dict[str, Any]) -> ChatModelConfig: 20000, ), thinking_include_budget=thinking_include_budget, - thinking_effort=_resolve_thinking_effort( + reasoning_effort_style=_resolve_reasoning_effort_style( _get_value( data, - ("models", "chat", "thinking_effort"), - "CHAT_MODEL_THINKING_EFFORT", - ), - ), - thinking_effort_style=_resolve_thinking_effort_style( - _get_value( - data, - ("models", "chat", "thinking_effort_style"), - "CHAT_MODEL_THINKING_EFFORT_STYLE", + ("models", "chat", "reasoning_effort_style"), + "CHAT_MODEL_REASONING_EFFORT_STYLE", ), ), thinking_tool_call_compat=thinking_tool_call_compat, @@ -1911,18 +1892,11 @@ def _parse_vision_model_config(data: dict[str, Any]) -> VisionModelConfig: 20000, ), thinking_include_budget=thinking_include_budget, - thinking_effort=_resolve_thinking_effort( - _get_value( - data, - ("models", "vision", "thinking_effort"), - "VISION_MODEL_THINKING_EFFORT", - ), - ), - thinking_effort_style=_resolve_thinking_effort_style( + reasoning_effort_style=_resolve_reasoning_effort_style( _get_value( data, - ("models", "vision", "thinking_effort_style"), - "VISION_MODEL_THINKING_EFFORT_STYLE", + ("models", "vision", "reasoning_effort_style"), + "VISION_MODEL_REASONING_EFFORT_STYLE", ), ), thinking_tool_call_compat=thinking_tool_call_compat, @@ -2031,18 +2005,11 @@ def _parse_security_model_config( 0, ), thinking_include_budget=thinking_include_budget, - thinking_effort=_resolve_thinking_effort( + reasoning_effort_style=_resolve_reasoning_effort_style( _get_value( data, - ("models", "security", "thinking_effort"), - "SECURITY_MODEL_THINKING_EFFORT", - ), - ), - thinking_effort_style=_resolve_thinking_effort_style( - _get_value( - data, - ("models", "security", "thinking_effort_style"), - "SECURITY_MODEL_THINKING_EFFORT_STYLE", + ("models", "security", "reasoning_effort_style"), + "SECURITY_MODEL_REASONING_EFFORT_STYLE", ), ), thinking_tool_call_compat=thinking_tool_call_compat, @@ -2064,8 +2031,7 @@ def _parse_security_model_config( thinking_enabled=False, thinking_budget_tokens=0, thinking_include_budget=True, - thinking_effort="", - thinking_effort_style="openai", + reasoning_effort_style="openai", thinking_tool_call_compat=chat_model.thinking_tool_call_compat, responses_tool_choice_compat=chat_model.responses_tool_choice_compat, responses_force_stateless_replay=chat_model.responses_force_stateless_replay, @@ -2156,18 +2122,11 @@ def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: 0, ), thinking_include_budget=thinking_include_budget, - thinking_effort=_resolve_thinking_effort( + reasoning_effort_style=_resolve_reasoning_effort_style( _get_value( data, - ("models", "agent", "thinking_effort"), - "AGENT_MODEL_THINKING_EFFORT", - ), - ), - thinking_effort_style=_resolve_thinking_effort_style( - _get_value( - data, - ("models", "agent", "thinking_effort_style"), - "AGENT_MODEL_THINKING_EFFORT_STYLE", + ("models", "agent", "reasoning_effort_style"), + "AGENT_MODEL_REASONING_EFFORT_STYLE", ), ), thinking_tool_call_compat=thinking_tool_call_compat, @@ -2346,21 +2305,13 @@ def _parse_historian_model_config( h.get("thinking_budget_tokens"), fallback.thinking_budget_tokens ), thinking_include_budget=thinking_include_budget, - thinking_effort=_resolve_thinking_effort( - _get_value( - {"models": {"historian": h}}, - ("models", "historian", "thinking_effort"), - "HISTORIAN_MODEL_THINKING_EFFORT", - ), - fallback.thinking_effort, - ), - thinking_effort_style=_resolve_thinking_effort_style( + reasoning_effort_style=_resolve_reasoning_effort_style( _get_value( {"models": {"historian": h}}, - ("models", "historian", "thinking_effort_style"), - "HISTORIAN_MODEL_THINKING_EFFORT_STYLE", + ("models", "historian", "reasoning_effort_style"), + "HISTORIAN_MODEL_REASONING_EFFORT_STYLE", ), - fallback.thinking_effort_style, + fallback.reasoning_effort_style, ), thinking_tool_call_compat=thinking_tool_call_compat, responses_tool_choice_compat=responses_tool_choice_compat, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index b36a80b7..3b073d11 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -38,8 +38,7 @@ class ModelPoolEntry: thinking_enabled: bool = False thinking_budget_tokens: int = 0 thinking_include_budget: bool = True - thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) - thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic + reasoning_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = True responses_tool_choice_compat: bool = False responses_force_stateless_replay: bool = False @@ -70,8 +69,7 @@ class ChatModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 20000 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens - thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) - thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic + reasoning_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -97,8 +95,7 @@ class VisionModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 20000 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens - thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) - thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic + reasoning_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -124,8 +121,7 @@ class SecurityModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 0 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens - thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) - thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic + reasoning_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) @@ -177,8 +173,7 @@ class AgentModelConfig: thinking_enabled: bool = False # 是否启用 thinking thinking_budget_tokens: int = 0 # 思维预算 token 数量 thinking_include_budget: bool = True # 是否在请求中发送 budget_tokens - thinking_effort: str = "" # thinking effort 档位(如 low / medium / high / max) - thinking_effort_style: str = "openai" # effort 传参风格:openai / anthropic + reasoning_effort_style: str = "openai" # effort 传参风格:openai / anthropic thinking_tool_call_compat: bool = ( True # 思维链 + 工具调用兼容(回传 reasoning_content) ) diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js index 5b4919a5..1f286c9e 100644 --- a/src/Undefined/webui/static/js/config-form.js +++ b/src/Undefined/webui/static/js/config-form.js @@ -251,7 +251,7 @@ function isLongText(value) { const FIELD_SELECT_OPTIONS = { api_mode: ["chat_completions", "responses"], - thinking_effort_style: ["openai", "anthropic"], + reasoning_effort_style: ["openai", "anthropic"], }; function getFieldSelectOptions(path) { @@ -908,18 +908,10 @@ function buildAotTemplate(path, arr) { if ( !Object.prototype.hasOwnProperty.call( template, - "thinking_effort", + "reasoning_effort_style", ) ) { - template.thinking_effort = ""; - } - if ( - !Object.prototype.hasOwnProperty.call( - template, - "thinking_effort_style", - ) - ) { - template.thinking_effort_style = "openai"; + template.reasoning_effort_style = "openai"; } } return template; @@ -930,8 +922,7 @@ function buildAotTemplate(path, arr) { api_key: "", api_mode: "chat_completions", thinking_tool_call_compat: true, - thinking_effort: "", - thinking_effort_style: "openai", + reasoning_effort_style: "openai", responses_tool_choice_compat: false, responses_force_stateless_replay: false, reasoning_enabled: false, diff --git a/tests/test_llm_request_params.py b/tests/test_llm_request_params.py index a6801b93..4f3e2a1d 100644 --- a/tests/test_llm_request_params.py +++ b/tests/test_llm_request_params.py @@ -118,6 +118,7 @@ async def test_chat_request_uses_model_reasoning_and_request_params( assert fake_client.chat.completions.last_kwargs["extra_body"] == { "metadata": {"source": "config"}, "reasoning": {"effort": "high"}, + "thinking": {"type": "adaptive"}, } assert ( "ignored_keys=model,stream" in caplog.text @@ -217,7 +218,10 @@ async def test_responses_request_normalizes_tool_calls_and_usage() -> None: "name": "lookup", } assert fake_client.responses.last_kwargs["metadata"] == {"source": "config"} - assert fake_client.responses.last_kwargs["extra_body"] == {"custom_flag": "on"} + assert fake_client.responses.last_kwargs["extra_body"] == { + "custom_flag": "on", + "thinking": {"type": "adaptive"}, + } assert "thinking" not in fake_client.responses.last_kwargs message = result["choices"][0]["message"] @@ -932,7 +936,7 @@ async def test_responses_tools_and_tool_choice_use_sanitized_api_names() -> None @pytest.mark.asyncio async def test_thinking_effort_anthropic_style_chat_completions() -> None: - """thinking_effort + anthropic style → adaptive thinking + output_config.effort.""" + """reasoning_effort + anthropic style → adaptive thinking + output_config.effort.""" requester = ModelRequester( http_client=httpx.AsyncClient(), token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), @@ -948,8 +952,9 @@ async def test_thinking_effort_anthropic_style_chat_completions() -> None: api_key="sk-test", model_name="claude-test", max_tokens=4096, - thinking_effort="max", - thinking_effort_style="anthropic", + reasoning_enabled=True, + reasoning_effort="max", + reasoning_effort_style="anthropic", ) await requester.request( @@ -970,7 +975,7 @@ async def test_thinking_effort_anthropic_style_chat_completions() -> None: @pytest.mark.asyncio async def test_thinking_effort_openai_style_responses() -> None: - """thinking_effort + openai style → adaptive thinking + reasoning.effort.""" + """reasoning_effort + openai style → adaptive thinking + reasoning.effort.""" requester = ModelRequester( http_client=httpx.AsyncClient(), token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), @@ -1001,8 +1006,9 @@ async def test_thinking_effort_openai_style_responses() -> None: model_name="gpt-test", max_tokens=4096, api_mode="responses", - thinking_effort="high", - thinking_effort_style="openai", + reasoning_enabled=True, + reasoning_effort="high", + reasoning_effort_style="openai", ) await requester.request( diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index 9fc629c8..6e48a663 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -26,7 +26,6 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> api_url="https://api.example.com/v1", api_mode="responses", thinking_enabled=False, - thinking_effort="", thinking_tool_call_compat=True, responses_tool_choice_compat=False, responses_force_stateless_replay=False, @@ -58,7 +57,6 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> "api_url": "https://api.example.com/...", "api_mode": "responses", "thinking_enabled": False, - "thinking_effort": "", "thinking_tool_call_compat": True, "responses_tool_choice_compat": False, "responses_force_stateless_replay": False, From 983e136980d0803d14ffcb801675fc1c3d7cab15 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 19:27:09 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(config):=20=E6=A8=A1=E6=9D=BF=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=94=AF=E6=8C=81=E6=B8=85=E7=90=86=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=EF=BC=88--prune=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_config_template.py 新增 --prune 选项,列出并删除存在于 config.toml 但不存在于 config.toml.example 中的配置项,需二次确认。 WebUI 同步模板后若检测到多余项也会弹窗询问是否清理。 Co-Authored-By: Claude Opus 4.6 --- scripts/sync_config_template.py | 66 ++++++++++++++++- src/Undefined/webui/routes/_config.py | 5 +- src/Undefined/webui/static/js/config-form.js | 34 +++++++++ src/Undefined/webui/static/js/i18n.js | 8 +++ src/Undefined/webui/utils/config_sync.py | 74 +++++++++++++++++++- 5 files changed, 183 insertions(+), 4 deletions(-) diff --git a/scripts/sync_config_template.py b/scripts/sync_config_template.py index 93cff1bb..6e0a24fc 100755 --- a/scripts/sync_config_template.py +++ b/scripts/sync_config_template.py @@ -39,16 +39,41 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="将同步后的完整 TOML 输出到标准输出。", ) + parser.add_argument( + "--prune", + action="store_true", + help="删除存在于 config.toml 但不存在于 config.toml.example 中的配置项(危险操作,需二次确认)。", + ) return parser +def _confirm_prune(removed_paths: list[str]) -> bool: + """显示即将删除的路径并请求用户二次确认。""" + print( + "\n\033[1;31m[sync-config] ⚠ 危险操作:以下配置项不存在于模板中,将被永久删除:\033[0m" + ) + for path in removed_paths: + print(f" \033[31m- {path}\033[0m") + print() + try: + answer = input( + "\033[1;33m确认删除以上配置项?此操作不可撤销。输入 yes 确认: \033[0m" + ) + except (EOFError, KeyboardInterrupt): + print() + return False + return answer.strip().lower() == "yes" + + def main() -> int: args = build_parser().parse_args() + + # 第一轮:不带 prune 的常规同步(或 dry-run 预览) try: result = sync_config_file( config_path=args.config, example_path=args.example, - write=not args.dry_run, + write=not args.dry_run and not args.prune, ) except FileNotFoundError as exc: print(f"[sync-config] 未找到示例配置:{exc}", file=sys.stderr) @@ -63,6 +88,45 @@ def main() -> int: for path in result.added_paths: print(f" + {path}") + if result.removed_paths: + print(f"[sync-config] 多余路径数量: {len(result.removed_paths)}") + for path in result.removed_paths: + print(f" - {path}") + + # --prune 流程:确认后带 prune 重新同步 + if args.prune and result.removed_paths: + if args.dry_run: + print("\n[sync-config] --dry-run 模式,跳过删除。") + elif _confirm_prune(result.removed_paths): + result = sync_config_file( + config_path=args.config, + example_path=args.example, + write=True, + prune=True, + ) + print( + f"\033[1;32m[sync-config] 已删除 {len(result.removed_paths)} 个多余配置项并写回文件。\033[0m" + ) + else: + # 用户取消 prune,仍执行不带 prune 的常规同步 + sync_config_file( + config_path=args.config, + example_path=args.example, + write=True, + prune=False, + ) + print("[sync-config] 已取消删除,仅执行常规同步。") + elif args.prune and not result.removed_paths: + if not args.dry_run: + # 无多余项但仍需写入常规同步结果 + sync_config_file( + config_path=args.config, + example_path=args.example, + write=True, + prune=False, + ) + print("[sync-config] 无多余配置项需要删除。") + if args.stdout: print("\n--- merged config.toml ---\n") print(result.content, end="") diff --git a/src/Undefined/webui/routes/_config.py b/src/Undefined/webui/routes/_config.py index a284d807..d6228f9d 100644 --- a/src/Undefined/webui/routes/_config.py +++ b/src/Undefined/webui/routes/_config.py @@ -151,8 +151,9 @@ async def config_patch_handler(request: web.Request) -> Response: async def sync_config_template_handler(request: web.Request) -> Response: if not check_auth(request): return web.json_response({"error": "Unauthorized"}, status=401) + prune = request.query.get("prune") == "true" try: - result = sync_config_file() + result = sync_config_file(prune=prune) get_config_manager().reload() validation_ok, validation_msg = validate_required_config() return web.json_response( @@ -161,6 +162,8 @@ async def sync_config_template_handler(request: web.Request) -> Response: "message": "Synced", "added_paths": result.added_paths, "added_count": len(result.added_paths), + "removed_paths": result.removed_paths, + "removed_count": len(result.removed_paths), "warning": None if validation_ok else validation_msg, } ) diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js index 1f286c9e..36352996 100644 --- a/src/Undefined/webui/static/js/config-form.js +++ b/src/Undefined/webui/static/js/config-form.js @@ -1091,6 +1091,40 @@ async function syncConfigTemplate(button) { ? ` (+${data.added_count})` : ""; showToast(`${t("config.sync_success")}${suffix}`, "info", 4000); + + if ( + Array.isArray(data.removed_paths) && + data.removed_paths.length > 0 + ) { + const listing = data.removed_paths + .map((p) => ` - ${p}`) + .join("\n"); + if ( + confirm( + `${t("config.prune_confirm")}\n\n${listing}\n\n${t("config.prune_confirm_action")}`, + ) + ) { + const pruneRes = await api( + "/api/config/sync-template?prune=true", + { method: "POST" }, + ); + const pruneData = await pruneRes.json(); + if (pruneData.success) { + await loadConfig(); + showToast( + `${t("config.prune_success")} (-${data.removed_paths.length})`, + "info", + 4000, + ); + } else { + showToast( + `${t("common.error")}: ${pruneData.error || t("config.sync_error")}`, + "error", + 5000, + ); + } + } + } } catch (e) { showSaveStatus("error", t("config.sync_error")); showToast(`${t("common.error")}: ${e.message}`, "error", 5000); diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 2959eec8..b6027322 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -79,6 +79,9 @@ const I18N = { "config.syncing": "同步模板中...", "config.sync_success": "模板同步完成", "config.sync_error": "模板同步失败", + "config.prune_confirm": "以下配置项不存在于模板中,是否删除?", + "config.prune_confirm_action": "点击「确定」将永久删除以上配置项。", + "config.prune_success": "已清理多余配置项", "config.search_placeholder": "搜索配置...", "config.clear_search": "清除搜索", "config.expand_all": "全部展开", @@ -307,6 +310,11 @@ const I18N = { "config.syncing": "Syncing template...", "config.sync_success": "Template sync completed", "config.sync_error": "Template sync failed", + "config.prune_confirm": + "The following config keys do not exist in the template. Delete them?", + "config.prune_confirm_action": + "Click OK to permanently remove the listed keys.", + "config.prune_success": "Removed obsolete config keys", "config.search_placeholder": "Search config...", "config.clear_search": "Clear search", "config.expand_all": "Expand all", diff --git a/src/Undefined/webui/utils/config_sync.py b/src/Undefined/webui/utils/config_sync.py index 61a88626..8b2693db 100644 --- a/src/Undefined/webui/utils/config_sync.py +++ b/src/Undefined/webui/utils/config_sync.py @@ -17,6 +17,7 @@ class ConfigTemplateSyncResult: content: str added_paths: list[str] + removed_paths: list[str] comments: CommentMap @@ -64,6 +65,65 @@ def _collect_added_paths( return added +def _collect_removed_paths( + defaults: TomlData, current: TomlData, prefix: str = "" +) -> list[str]: + """收集存在于 current 但不在 defaults 中的路径(与 _collect_added_paths 互逆)。""" + removed: list[str] = [] + for key, current_value in current.items(): + path = f"{prefix}.{key}" if prefix else key + if key not in defaults: + removed.append(path) + continue + default_value = defaults[key] + if isinstance(current_value, dict) and isinstance(default_value, dict): + removed.extend(_collect_removed_paths(default_value, current_value, path)) + elif _is_array_of_tables(current_value) and _is_array_of_tables(default_value): + if not default_value: + continue + template_item = default_value[0] + for index, current_item in enumerate(current_value): + default_item = ( + default_value[index] + if index < len(default_value) + else template_item + ) + removed.extend( + _collect_removed_paths( + default_item, + current_item, + f"{path}[{index}]", + ) + ) + return removed + + +def _prune_to_template(data: TomlData, template: TomlData) -> TomlData: + """递归移除 data 中不存在于 template 的键。""" + pruned: TomlData = {} + for key, value in data.items(): + if key not in template: + continue + template_value = template[key] + if isinstance(value, dict) and isinstance(template_value, dict): + pruned[key] = _prune_to_template(value, template_value) + elif _is_array_of_tables(value) and _is_array_of_tables(template_value): + if not template_value: + pruned[key] = list(value) + else: + tpl_item = template_value[0] + pruned[key] = [ + _prune_to_template( + item, + template_value[idx] if idx < len(template_value) else tpl_item, + ) + for idx, item in enumerate(value) + ] + else: + pruned[key] = value + return pruned + + def _is_array_of_tables(value: Any) -> bool: return ( isinstance(value, list) @@ -163,12 +223,20 @@ def _merge_comment_maps(current: CommentMap, example: CommentMap) -> CommentMap: return merged -def sync_config_text(current_text: str, example_text: str) -> ConfigTemplateSyncResult: +def sync_config_text( + current_text: str, + example_text: str, + *, + prune: bool = False, +) -> ConfigTemplateSyncResult: current_data = _parse_toml_text(current_text, label="current config") example_data = _parse_toml_text(example_text, label="config example") prepared_example_data = _prepare_pool_model_templates(example_data, current_data) added_paths = _collect_added_paths(prepared_example_data, current_data) + removed_paths = _collect_removed_paths(prepared_example_data, current_data) merged = merge_defaults(prepared_example_data, current_data) + if prune and removed_paths: + merged = _prune_to_template(merged, prepared_example_data) example_comments = parse_comment_map_text(example_text) comments = _merge_comment_maps( parse_comment_map_text(current_text), @@ -178,6 +246,7 @@ def sync_config_text(current_text: str, example_text: str) -> ConfigTemplateSync return ConfigTemplateSyncResult( content=content, added_paths=added_paths, + removed_paths=removed_paths, comments=comments, ) @@ -187,6 +256,7 @@ def sync_config_file( example_path: Path = CONFIG_EXAMPLE_PATH, *, write: bool = True, + prune: bool = False, ) -> ConfigTemplateSyncResult: resolved_example = _resolve_config_example_path(example_path) if resolved_example is None or not resolved_example.exists(): @@ -196,7 +266,7 @@ def sync_config_file( config_path.read_text(encoding="utf-8") if config_path.exists() else "" ) example_text = resolved_example.read_text(encoding="utf-8") - result = sync_config_text(current_text, example_text) + result = sync_config_text(current_text, example_text, prune=prune) if write: config_path.write_text(result.content, encoding="utf-8") return result From ddbb29208652b46a2307454a6f1e9da95652a879 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 20:27:26 +0800 Subject: [PATCH 5/7] fix(webui): preserve passthrough config containers --- src/Undefined/webui/utils/config_sync.py | 25 +++++++++++++-- tests/test_config_template_sync.py | 39 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/Undefined/webui/utils/config_sync.py b/src/Undefined/webui/utils/config_sync.py index 8b2693db..30ae4a78 100644 --- a/src/Undefined/webui/utils/config_sync.py +++ b/src/Undefined/webui/utils/config_sync.py @@ -12,6 +12,8 @@ from .config_io import CONFIG_EXAMPLE_PATH, _resolve_config_example_path from .toml_render import TomlData, merge_defaults, render_toml +_PASSTHROUGH_KEYS = {"request_params"} + @dataclass(frozen=True) class ConfigTemplateSyncResult: @@ -77,6 +79,8 @@ def _collect_removed_paths( continue default_value = defaults[key] if isinstance(current_value, dict) and isinstance(default_value, dict): + if _should_skip_passthrough_recursion(key, path, default_value): + continue removed.extend(_collect_removed_paths(default_value, current_value, path)) elif _is_array_of_tables(current_value) and _is_array_of_tables(default_value): if not default_value: @@ -98,15 +102,21 @@ def _collect_removed_paths( return removed -def _prune_to_template(data: TomlData, template: TomlData) -> TomlData: +def _prune_to_template( + data: TomlData, template: TomlData, prefix: str = "" +) -> TomlData: """递归移除 data 中不存在于 template 的键。""" pruned: TomlData = {} for key, value in data.items(): + path = f"{prefix}.{key}" if prefix else key if key not in template: continue template_value = template[key] if isinstance(value, dict) and isinstance(template_value, dict): - pruned[key] = _prune_to_template(value, template_value) + if _should_skip_passthrough_recursion(key, path, template_value): + pruned[key] = value + else: + pruned[key] = _prune_to_template(value, template_value, path) elif _is_array_of_tables(value) and _is_array_of_tables(template_value): if not template_value: pruned[key] = list(value) @@ -116,6 +126,7 @@ def _prune_to_template(data: TomlData, template: TomlData) -> TomlData: _prune_to_template( item, template_value[idx] if idx < len(template_value) else tpl_item, + f"{path}[{idx}]", ) for idx, item in enumerate(value) ] @@ -124,6 +135,16 @@ def _prune_to_template(data: TomlData, template: TomlData) -> TomlData: return pruned +def _should_skip_passthrough_recursion( + key: str, path: str, template_value: TomlData +) -> bool: + if template_value: + return False + return key in _PASSTHROUGH_KEYS or any( + path.endswith(passthrough_key) for passthrough_key in _PASSTHROUGH_KEYS + ) + + def _is_array_of_tables(value: Any) -> bool: return ( isinstance(value, list) diff --git a/tests/test_config_template_sync.py b/tests/test_config_template_sync.py index e57c2189..62475728 100644 --- a/tests/test_config_template_sync.py +++ b/tests/test_config_template_sync.py @@ -115,3 +115,42 @@ def test_sync_config_text_merges_new_fields_into_existing_pool_model_entries() - assert model["request_params"]["temperature"] == 0.2 assert "models.chat.pool.models[0].api_mode" in result.added_paths assert "models.chat.pool.models[0].request_params" in result.added_paths + + +def test_sync_config_text_prune_preserves_passthrough_request_params() -> None: + current = """ +[models.chat] +api_url = "https://primary.example/v1" +api_key = "primary-key" +model_name = "primary-model" + +[models.chat.request_params] +temperature = 0.2 + +[models.chat.request_params.metadata] +tier = "gold" + +[[models.chat.request_params.tags]] +name = "alpha" + +[models.chat.extra] +flag = true +""" + example = """ +[models.chat] +api_url = "" +api_key = "" +model_name = "" + +[models.chat.request_params] +""" + + result = sync_config_text(current, example, prune=True) + parsed = tomllib.loads(result.content) + request_params = parsed["models"]["chat"]["request_params"] + + assert result.removed_paths == ["models.chat.extra"] + assert request_params["temperature"] == 0.2 + assert request_params["metadata"]["tier"] == "gold" + assert request_params["tags"][0]["name"] == "alpha" + assert "extra" not in parsed["models"]["chat"] From 9aed1ccdfd0b2fb78fcea201138245e38a81be87 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 20:59:07 +0800 Subject: [PATCH 6/7] fix(ai): decouple thinking and reasoning payloads --- scripts/sync_config_template.py | 14 +++- src/Undefined/ai/llm.py | 29 ++++---- .../ai/transports/openai_transport.py | 24 ++---- src/Undefined/skills/agents/README.md | 4 +- tests/test_llm_request_params.py | 74 ++++++++++++++++--- tests/test_sync_config_template_script.py | 56 ++++++++++++++ 6 files changed, 156 insertions(+), 45 deletions(-) create mode 100644 tests/test_sync_config_template_script.py diff --git a/scripts/sync_config_template.py b/scripts/sync_config_template.py index 6e0a24fc..e4320012 100755 --- a/scripts/sync_config_template.py +++ b/scripts/sync_config_template.py @@ -65,6 +65,14 @@ def _confirm_prune(removed_paths: list[str]) -> bool: return answer.strip().lower() == "yes" +def _initial_action_label(*, dry_run: bool, prune: bool) -> str: + if dry_run: + return "预览完成" + if prune: + return "分析完成" + return "同步完成" + + def main() -> int: args = build_parser().parse_args() @@ -82,7 +90,7 @@ def main() -> int: print(f"[sync-config] 配置解析失败:{exc}", file=sys.stderr) return 1 - action = "预览完成" if args.dry_run else "同步完成" + action = _initial_action_label(dry_run=args.dry_run, prune=args.prune) print(f"[sync-config] {action}: {args.config}") print(f"[sync-config] 新增路径数量: {len(result.added_paths)}") for path in result.added_paths: @@ -125,7 +133,9 @@ def main() -> int: write=True, prune=False, ) - print("[sync-config] 无多余配置项需要删除。") + print("[sync-config] 无多余配置项需要删除,已完成常规同步。") + else: + print("[sync-config] 无多余配置项需要删除。") if args.stdout: print("\n--- merged config.toml ---\n") diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index d578b5e4..931f13bc 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -27,7 +27,6 @@ get_api_mode, get_effort_payload, get_effort_style, - get_reasoning_payload, get_thinking_payload, normalize_responses_result, ) @@ -876,6 +875,8 @@ def _build_effective_request_kwargs( getattr(model_config, "request_params", {}), overrides, ) + thinking_override = overrides["thinking"] if "thinking" in overrides else None + has_thinking_override = "thinking" in overrides reserved_fields = ( _RESPONSES_RESERVED_FIELDS if get_api_mode(model_config) == API_MODE_RESPONSES @@ -885,11 +886,15 @@ def _build_effective_request_kwargs( merged, reserved_fields, ) + if has_thinking_override: + ignored.pop("thinking", None) _warn_ignored_request_params( call_type=call_type, model_name=model_config.model_name, ignored=ignored, ) + if has_thinking_override: + allowed["thinking"] = thinking_override return allowed @@ -1453,10 +1458,17 @@ def build_request_body( """构建 API 请求体。""" api_mode = get_api_mode(model_config) extra_kwargs: dict[str, Any] = dict(kwargs) - reasoning_payload = get_reasoning_payload(model_config) + + if "thinking" in extra_kwargs: + normalized = _normalize_thinking_override( + extra_kwargs.get("thinking"), model_config + ) + if normalized is None: + extra_kwargs.pop("thinking", None) + else: + extra_kwargs["thinking"] = normalized if api_mode == API_MODE_RESPONSES: - extra_kwargs.pop("thinking", None) extra_kwargs.pop("reasoning", None) extra_kwargs.pop("reasoning_effort", None) extra_kwargs.pop("output_config", None) @@ -1477,15 +1489,6 @@ def build_request_body( "max_tokens": max_tokens, } - if "thinking" in extra_kwargs: - normalized = _normalize_thinking_override( - extra_kwargs.get("thinking"), model_config - ) - if normalized is None: - extra_kwargs.pop("thinking", None) - else: - extra_kwargs["thinking"] = normalized - extra_kwargs.pop("reasoning", None) extra_kwargs.pop("reasoning_effort", None) extra_kwargs.pop("output_config", None) @@ -1501,8 +1504,6 @@ def build_request_body( body["output_config"] = effort_payload else: body["reasoning"] = effort_payload - elif reasoning_payload is not None: - body["reasoning"] = reasoning_payload if tools: body["tools"] = tools diff --git a/src/Undefined/ai/transports/openai_transport.py b/src/Undefined/ai/transports/openai_transport.py index ba0b0999..c5ab32a2 100644 --- a/src/Undefined/ai/transports/openai_transport.py +++ b/src/Undefined/ai/transports/openai_transport.py @@ -26,22 +26,14 @@ def normalize_reasoning_effort(value: Any, default: str = "medium") -> str: def get_reasoning_payload(model_config: Any) -> dict[str, Any] | None: - if not bool(getattr(model_config, "reasoning_enabled", False)): - return None - return { - "effort": normalize_reasoning_effort( - getattr(model_config, "reasoning_effort", "medium") - ) - } + return get_effort_payload(model_config) _VALID_REASONING_EFFORT_STYLES = {"openai", "anthropic"} def get_thinking_payload(model_config: Any) -> dict[str, Any] | None: - """构建 thinking 请求参数。reasoning_enabled 启用时用 adaptive,否则回退 legacy budget。""" - if bool(getattr(model_config, "reasoning_enabled", False)): - return {"type": "adaptive"} + """构建 thinking 请求参数,仅由 thinking_* 配置控制。""" if not bool(getattr(model_config, "thinking_enabled", False)): return None param: dict[str, Any] = {"type": "enabled"} @@ -54,10 +46,11 @@ def get_effort_payload(model_config: Any) -> dict[str, Any] | None: """构建 effort 请求参数(仅在 reasoning_enabled 启用时生效)。""" if not bool(getattr(model_config, "reasoning_enabled", False)): return None - effort = str(getattr(model_config, "reasoning_effort", "") or "").strip().lower() - if not effort: - return None - return {"effort": effort} + return { + "effort": normalize_reasoning_effort( + getattr(model_config, "reasoning_effort", "medium") + ) + } def get_effort_style(model_config: Any) -> str: @@ -364,7 +357,6 @@ def build_responses_request_body( "model": getattr(model_config, "model_name"), "max_output_tokens": max_tokens, } - reasoning = get_reasoning_payload(model_config) thinking = get_thinking_payload(model_config) effort_payload = get_effort_payload(model_config) if effort_payload is not None: @@ -373,8 +365,6 @@ def build_responses_request_body( body["output_config"] = effort_payload else: body["reasoning"] = effort_payload - elif reasoning is not None: - body["reasoning"] = reasoning if thinking is not None: body["thinking"] = thinking if tools: diff --git a/src/Undefined/skills/agents/README.md b/src/Undefined/skills/agents/README.md index 8f46a53a..81f7d794 100644 --- a/src/Undefined/skills/agents/README.md +++ b/src/Undefined/skills/agents/README.md @@ -44,8 +44,8 @@ responses_force_stateless_replay = false ``` 说明: -- `api_mode = "chat_completions"` 时,旧 `thinking_*` 仍按原逻辑生效;若开启 `reasoning_enabled`,也会额外发送 `reasoning.effort`。 -- `api_mode = "responses"` 时,Agent 的多轮工具调用默认使用 `previous_response_id + function_call_output` 续轮;若开启 `responses_force_stateless_replay`,则会始终改为完整消息重放;旧 `thinking_*` 不会发到 `responses`。 +- `api_mode = "chat_completions"` 时,`thinking_*` 仍按原逻辑生效;若开启 `reasoning_enabled`,会额外发送 `reasoning.effort`。 +- `api_mode = "responses"` 时,`thinking_*` 与 `reasoning_*` 分别独立控制 `thinking` 和 `reasoning.effort` / `output_config.effort`;Agent 的多轮工具调用默认使用 `previous_response_id + function_call_output` 续轮;若开启 `responses_force_stateless_replay`,则会始终改为完整消息重放。 - `thinking_tool_call_compat` 默认 `true`,会把 `reasoning_content` 回填到本地消息历史,便于日志、回放和兼容读取。 兼容的环境变量(会覆盖 `config.toml`): diff --git a/tests/test_llm_request_params.py b/tests/test_llm_request_params.py index 4f3e2a1d..2d8e68ec 100644 --- a/tests/test_llm_request_params.py +++ b/tests/test_llm_request_params.py @@ -118,7 +118,6 @@ async def test_chat_request_uses_model_reasoning_and_request_params( assert fake_client.chat.completions.last_kwargs["extra_body"] == { "metadata": {"source": "config"}, "reasoning": {"effort": "high"}, - "thinking": {"type": "adaptive"}, } assert ( "ignored_keys=model,stream" in caplog.text @@ -194,7 +193,6 @@ async def test_responses_request_normalizes_tool_calls_and_usage() -> None: } ], tool_choice=cast(Any, {"type": "function", "function": {"name": "lookup"}}), - thinking={"enabled": False, "budget_tokens": 0}, ) assert fake_client.responses.last_kwargs is not None @@ -218,10 +216,7 @@ async def test_responses_request_normalizes_tool_calls_and_usage() -> None: "name": "lookup", } assert fake_client.responses.last_kwargs["metadata"] == {"source": "config"} - assert fake_client.responses.last_kwargs["extra_body"] == { - "custom_flag": "on", - "thinking": {"type": "adaptive"}, - } + assert fake_client.responses.last_kwargs["extra_body"] == {"custom_flag": "on"} assert "thinking" not in fake_client.responses.last_kwargs message = result["choices"][0]["message"] @@ -239,6 +234,61 @@ async def test_responses_request_normalizes_tool_calls_and_usage() -> None: "tool_result_start_index": 2, } + await requester._http_client.aclose() + + +@pytest.mark.asyncio +async def test_responses_request_respects_explicit_thinking_override() -> None: + requester = ModelRequester( + http_client=httpx.AsyncClient(), + token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), + ) + fake_client = _FakeClient( + responses=[ + { + "id": "resp_1", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + } + ] + ) + setattr( + requester, + "_get_openai_client_for_model", + lambda _cfg: cast(AsyncOpenAI, fake_client), + ) + cfg = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="gpt-test", + max_tokens=512, + api_mode="responses", + reasoning_enabled=True, + reasoning_effort="low", + ) + + await requester.request( + model_config=cfg, + messages=[{"role": "user", "content": "hello"}], + max_tokens=128, + call_type="chat", + thinking={"enabled": False, "budget_tokens": 0}, + ) + + assert fake_client.responses.last_kwargs is not None + assert fake_client.responses.last_kwargs["reasoning"] == {"effort": "low"} + assert fake_client.responses.last_kwargs["extra_body"] == { + "thinking": {"budget_tokens": 0, "type": "disabled"}, + } + + await requester._http_client.aclose() + def test_normalize_responses_result_falls_back_to_output_text_and_scalar_content() -> ( None @@ -936,7 +986,7 @@ async def test_responses_tools_and_tool_choice_use_sanitized_api_names() -> None @pytest.mark.asyncio async def test_thinking_effort_anthropic_style_chat_completions() -> None: - """reasoning_effort + anthropic style → adaptive thinking + output_config.effort.""" + """thinking_enabled + anthropic style → legacy thinking + output_config.effort.""" requester = ModelRequester( http_client=httpx.AsyncClient(), token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), @@ -952,6 +1002,8 @@ async def test_thinking_effort_anthropic_style_chat_completions() -> None: api_key="sk-test", model_name="claude-test", max_tokens=4096, + thinking_enabled=True, + thinking_budget_tokens=8000, reasoning_enabled=True, reasoning_effort="max", reasoning_effort_style="anthropic", @@ -966,7 +1018,7 @@ async def test_thinking_effort_anthropic_style_chat_completions() -> None: kw = fake_client.chat.completions.last_kwargs assert kw is not None - assert kw["extra_body"]["thinking"] == {"type": "adaptive"} + assert kw["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 8000} assert kw["extra_body"]["output_config"] == {"effort": "max"} assert "reasoning" not in kw.get("extra_body", {}) @@ -975,7 +1027,7 @@ async def test_thinking_effort_anthropic_style_chat_completions() -> None: @pytest.mark.asyncio async def test_thinking_effort_openai_style_responses() -> None: - """reasoning_effort + openai style → adaptive thinking + reasoning.effort.""" + """thinking_enabled + openai style → legacy thinking + reasoning.effort.""" requester = ModelRequester( http_client=httpx.AsyncClient(), token_usage_storage=cast(TokenUsageStorage, _FakeUsageStorage()), @@ -1006,6 +1058,8 @@ async def test_thinking_effort_openai_style_responses() -> None: model_name="gpt-test", max_tokens=4096, api_mode="responses", + thinking_enabled=True, + thinking_budget_tokens=8000, reasoning_enabled=True, reasoning_effort="high", reasoning_effort_style="openai", @@ -1020,7 +1074,7 @@ async def test_thinking_effort_openai_style_responses() -> None: kw = fake_client.responses.last_kwargs assert kw is not None - assert kw["extra_body"]["thinking"] == {"type": "adaptive"} + assert kw["extra_body"]["thinking"] == {"type": "enabled", "budget_tokens": 8000} assert kw["reasoning"] == {"effort": "high"} assert "output_config" not in kw and "output_config" not in kw.get("extra_body", {}) diff --git a/tests/test_sync_config_template_script.py b/tests/test_sync_config_template_script.py new file mode 100644 index 00000000..3f14aec4 --- /dev/null +++ b/tests/test_sync_config_template_script.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +def _load_script_module() -> ModuleType: + script_path = ( + Path(__file__).resolve().parent.parent / "scripts" / "sync_config_template.py" + ) + spec = importlib.util.spec_from_file_location( + "sync_config_template_script", script_path + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_prune_mode_reports_analysis_before_write( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_script_module() + calls: list[tuple[bool, bool]] = [] + + def fake_sync_config_file( + *, + config_path: Path, + example_path: Path, + write: bool = True, + prune: bool = False, + ) -> SimpleNamespace: + del config_path, example_path + calls.append((write, prune)) + return SimpleNamespace( + content="", + added_paths=[], + removed_paths=["models.chat.extra"], + comments={}, + ) + + monkeypatch.setattr(module, "sync_config_file", fake_sync_config_file) + monkeypatch.setattr(module, "_confirm_prune", lambda _paths: False) + monkeypatch.setattr(sys, "argv", ["sync_config_template.py", "--prune"]) + + assert module.main() == 0 + + output = capsys.readouterr().out + assert "[sync-config] 分析完成:" in output + assert calls == [(False, False), (True, False)] From d64bddadd8799bbb9612557dac960c1ed2b31109 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 21:30:22 +0800 Subject: [PATCH 7/7] fix(webui): preview template sync before pruning --- src/Undefined/webui/routes/_config.py | 14 ++- src/Undefined/webui/static/js/config-form.js | 96 +++++++++-------- src/Undefined/webui/utils/config_sync.py | 37 +++---- tests/test_config_template_sync.py | 32 ++++++ tests/test_webui_management_api.py | 102 ++++++++++++++++++- 5 files changed, 210 insertions(+), 71 deletions(-) diff --git a/src/Undefined/webui/routes/_config.py b/src/Undefined/webui/routes/_config.py index d6228f9d..0afeaa01 100644 --- a/src/Undefined/webui/routes/_config.py +++ b/src/Undefined/webui/routes/_config.py @@ -1,3 +1,4 @@ +import asyncio import tomllib from pathlib import Path from tempfile import NamedTemporaryFile @@ -152,14 +153,21 @@ async def sync_config_template_handler(request: web.Request) -> Response: if not check_auth(request): return web.json_response({"error": "Unauthorized"}, status=401) prune = request.query.get("prune") == "true" + write = request.query.get("write", "true").lower() != "false" try: - result = sync_config_file(prune=prune) - get_config_manager().reload() - validation_ok, validation_msg = validate_required_config() + result = await asyncio.to_thread(sync_config_file, prune=prune, write=write) + validation_ok = True + validation_msg: str | None = None + if write: + await asyncio.to_thread(get_config_manager().reload) + validation_ok, validation_msg = await asyncio.to_thread( + validate_required_config + ) return web.json_response( { "success": True, "message": "Synced", + "preview": not write, "added_paths": result.added_paths, "added_count": len(result.added_paths), "removed_paths": result.removed_paths, diff --git a/src/Undefined/webui/static/js/config-form.js b/src/Undefined/webui/static/js/config-form.js index 36352996..aafb0048 100644 --- a/src/Undefined/webui/static/js/config-form.js +++ b/src/Undefined/webui/static/js/config-form.js @@ -1067,63 +1067,73 @@ async function syncConfigTemplate(button) { setButtonLoading(button, true); showSaveStatus("saving", t("config.syncing")); try { - const res = await api("/api/config/sync-template", { method: "POST" }); - const data = await res.json(); - if (!data.success) { + const previewRes = await api("/api/config/sync-template?write=false", { + method: "POST", + }); + const previewData = await previewRes.json(); + if (!previewData.success) { showSaveStatus("error", t("config.save_error")); showToast( - `${t("common.error")}: ${data.error || t("config.sync_error")}`, + `${t("common.error")}: ${previewData.error || t("config.sync_error")}`, "error", 5000, ); return; } + + let shouldPrune = false; + if ( + Array.isArray(previewData.removed_paths) && + previewData.removed_paths.length > 0 + ) { + const listing = previewData.removed_paths + .map((p) => ` - ${p}`) + .join("\n"); + shouldPrune = confirm( + `${t("config.prune_confirm")}\n\n${listing}\n\n${t("config.prune_confirm_action")}`, + ); + } + + const finalUrl = shouldPrune + ? "/api/config/sync-template?prune=true" + : "/api/config/sync-template"; + const finalRes = await api(finalUrl, { method: "POST" }); + const finalData = await finalRes.json(); + if (!finalData.success) { + showSaveStatus("error", t("config.save_error")); + showToast( + `${t("common.error")}: ${finalData.error || t("config.sync_error")}`, + "error", + 5000, + ); + return; + } + await loadConfig(); showSaveStatus("saved", t("config.saved")); - if (data.warning) { + if (finalData.warning) { showToast( - `${t("common.warning")}: ${data.warning}`, + `${t("common.warning")}: ${finalData.warning}`, "warning", 5000, ); } - const suffix = Number.isFinite(data.added_count) - ? ` (+${data.added_count})` - : ""; - showToast(`${t("config.sync_success")}${suffix}`, "info", 4000); - - if ( - Array.isArray(data.removed_paths) && - data.removed_paths.length > 0 - ) { - const listing = data.removed_paths - .map((p) => ` - ${p}`) - .join("\n"); - if ( - confirm( - `${t("config.prune_confirm")}\n\n${listing}\n\n${t("config.prune_confirm_action")}`, - ) - ) { - const pruneRes = await api( - "/api/config/sync-template?prune=true", - { method: "POST" }, - ); - const pruneData = await pruneRes.json(); - if (pruneData.success) { - await loadConfig(); - showToast( - `${t("config.prune_success")} (-${data.removed_paths.length})`, - "info", - 4000, - ); - } else { - showToast( - `${t("common.error")}: ${pruneData.error || t("config.sync_error")}`, - "error", - 5000, - ); - } - } + if (shouldPrune) { + const removedCount = Number.isFinite(finalData.removed_count) + ? finalData.removed_count + : Array.isArray(finalData.removed_paths) + ? finalData.removed_paths.length + : 0; + showToast( + `${t("config.prune_success")} (-${removedCount})`, + "info", + 4000, + ); + } else { + const suffix = Number.isFinite(finalData.added_count) + ? ` (+${finalData.added_count})` + : ""; + showToast(`${t("config.sync_success")}${suffix}`, "info", 4000); } } catch (e) { showSaveStatus("error", t("config.sync_error")); diff --git a/src/Undefined/webui/utils/config_sync.py b/src/Undefined/webui/utils/config_sync.py index 30ae4a78..46c16eef 100644 --- a/src/Undefined/webui/utils/config_sync.py +++ b/src/Undefined/webui/utils/config_sync.py @@ -79,12 +79,10 @@ def _collect_removed_paths( continue default_value = defaults[key] if isinstance(current_value, dict) and isinstance(default_value, dict): - if _should_skip_passthrough_recursion(key, path, default_value): + if _should_skip_passthrough_recursion(key, default_value): continue removed.extend(_collect_removed_paths(default_value, current_value, path)) elif _is_array_of_tables(current_value) and _is_array_of_tables(default_value): - if not default_value: - continue template_item = default_value[0] for index, current_item in enumerate(current_value): default_item = ( @@ -113,36 +111,27 @@ def _prune_to_template( continue template_value = template[key] if isinstance(value, dict) and isinstance(template_value, dict): - if _should_skip_passthrough_recursion(key, path, template_value): + if _should_skip_passthrough_recursion(key, template_value): pruned[key] = value else: pruned[key] = _prune_to_template(value, template_value, path) elif _is_array_of_tables(value) and _is_array_of_tables(template_value): - if not template_value: - pruned[key] = list(value) - else: - tpl_item = template_value[0] - pruned[key] = [ - _prune_to_template( - item, - template_value[idx] if idx < len(template_value) else tpl_item, - f"{path}[{idx}]", - ) - for idx, item in enumerate(value) - ] + tpl_item = template_value[0] + pruned[key] = [ + _prune_to_template( + item, + template_value[idx] if idx < len(template_value) else tpl_item, + f"{path}[{idx}]", + ) + for idx, item in enumerate(value) + ] else: pruned[key] = value return pruned -def _should_skip_passthrough_recursion( - key: str, path: str, template_value: TomlData -) -> bool: - if template_value: - return False - return key in _PASSTHROUGH_KEYS or any( - path.endswith(passthrough_key) for passthrough_key in _PASSTHROUGH_KEYS - ) +def _should_skip_passthrough_recursion(key: str, template_value: TomlData) -> bool: + return not template_value and key in _PASSTHROUGH_KEYS def _is_array_of_tables(value: Any) -> bool: diff --git a/tests/test_config_template_sync.py b/tests/test_config_template_sync.py index 62475728..c26e107b 100644 --- a/tests/test_config_template_sync.py +++ b/tests/test_config_template_sync.py @@ -154,3 +154,35 @@ def test_sync_config_text_prune_preserves_passthrough_request_params() -> None: assert request_params["metadata"]["tier"] == "gold" assert request_params["tags"][0]["name"] == "alpha" assert "extra" not in parsed["models"]["chat"] + + +def test_sync_config_text_prune_only_preserves_exact_passthrough_keys() -> None: + current = """ +[models.chat] +api_url = "https://primary.example/v1" +api_key = "primary-key" +model_name = "primary-model" + +[models.chat.request_params] +temperature = 0.2 + +[models.chat.custom_request_params] +temperature = 0.8 +""" + example = """ +[models.chat] +api_url = "" +api_key = "" +model_name = "" + +[models.chat.request_params] + +[models.chat.custom_request_params] +""" + + result = sync_config_text(current, example, prune=True) + parsed = tomllib.loads(result.content) + + assert result.removed_paths == ["models.chat.custom_request_params.temperature"] + assert parsed["models"]["chat"]["request_params"]["temperature"] == 0.2 + assert parsed["models"]["chat"]["custom_request_params"] == {} diff --git a/tests/test_webui_management_api.py b/tests/test_webui_management_api.py index d4caedfa..be44bffe 100644 --- a/tests/test_webui_management_api.py +++ b/tests/test_webui_management_api.py @@ -10,7 +10,7 @@ from Undefined.webui import app as webui_app from Undefined.webui.app import create_app from Undefined.webui.core import SessionStore -from Undefined.webui.routes import _auth, _index, _shared, _system +from Undefined.webui.routes import _auth, _config, _index, _shared, _system from Undefined.webui.routes._shared import ( REDIRECT_TO_CONFIG_ONCE_APP_KEY, SESSION_COOKIE, @@ -156,6 +156,106 @@ async def _fake_runtime() -> tuple[bool, bool, str]: assert payload["advice"] +async def test_sync_config_template_handler_preview_skips_reload( + monkeypatch: Any, +) -> None: + request = _request(query={"write": "false"}) + calls: list[tuple[str, object, object]] = [] + + async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + return func(*args, **kwargs) + + def _fake_validate_required_config() -> tuple[bool, str]: + calls.append(("validate", None, None)) + return True, "OK" + + def _fake_sync_config_file(*, write: bool = True, prune: bool = False) -> Any: + calls.append(("sync", write, prune)) + return SimpleNamespace( + added_paths=["models.chat.api_mode"], + removed_paths=["models.chat.extra"], + ) + + monkeypatch.setattr(_config, "check_auth", lambda _request: True) + monkeypatch.setattr( + cast(Any, getattr(_config, "asyncio")), "to_thread", _fake_to_thread + ) + monkeypatch.setattr(_config, "sync_config_file", _fake_sync_config_file) + monkeypatch.setattr( + _config, + "get_config_manager", + lambda: SimpleNamespace( + reload=lambda: calls.append(("reload", None, None)), + ), + ) + monkeypatch.setattr( + _config, "validate_required_config", _fake_validate_required_config + ) + + response = await _config.sync_config_template_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert payload["success"] is True + assert payload["preview"] is True + assert payload["warning"] is None + assert payload["added_count"] == 1 + assert payload["removed_count"] == 1 + assert calls == [("sync", False, False)] + + +async def test_sync_config_template_handler_write_reloads_and_validates( + monkeypatch: Any, +) -> None: + request = _request(query={"prune": "true"}) + calls: list[tuple[str, object, object]] = [] + + async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: + return func(*args, **kwargs) + + def _fake_validate_required_config() -> tuple[bool, str]: + calls.append(("validate", None, None)) + return False, "missing required field" + + def _fake_sync_config_file(*, write: bool = True, prune: bool = False) -> Any: + calls.append(("sync", write, prune)) + return SimpleNamespace( + added_paths=[], + removed_paths=["models.chat.extra"], + ) + + monkeypatch.setattr(_config, "check_auth", lambda _request: True) + monkeypatch.setattr( + cast(Any, getattr(_config, "asyncio")), "to_thread", _fake_to_thread + ) + monkeypatch.setattr(_config, "sync_config_file", _fake_sync_config_file) + monkeypatch.setattr( + _config, + "get_config_manager", + lambda: SimpleNamespace( + reload=lambda: calls.append(("reload", None, None)), + ), + ) + monkeypatch.setattr( + _config, "validate_required_config", _fake_validate_required_config + ) + + response = await _config.sync_config_template_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert payload["success"] is True + assert payload["preview"] is False + assert payload["warning"] == "missing required field" + assert calls == [ + ("sync", True, True), + ("reload", None, None), + ("validate", None, None), + ] + + def test_create_app_registers_management_routes() -> None: app = create_app() routes = {