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 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) + .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 { + active_llm_temperature_value(root).map(|temperature| temperature.to_string()) +} + fn active_llm_extra_headers_json(root: &CredsRoot) -> Result> { let headers = active_llm_extra_headers(root); if headers.is_empty() { @@ -310,6 +330,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 !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)) +} + fn is_valid_header_name(name: &str) -> bool { !name.is_empty() && name.bytes().all(|b| { @@ -1370,6 +1405,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 +1460,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 +1770,52 @@ 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" + ); + } + } + + #[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 1ff47baf..dd216ed7 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 = 按渠道级官方参数关闭或压低思考。不做模型白名单判断, @@ -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: DEFAULT_TEMPERATURE, + temperature, request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, thinking_enabled: false, } @@ -77,6 +80,42 @@ 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" || !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)] @@ -536,9 +575,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 } @@ -2300,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 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -2452,6 +2542,84 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + 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" })]); + + 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_for_builtin_provider() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "openai", + "OpenAI", + "https://api.openai.com/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) + ); + 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] 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..7a54ff09 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; 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 b747c435..55db0445 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〜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 c0a7acd9..5804608d 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~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 bfe6011c..fd213168 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~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 88e7c8a5..71e10f0f 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~2(含邊界),例如 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' && ( - + <> + + + )} )}