From 063cd36a3258188c4832e8312704a6aadbb375a7 Mon Sep 17 00:00:00 2001 From: Mavis Date: Sat, 6 Jun 2026 10:13:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E4=BF=AE=E5=A4=8D=20MiniMax=20thin?= =?UTF-8?q?king=20=E5=85=B3=E9=97=AD=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Bug 复现** 用户在 OpenLess 设置里把「思考」关闭后,MiniMax(M3)润色仍输出 thinking 内容块,UI 关闭形同虚设。 **根因** polish.rs::openai_compatible_thinking_control 只在 5 个 provider_id (deepseek / openrouterFree / alibabaCoding / openai / codingPlanX) 上分派 thinking 控制参数;MiniMax 不在列表里,走默认 _ => None 分支,后端根本 不下发 thinking 字段。M3 默认开启 thinking,所以 UI 关闭后服务端仍 输出 块。 **修复** - 新增 ThinkingControl::MiniMaxThinking 变体:关闭下发 `thinking.type = "disabled"`、开启下发 `thinking.type = "adaptive"`, 与 minimaxi 官方 Chat Completions schema 完全对齐。 - openai_compatible_thinking_control 增加 "minimax" provider_id 识别。 - 同时增加 base_url 兜底识别(host 含 "minimax" 关键字即命中),让用户 用「自定义」preset 接入 MiniMax 时也能正确下发 thinking 控制参数。 - 前端 ProvidersSection.tsx::LLM_PRESETS 新增 minimax preset (baseUrl=https://api.minimaxi.com/v1, model=MiniMax-M3)。 - 5 个 i18n 文件 (en/zh-CN/zh-TW/ja/ko) 加 minimax 文案。 - 加 4 个 Rust 单元测试覆盖:preset 命中、adaptive 字面量、custom base_url 兜底、host 提取对尾斜杠与路径的容错。 **兼容性** - 旧 provider (deepseek/openai/codingPlanX/alibabaCoding/openrouterFree) 行为完全不变,新增 case 仅在 provider_id 命中 minimax 或 base_url 含 minimaxi 主机时启用。 - 行为对齐 minimaxi 官方文档: https://platform.minimaxi.com/docs/api-reference/text-chat-openai#thinking-控制 **测试** cargo test --lib polish:: 41 passed, 0 failed 新增 4 个 case: - openai_chat_body_disables_minimax_thinking_by_preset - openai_chat_body_enables_minimax_thinking_with_adaptive_literal - openai_chat_body_falls_back_to_base_url_for_custom_minimax_endpoint - openai_chat_body_base_url_fallback_respects_trailing_slash_and_path --- openless-all/app/src-tauri/src/polish.rs | 137 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + .../src/pages/settings/ProvidersSection.tsx | 12 ++ 7 files changed, 153 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 05c87fa9..7e1685da 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1508,7 +1508,11 @@ fn unix_now_secs() -> u64 { } fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICompatibleConfig) { - match openai_compatible_thinking_control(&config.provider_id) { + // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, + // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 + let control = openai_compatible_thinking_control(&config.provider_id) + .or_else(|| openai_compatible_thinking_control_for_base_url(&config.base_url)); + match control { Some(ThinkingControl::ReasoningEffort) => { // OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort; // 普通 chat 模型会直接 400。其它兼容渠道按渠道声明继续下发。 @@ -1540,6 +1544,17 @@ fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICom "type": if config.thinking_enabled { "enabled" } else { "disabled" }, }); } + // MiniMax OpenAI 兼容 Chat Completions 接受官方 `thinking` 字段,关闭用 + // `disabled`、开启用 `adaptive`(不传即默认开启,这里显式发 `adaptive` 与 + // 渠道文档保持一致)。schema 与 DeepSeekThinking 相同,仅取值字面量不同—— + // 走独立变体避免 OpenLess 默认值(DeepSeek 写"enabled")污染 MiniMax 字段。 + // 注:M2.x 系列不支持关闭,后端即便下发 `disabled` 服务端仍会保持开启; + // 这与 OpenLess 渠道级"按官方参数声明下发"的策略一致,不维护单模型白名单。 + Some(ThinkingControl::MiniMaxThinking) => { + body["thinking"] = json!({ + "type": if config.thinking_enabled { "adaptive" } else { "disabled" }, + }); + } None => {} } } @@ -1550,18 +1565,55 @@ enum ThinkingControl { EnableThinking, OpenRouterReasoning, DeepSeekThinking, + MiniMaxThinking, } fn openai_compatible_thinking_control(provider_id: &str) -> Option { match provider_id.trim() { "deepseek" => Some(ThinkingControl::DeepSeekThinking), + // provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。 + "minimax" => Some(ThinkingControl::MiniMaxThinking), "openrouterFree" => Some(ThinkingControl::OpenRouterReasoning), "alibabaCoding" => Some(ThinkingControl::EnableThinking), "openai" | "codingPlanX" => Some(ThinkingControl::ReasoningEffort), + // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 + // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 _ => None, } } +/// 当 provider_id 不在已知列表(典型场景:用户用"自定义"preset 接入)时, +/// 通过 base_url 推断该走哪种 thinking 控制策略。返回 `None` 表示无法 +/// 识别,沿用原"不主动干预"行为。 +/// +/// 命中策略:base_url 主机名包含厂商关键字。 +fn openai_compatible_thinking_control_for_base_url(base_url: &str) -> Option { + // 抽 host(不区分大小写),允许带端口。`base_url` 末尾可能带 `/v1`、`/v1/`、 + // 甚至 `/v1/chat/completions`——统一取第一个 `/` 段当 host。 + let host = base_url + .trim() + .trim_end_matches('/') + .split_once("://") + .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_ascii_lowercase()) + .unwrap_or_default(); + if host.is_empty() { + return None; + } + if host.contains("minimax") { + return Some(ThinkingControl::MiniMaxThinking); + } + if host.contains("deepseek") { + return Some(ThinkingControl::DeepSeekThinking); + } + if host.contains("openrouter") { + return Some(ThinkingControl::OpenRouterReasoning); + } + if host.contains("dashscope") || host.contains("aliyuncs") { + return Some(ThinkingControl::EnableThinking); + } + None +} + fn openai_chat_reasoning_effort(model: &str, thinking_enabled: bool) -> Option<&'static str> { let normalized = model .trim() @@ -2826,6 +2878,89 @@ mod tests { assert_eq!(body["thinking"]["type"], "disabled"); } + #[test] + fn openai_chat_body_disables_minimax_thinking_by_preset() { + // provider_id 预设命中 "minimax" → 走 MiniMaxThinking 分支,关闭时下发 + // `thinking.type = "disabled"`,与 minimaxi 官方 Chat Completions 文档 + // (https://platform.minimaxi.com/docs/api-reference/text-chat-openai#thinking-控制) 一致。 + // 修这个 bug 前,provider_id 未命中时根本不下发 thinking 参数,UI 关闭无效。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "minimax", + "MiniMax", + "https://api.minimaxi.com/v1", + "k", + "MiniMax-M3", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + + #[test] + fn openai_chat_body_enables_minimax_thinking_with_adaptive_literal() { + // MiniMax 开启 thinking 必须用 `"adaptive"`,不是 DeepSeek 的 `"enabled"`。 + // 若错发 `"enabled"`,M3 会落到未声明的 type 并报参数错误,反而失去思考。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "minimax", + "MiniMax", + "https://api.minimaxi.com/v1", + "k", + "MiniMax-M3", + ) + .with_thinking_enabled(true), + ); + + let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "adaptive"); + } + + #[test] + fn openai_chat_body_falls_back_to_base_url_for_custom_minimax_endpoint() { + // 用 "custom" preset + 自定义 MiniMax base_url 接入时,base_url 兜底 + // 识别需要命中"minimax"关键字,下发 thinking 控制参数。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://api.minimaxi.com/v1", + "k", + "MiniMax-M3", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + + #[test] + fn openai_chat_body_base_url_fallback_respects_trailing_slash_and_path() { + // base_url 可能带尾斜杠或带 /v1 后缀,host 提取逻辑都要能正确识别。 + for base_url in [ + "https://api.minimaxi.com/v1", + "https://api.minimaxi.com/v1/", + "https://api.minimaxi.com", + "https://api.minimaxi.com/", + ] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new("custom", "Custom", base_url, "k", "MiniMax-M3") + .with_thinking_enabled(false), + ); + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + assert_eq!( + body["thinking"]["type"], "disabled", + "base_url={base_url} should trigger MiniMax thinking control" + ); + } + } + #[test] fn openai_chat_body_omits_thinking_control_for_unknown_provider() { let provider = OpenAICompatibleLLMProvider::new( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 507ce497..caa303cb 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -611,6 +611,7 @@ export const en: typeof zhCN = { openrouterFree: 'OpenRouter (free models)', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', + minimax: 'MiniMax (M3)', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian realtime ASR', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index fa83f525..cfd576a3 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -613,6 +613,7 @@ export const ja: typeof zhCN = { openrouterFree: 'OpenRouter(無料モデル)', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', + minimax: 'MiniMax(M3)', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian リアルタイム ASR', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 3e3c0887..a4f22033 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -613,6 +613,7 @@ export const ko: typeof zhCN = { openrouterFree: 'OpenRouter(무료 모델)', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', + minimax: 'MiniMax (M3)', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian 실시간 ASR', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 14289086..ad32c06a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -609,6 +609,7 @@ export const zhCN = { openrouterFree: 'OpenRouter(免费模型)', alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', + minimax: 'MiniMax(M3)', custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里云百炼实时 ASR', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0aafd1b4..05cf5e4d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -611,6 +611,7 @@ export const zhTW: typeof zhCN = { openrouterFree: 'OpenRouter(免費模型)', alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', + minimax: 'MiniMax(M3)', custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里雲百煉即時 ASR', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index dae82737..8c4c58c8 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -116,6 +116,18 @@ const LLM_PRESETS = [ baseUrl: 'https://api.codingplanx.ai/v1', modelPlaceholder: 'gpt-5-mini', }, + { + // MiniMax 国内开放平台(minimaxi.com),OpenAI 兼容 /v1/chat/completions。 + // M3 默认开启 thinking,可通过 `thinking.type = disabled` 关闭。 + // provider_id 在后端 polish.rs::openai_compatible_thinking_control 命中 + // "minimax" → MiniMaxThinking 分支,关闭时下发 disabled、开启时发 adaptive。 + // 走"自定义"preset 接入时由 base_url 含 "minimax" 兜底识别,见 polish.rs。 + // 文档: https://platform.minimaxi.com/docs/api-reference/text-chat-openai#thinking-控制 + id: 'minimax', + nameKey: 'minimax', + baseUrl: 'https://api.minimaxi.com/v1', + modelPlaceholder: 'MiniMax-M3', + }, { id: 'custom', nameKey: 'custom',