From 494710f914d18c41c98dcf220c58a15897c35518 Mon Sep 17 00:00:00 2001 From: Duyi-Wang Date: Tue, 14 Jul 2026 16:39:56 +0800 Subject: [PATCH 1/4] fix(llm): make custom temperature configurable Allow custom OpenAI-compatible LLM providers to omit or override the temperature parameter instead of always sending the default 0.3. - Send temperature only when configured in OpenAI-compatible chat bodies - Keep built-in OpenAI-compatible providers on the existing 0.3 default - Store active custom LLM temperature via ark.temperature - Expose a Temperature field for custom LLM providers in Settings - Reuse the same temperature config for provider validation and real polish - Add tests for omitted/configured temperature and input validation --- .../app/src-tauri/src/commands/credentials.rs | 9 ++ .../app/src-tauri/src/commands/mod.rs | 8 +- .../app/src-tauri/src/commands/providers.rs | 18 +++- openless-all/app/src-tauri/src/coordinator.rs | 10 +- .../src-tauri/src/persistence/credentials.rs | 69 +++++++++++++- openless-all/app/src-tauri/src/polish.rs | 93 ++++++++++++++++++- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../src/pages/settings/ProvidersSection.tsx | 25 +++-- 12 files changed, 222 insertions(+), 20 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 7ed34ba1..732ce00c 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -1,6 +1,7 @@ use super::*; const LLM_EXTRA_HEADERS_ACCOUNT: &str = "ark.extra_headers"; +const LLM_TEMPERATURE_ACCOUNT: &str = "ark.temperature"; #[tauri::command] pub fn get_credentials() -> CredentialsStatus { @@ -171,6 +172,11 @@ pub fn set_credential( let _ = window.emit("credentials:changed", ()); return Ok(()); } + if account == LLM_TEMPERATURE_ACCOUNT { + CredentialsVault::set_active_llm_temperature(&value).map_err(|e| e.to_string())?; + let _ = window.emit("credentials:changed", ()); + return Ok(()); + } let acc = parse_account(&account)?; if let Some(provider) = provider { if !matches!( @@ -276,6 +282,9 @@ pub fn read_credential( if account == LLM_EXTRA_HEADERS_ACCOUNT { return CredentialsVault::get_active_llm_extra_headers_json().map_err(|e| e.to_string()); } + if account == LLM_TEMPERATURE_ACCOUNT { + return Ok(CredentialsVault::get_active_llm_temperature_string()); + } let acc = parse_account(&account)?; if let Some(provider) = provider { CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string()) diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 0c4ea100..30b54775 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -47,9 +47,9 @@ pub(crate) use crate::persistence::{ PreferencesStore, }; pub(crate) use crate::polish::{ - http_client_builder, CodexOAuthConfig, CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError, - OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, - CODEX_OAUTH_PROVIDER_ID, + http_client_builder, openai_compatible_temperature_for_provider, CodexOAuthConfig, + CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError, OpenAICompatibleConfig, + OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, }; #[cfg(not(mobile))] pub(crate) use crate::recorder::{AudioConsumer, Recorder}; @@ -1377,6 +1377,7 @@ mod tests { base_url: format!("http://{}", addr), api_key: String::new(), extra_headers: Default::default(), + temperature: None, }) .await .unwrap(); @@ -1425,6 +1426,7 @@ mod tests { extra_headers: [("x-openless-test-token".to_string(), "secret".to_string())] .into_iter() .collect(), + temperature: None, }) .await .unwrap(); diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 22dae6bd..b1314873 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -110,6 +110,7 @@ pub(crate) struct ProviderConfig { pub(crate) base_url: String, pub(crate) api_key: String, pub(crate) extra_headers: HashMap, + pub(crate) temperature: Option, } fn read_openai_provider_config(kind: &str) -> Result { @@ -132,10 +133,17 @@ fn read_openai_provider_config(kind: &str) -> Result { let base_url = CredentialsVault::get(endpoint_account) .map_err(|e| e.to_string())? .unwrap_or_default(); - let extra_headers = if kind == "llm" { - CredentialsVault::get_active_llm_extra_headers() + let (extra_headers, temperature) = if kind == "llm" { + let active_llm = CredentialsVault::get_active_llm(); + ( + CredentialsVault::get_active_llm_extra_headers(), + openai_compatible_temperature_for_provider( + &active_llm, + CredentialsVault::get_active_llm_temperature(), + ), + ) } else { - HashMap::new() + (HashMap::new(), None) }; if api_key_required && api_key.trim().is_empty() { return Err("API Key 为空".to_string()); @@ -154,6 +162,7 @@ fn read_openai_provider_config(kind: &str) -> Result { base_url, api_key, extra_headers, + temperature, }) } @@ -202,6 +211,7 @@ async fn validate_llm_provider() -> Result<(), String> { model, ) .with_thinking_enabled(llm_thinking_enabled) + .with_temperature(config.temperature) .with_extra_headers(config.extra_headers), ); provider @@ -1021,6 +1031,7 @@ mod tests { ), api_key: String::new(), extra_headers: HashMap::new(), + temperature: None, }) .await .expect_err("closed listener should reject the provider request"); @@ -1084,6 +1095,7 @@ mod tests { base_url: format!("http://{addr}/v1?token=query-secret#client-fragment"), api_key: String::new(), extra_headers: HashMap::new(), + temperature: None, }) .await .unwrap(); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fc5a2736..868f8c97 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -46,8 +46,9 @@ use crate::persistence::{ use crate::llm_gemini::{GeminiConfig, GeminiProvider}; use crate::polish::{ - ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig, - OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, + openai_compatible_temperature_for_provider, ActiveLLMProvider, CodexOAuthConfig, + CodexOAuthLLMProvider, OpenAICompatibleConfig, OpenAICompatibleLLMProvider, + CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, }; use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; @@ -2589,8 +2590,13 @@ fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result HashMap { .unwrap_or_default() } +fn active_llm_temperature(root: &CredsRoot) -> Option { + root.providers + .llm + .get(&root.active.llm) + .and_then(|entry| entry.temperature) + .map(|value| value as f32) +} + +fn active_llm_temperature_string(root: &CredsRoot) -> Option { + root.providers + .llm + .get(&root.active.llm) + .and_then(|entry| entry.temperature) + .map(|value| value.to_string()) +} + fn active_llm_extra_headers_json(root: &CredsRoot) -> Result> { let headers = active_llm_extra_headers(root); if headers.is_empty() { @@ -310,6 +326,21 @@ fn parse_extra_headers_json(value: &str) -> Result> { Ok(headers) } +fn parse_llm_temperature(value: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let temperature: f64 = trimmed.parse().context("temperature must be a number")?; + if !temperature.is_finite() { + anyhow::bail!("temperature must be finite"); + } + if !(0.0..=2.0).contains(&temperature) { + anyhow::bail!("temperature must be between 0 and 2"); + } + Ok(Some(temperature)) +} + fn is_valid_header_name(name: &str) -> bool { !name.is_empty() && name.bytes().all(|b| { @@ -1370,6 +1401,25 @@ impl CredentialsVault { active_llm_extra_headers_json(&load_credentials()) } + pub fn get_active_llm_temperature() -> Option { + let _guard = credentials_lock().lock(); + active_llm_temperature(&load_credentials()) + } + + pub fn get_active_llm_temperature_string() -> Option { + let _guard = credentials_lock().lock(); + active_llm_temperature_string(&load_credentials()) + } + + pub fn set_active_llm_temperature(value: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let temperature = parse_llm_temperature(value)?; + let mut root = load_credentials_for_update()?; + let entry = root.providers.llm.entry(root.active.llm.clone()).or_default(); + entry.temperature = temperature; + save_credentials(&root) + } + pub fn set_active_llm_extra_headers_json(value: &str) -> Result<()> { let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; @@ -1406,7 +1456,7 @@ mod tests { android_persistable_credentials, chunk_json_payload, credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path, load_android_credentials_from_path_with_crypto, load_android_credentials_into_cache_with, - lookup_marketplace_github_token, parse_extra_headers_json, + lookup_marketplace_github_token, parse_extra_headers_json, parse_llm_temperature, reset_credentials_cache_for_tests, write_marketplace_github_token, CredsRoot, MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS, }; @@ -1716,4 +1766,21 @@ mod tests { *credentials_cache().lock() = Some(CredsRoot::default()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn parse_llm_temperature_accepts_empty_and_valid_range() { + assert_eq!(parse_llm_temperature("").unwrap(), None); + assert_eq!(parse_llm_temperature(" 0.3 ").unwrap(), Some(0.3)); + assert_eq!(parse_llm_temperature("2").unwrap(), Some(2.0)); + } + + #[test] + fn parse_llm_temperature_rejects_invalid_values() { + for value in ["abc", "-0.1", "2.1", "NaN", "inf"] { + assert!( + parse_llm_temperature(value).is_err(), + "{value} should be rejected" + ); + } + } } diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 1ff47baf..b590c8ff 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -39,7 +39,7 @@ pub struct OpenAICompatibleConfig { pub api_key: String, pub model: String, pub extra_headers: HashMap, - pub temperature: f32, + pub temperature: Option, pub request_timeout_secs: u64, /// true = 让支持的 OpenAI-compatible provider 启用推理 / 思考; /// false = 按渠道级官方参数关闭或压低思考。不做模型白名单判断, @@ -62,7 +62,7 @@ impl OpenAICompatibleConfig { api_key: api_key.into(), model: model.into(), extra_headers: HashMap::new(), - temperature: DEFAULT_TEMPERATURE, + temperature: Some(DEFAULT_TEMPERATURE), request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, thinking_enabled: false, } @@ -77,6 +77,22 @@ impl OpenAICompatibleConfig { self.extra_headers = extra_headers; self } + + pub fn with_temperature(mut self, temperature: Option) -> Self { + self.temperature = temperature; + self + } +} + +pub fn openai_compatible_temperature_for_provider( + provider_id: &str, + custom_temperature: Option, +) -> Option { + if provider_id == "custom" { + custom_temperature + } else { + Some(DEFAULT_TEMPERATURE) + } } #[derive(Debug, Error)] @@ -536,9 +552,11 @@ impl OpenAICompatibleLLMProvider { let mut body = json!({ "model": self.config.model, "stream": stream, - "temperature": self.config.temperature, "messages": messages, }); + if let Some(temperature) = self.config.temperature { + body["temperature"] = json!(temperature); + } apply_openai_compatible_thinking_control(&mut body, &self.config); body } @@ -2452,6 +2470,75 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + fn chat_body_omits_temperature_when_config_is_none() { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://example.test/v1", + "k", + "gpt-5.6-terra", + ) + .with_temperature(None), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert!(body.get("temperature").is_none()); + } + + #[test] + fn chat_body_sends_configured_temperature() { + for temperature in [0.0, 0.3, 1.0] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://example.test/v1", + "k", + "gpt-5.6-terra", + ) + .with_temperature(Some(temperature)), + ); + + let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["temperature"], json!(temperature)); + } + } + + #[test] + fn chat_body_uses_default_temperature_when_unspecified() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://example.test/v1", + "k", + "qwen3-max", + )); + + let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["temperature"], json!(DEFAULT_TEMPERATURE)); + } + + #[test] + fn provider_temperature_policy_makes_custom_opt_in() { + assert_eq!( + openai_compatible_temperature_for_provider("custom", None), + None + ); + assert_eq!( + openai_compatible_temperature_for_provider("custom", Some(0.7)), + Some(0.7) + ); + assert_eq!( + openai_compatible_temperature_for_provider("openai", None), + Some(DEFAULT_TEMPERATURE) + ); + } + #[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"] { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d12da35e..39001b54 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -825,6 +825,8 @@ export const en: typeof zhCN = { apiKeyLabel: 'API Key', baseUrlLabel: 'Base URL', modelLabel: 'Model', + temperatureLabel: 'Temperature', + temperaturePlaceholder: 'Leave empty to omit; e.g. 0.3', extraHeadersLabel: 'Extra headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: 'Thinking', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b747c435..aed6501c 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -827,6 +827,8 @@ export const ja: typeof zhCN = { apiKeyLabel: 'API キー', baseUrlLabel: 'エンドポイント', modelLabel: 'モデル', + temperatureLabel: 'Temperature', + temperaturePlaceholder: '空欄なら送信しません。例: 0.3', extraHeadersLabel: '追加 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index c0a7acd9..fd22a6ca 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -827,6 +827,8 @@ export const ko: typeof zhCN = { apiKeyLabel: 'API 키', baseUrlLabel: '엔드포인트', modelLabel: '모델', + temperatureLabel: 'Temperature', + temperaturePlaceholder: '비워 두면 보내지 않음. 예: 0.3', extraHeadersLabel: '추가 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '사고', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bfe6011c..7dd0b4bc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -823,6 +823,8 @@ export const zhCN = { apiKeyLabel: 'API 密钥', baseUrlLabel: '接口地址', modelLabel: '模型', + temperatureLabel: 'Temperature', + temperaturePlaceholder: '留空则不发送;例如 0.3', extraHeadersLabel: '额外 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 88e7c8a5..e4515910 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -825,6 +825,8 @@ export const zhTW: typeof zhCN = { apiKeyLabel: 'API 密鑰', baseUrlLabel: '接口地址', modelLabel: '模型', + temperatureLabel: 'Temperature', + temperaturePlaceholder: '留空則不發送;例如 0.3', extraHeadersLabel: '額外 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index c645346b..c700e3a6 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -378,14 +378,23 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { {committedLlmProvider === 'custom' && ( - + <> + + + )} )} From 7d0fc5c173f4d3425cda2e469416bee54637053c Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 14 Jul 2026 18:25:41 +0800 Subject: [PATCH 2/4] docs(settings): show custom temperature range --- openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/ja.ts | 2 +- openless-all/app/src/i18n/ko.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/i18n/zh-TW.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 39001b54..7a54ff09 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -826,7 +826,7 @@ export const en: typeof zhCN = { baseUrlLabel: 'Base URL', modelLabel: 'Model', temperatureLabel: 'Temperature', - temperaturePlaceholder: 'Leave empty to omit; e.g. 0.3', + temperaturePlaceholder: 'Leave empty to omit; range 0–2 inclusive, e.g. 0.3', extraHeadersLabel: 'Extra headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: 'Thinking', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index aed6501c..55db0445 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -828,7 +828,7 @@ export const ja: typeof zhCN = { baseUrlLabel: 'エンドポイント', modelLabel: 'モデル', temperatureLabel: 'Temperature', - temperaturePlaceholder: '空欄なら送信しません。例: 0.3', + temperaturePlaceholder: '空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3', extraHeadersLabel: '追加 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fd22a6ca..5804608d 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -828,7 +828,7 @@ export const ko: typeof zhCN = { baseUrlLabel: '엔드포인트', modelLabel: '모델', temperatureLabel: 'Temperature', - temperaturePlaceholder: '비워 두면 보내지 않음. 예: 0.3', + temperaturePlaceholder: '비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3', extraHeadersLabel: '추가 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '사고', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 7dd0b4bc..fd213168 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -824,7 +824,7 @@ export const zhCN = { baseUrlLabel: '接口地址', modelLabel: '模型', temperatureLabel: 'Temperature', - temperaturePlaceholder: '留空则不发送;例如 0.3', + temperaturePlaceholder: '留空则不发送;范围 0~2(含边界),例如 0.3', extraHeadersLabel: '额外 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e4515910..71e10f0f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -826,7 +826,7 @@ export const zhTW: typeof zhCN = { baseUrlLabel: '接口地址', modelLabel: '模型', temperatureLabel: 'Temperature', - temperaturePlaceholder: '留空則不發送;例如 0.3', + temperaturePlaceholder: '留空則不發送;範圍 0~2(含邊界),例如 0.3', extraHeadersLabel: '額外 Headers', extraHeadersPlaceholder: '{"custom-head":"..."}', thinkingModeLabel: '思考', From 5df1a95c8daf9ac905b79e07cf9f6a99a6139e27 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 21 Jul 2026 22:45:50 +0800 Subject: [PATCH 3/4] fix(llm): honor temperature for legacy custom providers --- openless-all/app/src-tauri/src/polish.rs | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index b590c8ff..f88848fa 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -88,13 +88,33 @@ pub fn openai_compatible_temperature_for_provider( provider_id: &str, custom_temperature: Option, ) -> Option { - if provider_id == "custom" { + if provider_id == "custom" || !is_builtin_llm_provider(provider_id) { custom_temperature } else { Some(DEFAULT_TEMPERATURE) } } +fn is_builtin_llm_provider(provider_id: &str) -> bool { + matches!( + provider_id, + "ark" + | "deepseek" + | "siliconflow" + | "atlascloud" + | "openai" + | "gemini" + | "codex_oauth" + | "mimo" + | "cometapi" + | "openrouterFree" + | "alibabaCoding" + | "codingPlanX" + | "minimax" + | "stepfun" + ) +} + #[derive(Debug, Error)] pub enum LLMError { #[error("missing credentials")] @@ -2537,6 +2557,18 @@ mod tests { openai_compatible_temperature_for_provider("openai", None), Some(DEFAULT_TEMPERATURE) ); + assert_eq!( + openai_compatible_temperature_for_provider("self-hosted", None), + None + ); + assert_eq!( + openai_compatible_temperature_for_provider("self-hosted", Some(0.7)), + Some(0.7) + ); + assert_eq!( + openai_compatible_temperature_for_provider("atlascloud", None), + Some(DEFAULT_TEMPERATURE) + ); } #[test] From 00d3b4cce6cc6aa4fe7543691b4ff8697d9275cb Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 21 Jul 2026 23:11:04 +0800 Subject: [PATCH 4/4] fix(llm): harden custom temperature handling --- .../src-tauri/src/persistence/credentials.rs | 57 ++++++++++--- openless-all/app/src-tauri/src/polish.rs | 83 +++++++++++++++---- 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 6f283a43..8fa1eaa7 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -268,20 +268,24 @@ fn active_llm_extra_headers(root: &CredsRoot) -> HashMap { .unwrap_or_default() } -fn active_llm_temperature(root: &CredsRoot) -> Option { +fn is_valid_llm_temperature(temperature: f64) -> bool { + temperature.is_finite() && (0.0..=2.0).contains(&temperature) +} + +fn active_llm_temperature_value(root: &CredsRoot) -> Option { root.providers .llm .get(&root.active.llm) .and_then(|entry| entry.temperature) - .map(|value| value as f32) + .filter(|temperature| is_valid_llm_temperature(*temperature)) +} + +fn active_llm_temperature(root: &CredsRoot) -> Option { + active_llm_temperature_value(root).map(|temperature| temperature as f32) } fn active_llm_temperature_string(root: &CredsRoot) -> Option { - root.providers - .llm - .get(&root.active.llm) - .and_then(|entry| entry.temperature) - .map(|value| value.to_string()) + active_llm_temperature_value(root).map(|temperature| temperature.to_string()) } fn active_llm_extra_headers_json(root: &CredsRoot) -> Result> { @@ -332,10 +336,10 @@ fn parse_llm_temperature(value: &str) -> Result> { return Ok(None); } let temperature: f64 = trimmed.parse().context("temperature must be a number")?; - if !temperature.is_finite() { - anyhow::bail!("temperature must be finite"); - } - if !(0.0..=2.0).contains(&temperature) { + if !is_valid_llm_temperature(temperature) { + if !temperature.is_finite() { + anyhow::bail!("temperature must be finite"); + } anyhow::bail!("temperature must be between 0 and 2"); } Ok(Some(temperature)) @@ -1783,4 +1787,35 @@ mod tests { ); } } + + #[test] + fn active_llm_temperature_ignores_invalid_persisted_values() { + for temperature in [-0.1, 2.5] { + let mut root = CredsRoot::default(); + root.providers.llm.insert( + root.active.llm.clone(), + super::CredsLlmEntry { + temperature: Some(temperature), + ..Default::default() + }, + ); + + assert_eq!(super::active_llm_temperature(&root), None); + assert_eq!(super::active_llm_temperature_string(&root), None); + } + + let mut root = CredsRoot::default(); + root.providers.llm.insert( + root.active.llm.clone(), + super::CredsLlmEntry { + temperature: Some(0.7), + ..Default::default() + }, + ); + assert_eq!(super::active_llm_temperature(&root), Some(0.7)); + assert_eq!( + super::active_llm_temperature_string(&root).as_deref(), + Some("0.7") + ); + } } diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index f88848fa..dd216ed7 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -55,14 +55,17 @@ impl OpenAICompatibleConfig { api_key: impl Into, model: impl Into, ) -> Self { + let provider_id = provider_id.into(); + let temperature = openai_compatible_temperature_for_provider(&provider_id, None); + Self { - provider_id: provider_id.into(), + provider_id, display_name: display_name.into(), base_url: base_url.into(), api_key: api_key.into(), model: model.into(), extra_headers: HashMap::new(), - temperature: Some(DEFAULT_TEMPERATURE), + temperature, request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, thinking_enabled: false, } @@ -2338,6 +2341,55 @@ mod tests { haystack.find(needle).expect("needle exists") + 1 } + #[tokio::test] + async fn polish_request_omits_temperature_for_unconfigured_custom_provider() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("request must contain headers"); + let body: serde_json::Value = serde_json::from_slice(&request[header_end + 4..]) + .expect("request body must be JSON"); + assert!(body.get("temperature").is_none()); + + let body = r#"{"choices":[{"message":{"content":"polished"}}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + format!("http://{addr}"), + "", + "test-model", + )); + let output = provider + .polish( + "raw text", + PolishMode::Raw, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + &[], + ) + .await + .unwrap(); + + assert_eq!(output, "polished"); + server.join().unwrap(); + } + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -2491,17 +2543,14 @@ mod tests { } #[test] - fn chat_body_omits_temperature_when_config_is_none() { - let provider = OpenAICompatibleLLMProvider::new( - OpenAICompatibleConfig::new( - "custom", - "Custom", - "https://example.test/v1", - "k", - "gpt-5.6-terra", - ) - .with_temperature(None), - ); + fn chat_body_omits_temperature_for_unconfigured_custom_provider() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://example.test/v1", + "k", + "gpt-5.6-terra", + )); let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); @@ -2529,11 +2578,11 @@ mod tests { } #[test] - fn chat_body_uses_default_temperature_when_unspecified() { + fn chat_body_uses_default_temperature_for_builtin_provider() { let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( - "custom", - "Custom", - "https://example.test/v1", + "openai", + "OpenAI", + "https://api.openai.com/v1", "k", "qwen3-max", ));