diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 4374d17e..1b152f90 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -35,7 +35,7 @@ pub struct OpenAICompatibleConfig { pub request_timeout_secs: u64, /// true = 让支持的 OpenAI-compatible provider 启用推理 / 思考; /// false = 按渠道级官方参数关闭或压低思考。不做模型白名单判断, - /// 具体模型兼容性交给 provider 处理。 + /// 但 OpenAI 官方渠道会跳过已知不支持 reasoning_effort 的普通 chat 模型。 pub thinking_enabled: bool, } @@ -1509,13 +1509,20 @@ fn unix_now_secs() -> u64 { fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICompatibleConfig) { match openai_compatible_thinking_control(&config.provider_id) { Some(ThinkingControl::ReasoningEffort) => { - // OpenAI Chat Completions 的 reasoning_effort 是渠道级请求字段。 - // 关闭时统一压到 low,避免引入模型白名单;不支持该字段的模型由 provider 自行处理。 - body["reasoning_effort"] = json!(if config.thinking_enabled { - "medium" + // OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort; + // 普通 chat 模型会直接 400。其它兼容渠道按渠道声明继续下发。 + let effort = if config.provider_id.trim() == "openai" { + openai_chat_reasoning_effort(&config.model, config.thinking_enabled) } else { - "low" - }); + Some(if config.thinking_enabled { + "medium" + } else { + "low" + }) + }; + if let Some(effort) = effort { + body["reasoning_effort"] = json!(effort); + } } Some(ThinkingControl::EnableThinking) => { body["enable_thinking"] = json!(config.thinking_enabled); @@ -1554,6 +1561,28 @@ fn openai_compatible_thinking_control(provider_id: &str) -> Option Option<&'static str> { + let normalized = model + .trim() + .strip_prefix("openai/") + .unwrap_or_else(|| model.trim()) + .to_ascii_lowercase(); + + if normalized.starts_with("gpt-5-pro") { + return Some("high"); + } + + if normalized.starts_with("o1") + || normalized.starts_with("o3") + || normalized.starts_with("o4") + || normalized.starts_with("gpt-5") + { + Some(if thinking_enabled { "medium" } else { "low" }) + } else { + None + } +} + /// 把 working_languages + front_app 拼成 system prompt 头部前提: /// # 上下文 /// 用户的工作语言:… @@ -2656,14 +2685,14 @@ mod tests { } #[test] - fn openai_chat_body_adds_reasoning_effort_for_openai_channel() { + fn openai_chat_body_adds_reasoning_effort_for_openai_reasoning_model() { let provider = OpenAICompatibleLLMProvider::new( OpenAICompatibleConfig::new( "openai", "OpenAI", "https://api.openai.com/v1", "k", - "any-model", + "gpt-5-mini", ) .with_thinking_enabled(true), ); @@ -2673,6 +2702,49 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + fn openai_chat_body_omits_reasoning_effort_for_non_reasoning_chat_models() { + for model in ["gpt-4o-mini", "gpt-4o", "gpt-4.1-nano"] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + "https://api.openai.com/v1", + "k", + model, + ) + .with_thinking_enabled(true), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert!( + body.get("reasoning_effort").is_none(), + "{model} must not receive reasoning_effort" + ); + } + } + + #[test] + fn openai_chat_body_uses_high_reasoning_effort_for_gpt_5_pro() { + for thinking_enabled in [false, true] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + "https://api.openai.com/v1", + "k", + "gpt-5-pro", + ) + .with_thinking_enabled(thinking_enabled), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["reasoning_effort"], "high"); + } + } + #[test] fn openai_chat_body_lowers_reasoning_when_disabled_for_channel() { let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 1d9d925f..93b0ae08 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -532,7 +532,7 @@ pub struct UserPreferences { pub active_llm_provider: String, // "ark" | "openai" | ... /// LLM 思考模式开关。默认 false 以保持既有「尽量关闭思考」行为; /// Gemini 走原生 thinkingConfig,OpenAI-compatible 路径仅按 provider/channel - /// 下发官方渠道级字段,不用 prompt 注入,也不做模型白名单适配。详见 issue #402。 + /// 下发官方渠道级字段;OpenAI 官方渠道会跳过普通 chat 模型不支持的字段。详见 issue #402。 #[serde(default)] pub llm_thinking_enabled: bool, /// Windows/Linux 粘贴成功后是否恢复用户原剪贴板。默认 true 跟历史行为一致; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 245e7895..be033bf7 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -222,7 +222,7 @@ export interface UserPreferences { microphoneDeviceName: string; activeAsrProvider: string; activeLlmProvider: string; - /** LLM 思考模式开关。默认关闭,保持既有尽量关闭思考的行为。详见 issue #402。 */ + /** LLM 思考模式开关。默认关闭;OpenAI 普通 chat 模型会跳过不支持的字段。详见 issue #402。 */ llmThinkingEnabled: boolean; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ restoreClipboardAfterPaste: boolean;