From bb9108b0e21d4c91e86a5ce8c6ffae0f5b87a724 Mon Sep 17 00:00:00 2001 From: DragonNameLeo <15801369+DragonNameLeo@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:31:22 +0800 Subject: [PATCH 1/2] fix(llm): make Gemini validation work with bare base URL and current models - Default a bare https://generativelanguage.googleapis.com to /v1beta for generateContent / streamGenerateContent, matching model listing and the Gemini ASR path (validation previously 404'd while model listing worked). - Retry once without thinkingConfig when the model rejects the channel-level thinking-off config with a 400, instead of failing the call. - Switch the LLM / Omni default model from gemini-2.5-flash (no longer available to new users) to gemini-3.5-flash-lite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJvDYNu6QoSEhJ4yGSuRqk --- .../crates/openless-core/src/llm_gemini.rs | 209 +++++++++++++++--- .../openless-core/src/provider_rules.rs | 6 +- .../lib/ipc/mock-provider-descriptors.json | 4 +- .../ipc/provider-descriptors.generated.json | 4 +- 4 files changed, 183 insertions(+), 40 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/llm_gemini.rs b/openless-all/app/crates/openless-core/src/llm_gemini.rs index 329080e14..c91bdffdb 100644 --- a/openless-all/app/crates/openless-core/src/llm_gemini.rs +++ b/openless-all/app/crates/openless-core/src/llm_gemini.rs @@ -319,23 +319,57 @@ impl GeminiProvider { }) } - async fn send_unary(&self, url: &str, body: &Value) -> Result { + async fn post( + &self, + url: &str, + body: &Value, + sse: bool, + ) -> Result { let mut request = self .client .post(url) .header("Content-Type", "application/json"); + if sse { + request = request.header("Accept", "text/event-stream"); + } if !self.config.api_key.trim().is_empty() { request = request.header("x-goog-api-key", self.config.api_key.as_str()); } - let request = request.json(body); - - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), - }; + request + .json(body) + .send() + .await + .map_err(llm_error_from_reqwest) + } + async fn post_unary_once( + &self, + url: &str, + body: &Value, + ) -> Result<(reqwest::StatusCode, String), LLMError> { + let response = self.post(url, body, false).await?; let status = response.status(); let body_text = response.text().await.map_err(llm_error_from_reqwest)?; + Ok((status, body_text)) + } + + fn log_thinking_fallback(&self) { + log::warn!( + "[llm] gemini model={} rejected the thinking-off config; retrying with the model's default thinking", + self.config.model + ); + } + + async fn send_unary(&self, url: &str, body: &Value) -> Result { + let (status, body_text) = self.post_unary_once(url, body).await?; + let (status, body_text) = + match body_without_rejected_thinking_config(status, &body_text, body) { + Some(retry_body) => { + self.log_thinking_fallback(); + self.post_unary_once(url, &retry_body).await? + } + None => (status, body_text), + }; let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); let preview = safe_str_slice(&body_text, preview_end); @@ -362,34 +396,24 @@ impl GeminiProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let mut request = self - .client - .post(url) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("x-goog-api-key", self.config.api_key.as_str()); + let mut response = self.post(url, body, true).await?; + if !response.status().is_success() { + let status = response.status(); + let body_text = response.text().await.map_err(llm_error_from_reqwest)?; + let Some(retry_body) = body_without_rejected_thinking_config(status, &body_text, body) + else { + return Err(http_failure(status, &body_text)); + }; + self.log_thinking_fallback(); + response = self.post(url, &retry_body, true).await?; } - let request = request.json(body); - - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), - }; let status = response.status(); if !status.is_success() { let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); - return Err(LLMError::InvalidResponse { - status: status.as_u16(), - body: preview.to_string(), - }); + return Err(http_failure(status, &body_text)); } - let mut response = response; // Byte-level buffering — `reqwest::chunk()` may split in the middle // of a multi-byte UTF-8 character (CJK / emoji); calling from_utf8 on // each chunk independently would treat a valid SSE stream as @@ -465,6 +489,16 @@ impl GeminiProvider { // ─────────────────────── internal helpers ─────────────────────── +fn http_failure(status: reqwest::StatusCode, body_text: &str) -> LLMError { + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(body_text, preview_end); + log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); + LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + } +} + fn user_content(text: &str) -> Value { json!({ "role": "user", "parts": [{ "text": text }] }) } @@ -585,20 +619,55 @@ fn qa_messages_to_contents(messages: &[QaChatMessage]) -> Vec { /// /// OpenLess keeps no per-model adaptation table: when thinking is enabled, /// no thinkingConfig is sent; when disabled, it sends the official -/// `thinkingBudget = 0` that expresses "no thinking". If a specific model -/// doesn't support the field or can't fully disable thinking, the Gemini API -/// itself handles that. +/// `thinkingBudget = 0` that expresses "no thinking". A model that rejects +/// it (Gemini 3+ models only take `thinkingLevel`, some can't stop thinking) +/// is retried once without it — see `body_without_rejected_thinking_config`. fn disabled_thinking_config() -> Value { json!({ "thinkingBudget": 0 }) } +/// Retry body for a 400 that blames the thinking config: the same request +/// with `thinkingConfig` dropped, so the model falls back to its own default +/// thinking instead of failing the call. `None` when the failure is anything +/// else or the request carried no thinkingConfig. +fn body_without_rejected_thinking_config( + status: reqwest::StatusCode, + response_body: &str, + request_body: &Value, +) -> Option { + if status != reqwest::StatusCode::BAD_REQUEST + || !response_body.to_ascii_lowercase().contains("thinking") + { + return None; + } + request_body["generationConfig"].get("thinkingConfig")?; + let mut retry_body = request_body.clone(); + retry_body["generationConfig"] + .as_object_mut()? + .remove("thinkingConfig"); + Some(retry_body) +} + +/// Base path with the API version defaulted like model listing and Gemini +/// ASR (`asr::gemini::endpoint_url`): a bare +/// `https://generativelanguage.googleapis.com` would otherwise POST to +/// `/models/...` and 404. +fn api_base_path(url: &reqwest::Url) -> String { + let path = url.path().trim_end_matches('/'); + if path.is_empty() { + "/v1beta".to_owned() + } else { + path.to_owned() + } +} + fn generate_content_url(base_url: &str, model: &str) -> String { let trimmed = base_url.trim(); let Ok(mut url) = reqwest::Url::parse(trimmed) else { let fallback = trimmed.trim_end_matches('/'); return format!("{fallback}/models/{model}:generateContent"); }; - let path = url.path().trim_end_matches('/'); + let path = api_base_path(&url); url.set_path(&format!("{path}/models/{model}:generateContent")); url.to_string() } @@ -609,7 +678,7 @@ fn stream_generate_content_url(base_url: &str, model: &str) -> String { let fallback = trimmed.trim_end_matches('/'); return format!("{fallback}/models/{model}:streamGenerateContent?alt=sse"); }; - let path = url.path().trim_end_matches('/'); + let path = api_base_path(&url); url.set_path(&format!("{path}/models/{model}:streamGenerateContent")); let existing_query = url .query_pairs() @@ -693,6 +762,80 @@ mod tests { ); } + #[test] + fn content_urls_default_bare_host_to_v1beta() { + // Model listing and Gemini ASR already default a bare host to + // /v1beta; the LLM call must too, or validation 404s. + for base in [ + "https://generativelanguage.googleapis.com", + "https://generativelanguage.googleapis.com/", + ] { + assert_eq!( + generate_content_url(base, "gemini-3.5-flash-lite"), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:generateContent" + ); + assert_eq!( + stream_generate_content_url(base, "gemini-3.5-flash-lite"), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:streamGenerateContent?alt=sse" + ); + } + assert_eq!( + generate_content_url("https://proxy.example/v1", "m"), + "https://proxy.example/v1/models/m:generateContent" + ); + } + + #[test] + fn rejected_thinking_config_is_retried_without_it() { + let cfg = GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta"); + let body = GeminiProvider::new(cfg).build_generate_body("SYS", vec![user_content("hi")]); + let rejection = r#"{"error":{"code":400,"message":"Budget 0 is invalid. This model only works in thinking mode.","status":"INVALID_ARGUMENT"}}"#; + + let retry = body_without_rejected_thinking_config( + reqwest::StatusCode::BAD_REQUEST, + rejection, + &body, + ) + .expect("thinking rejection should be retried"); + assert!(retry["generationConfig"].get("thinkingConfig").is_none()); + assert_eq!( + retry["generationConfig"]["temperature"], + body["generationConfig"]["temperature"] + ); + assert_eq!(retry["contents"], body["contents"]); + assert_eq!(retry["systemInstruction"], body["systemInstruction"]); + } + + #[test] + fn unrelated_failures_are_not_retried() { + let cfg = GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta"); + let body = GeminiProvider::new(cfg).build_generate_body("SYS", vec![user_content("hi")]); + assert!(body_without_rejected_thinking_config( + reqwest::StatusCode::BAD_REQUEST, + r#"{"error":{"code":400,"message":"API key not valid."}}"#, + &body, + ) + .is_none()); + assert!(body_without_rejected_thinking_config( + reqwest::StatusCode::NOT_FOUND, + r#"{"error":{"message":"thinking model not found"}}"#, + &body, + ) + .is_none()); + + let thinking_on = GeminiProvider::new( + GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta") + .with_thinking_enabled(true), + ) + .build_generate_body("SYS", vec![user_content("hi")]); + assert!(body_without_rejected_thinking_config( + reqwest::StatusCode::BAD_REQUEST, + r#"{"error":{"message":"thinking is not supported"}}"#, + &thinking_on, + ) + .is_none()); + } + #[test] fn stream_generate_content_url_appends_alt_sse() { let a = stream_generate_content_url("https://x/v1beta", "gemini-2.5-flash"); diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 27a7e3df6..63ae3b8cb 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -646,7 +646,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { "siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"), "atlascloud" => Some("qwen/qwen3.5-flash"), "openai" | "cometapi" => Some("gpt-4o"), - "gemini" => Some("gemini-2.5-flash"), + "gemini" => Some("gemini-3.5-flash-lite"), crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL), "mimo" => Some("xiaomi/mimo-v2-flash"), "openrouterFree" => Some("qwen/qwen3-coder:free"), @@ -740,7 +740,7 @@ pub fn default_omni_endpoint(provider_type: &str) -> Option<&'static str> { pub fn default_omni_model(provider_type: &str) -> Option<&'static str> { match provider_type { "openai" => Some("gpt-4o-audio-preview"), - "gemini" => Some("gemini-2.5-flash"), + "gemini" => Some("gemini-3.5-flash-lite"), "dashscope-omni" => Some("qwen3-omni-flash"), _ => None, } @@ -1351,7 +1351,7 @@ mod tests { "https://api.openai.com/v1/chat/completions/", default_llm_endpoint("openai").unwrap() )); - assert_eq!(default_llm_model("gemini"), Some("gemini-2.5-flash")); + assert_eq!(default_llm_model("gemini"), Some("gemini-3.5-flash-lite")); assert_eq!( default_omni_endpoint("dashscope-omni"), Some("https://dashscope.aliyuncs.com/compatible-mode/v1") diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 65aa91cd6..ff6340da7 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -96,7 +96,7 @@ "providerType": "gemini", "labelKey": "gemini", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "authRequirement": "api_key", "validationProbe": "llm_text", "staticModels": [], @@ -764,7 +764,7 @@ "providerType": "gemini", "labelKey": "omniGemini", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "authRequirement": "api_key", "validationProbe": "omni_text", "staticModels": [], diff --git a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json index 5c1be1f81..618826b6d 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -477,7 +477,7 @@ { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "defaultRequestFormat": null, "kind": "llm", "labelKey": "gemini", @@ -768,7 +768,7 @@ { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", - "defaultModel": "gemini-2.5-flash", + "defaultModel": "gemini-3.5-flash-lite", "defaultRequestFormat": null, "kind": "omni", "labelKey": "omniGemini", From d4866229884212cd7012d0218733b98a9e867e17 Mon Sep 17 00:00:00 2001 From: DragonNameLeo <15801369+DragonNameLeo@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:32:43 +0800 Subject: [PATCH 2/2] fix(llm): ask to turn thinking on when Gemini rejects thinking turned off gemini-3.5-flash-lite (the Gemini channel default) answers the thinking-off config (thinkingBudget 0) with the generic "Request contains an invalid argument.", so the earlier retry that looked for "thinking" in the error body never fired and validation kept failing with a bare 400. - On a 400 for a request that carried thinkingConfig, re-send it once without thinkingConfig. If that passes, fail with llmThinkingRequired instead of the HTTP error; any other 400 keeps its original error. - Pass the code through validation (polish failures arrive as "LLM provider failed: parse error: ") and show it in the provider settings and the channel list as "turn Thinking on", in all eight UI languages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Abbfv5hvANpC7Sku5KEXJ3 --- .../crates/openless-core/src/llm_gemini.rs | 276 ++++++++++++------ .../openless-core/src/provider_service.rs | 19 +- openless-all/app/src/i18n/de.ts | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/es.ts | 3 + openless-all/app/src/i18n/fr.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../app/src/pages/settings/ChannelList.tsx | 1 + .../src/pages/settings/ProvidersSection.tsx | 1 + 12 files changed, 229 insertions(+), 90 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/llm_gemini.rs b/openless-all/app/crates/openless-core/src/llm_gemini.rs index c91bdffdb..88f1157d9 100644 --- a/openless-all/app/crates/openless-core/src/llm_gemini.rs +++ b/openless-all/app/crates/openless-core/src/llm_gemini.rs @@ -342,40 +342,46 @@ impl GeminiProvider { .map_err(llm_error_from_reqwest) } - async fn post_unary_once( + /// Whether a 400 came from the thinking-off config: Gemini rejects it + /// with the generic "Request contains an invalid argument." + /// (gemini-3.5-flash-lite), so only re-sending the request without + /// `thinkingConfig` tells it apart from other bad arguments. + async fn rejects_thinking_off( &self, url: &str, + status: reqwest::StatusCode, body: &Value, - ) -> Result<(reqwest::StatusCode, String), LLMError> { - let response = self.post(url, body, false).await?; - let status = response.status(); - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - Ok((status, body_text)) - } - - fn log_thinking_fallback(&self) { - log::warn!( - "[llm] gemini model={} rejected the thinking-off config; retrying with the model's default thinking", - self.config.model - ); + sse: bool, + ) -> bool { + let Some(probe_body) = body_without_thinking_config(status, body) else { + return false; + }; + let rejected = self + .post(url, &probe_body, sse) + .await + .is_ok_and(|probe| probe.status().is_success()); + if rejected { + log::warn!( + "[llm] gemini model={} rejects thinking turned off; the channel needs thinking on", + self.config.model + ); + } + rejected } async fn send_unary(&self, url: &str, body: &Value) -> Result { - let (status, body_text) = self.post_unary_once(url, body).await?; - let (status, body_text) = - match body_without_rejected_thinking_config(status, &body_text, body) { - Some(retry_body) => { - self.log_thinking_fallback(); - self.post_unary_once(url, &retry_body).await? - } - None => (status, body_text), - }; + let response = self.post(url, body, false).await?; + let status = response.status(); + let body_text = response.text().await.map_err(llm_error_from_reqwest)?; let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); let preview = safe_str_slice(&body_text, preview_end); log::info!("[llm] HTTP {} body={}", status.as_u16(), preview); if !status.is_success() { + if self.rejects_thinking_off(url, status, body, false).await { + return Err(thinking_required_error()); + } return Err(LLMError::InvalidResponse { status: status.as_u16(), body: preview.to_string(), @@ -397,21 +403,14 @@ impl GeminiProvider { C: Fn() -> bool + Send + Sync, { let mut response = self.post(url, body, true).await?; - if !response.status().is_success() { - let status = response.status(); - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let Some(retry_body) = body_without_rejected_thinking_config(status, &body_text, body) - else { - return Err(http_failure(status, &body_text)); - }; - self.log_thinking_fallback(); - response = self.post(url, &retry_body, true).await?; - } - let status = response.status(); if !status.is_success() { let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - return Err(http_failure(status, &body_text)); + let error = http_failure(status, &body_text); + if self.rejects_thinking_off(url, status, body, true).await { + return Err(thinking_required_error()); + } + return Err(error); } // Byte-level buffering — `reqwest::chunk()` may split in the middle @@ -621,31 +620,34 @@ fn qa_messages_to_contents(messages: &[QaChatMessage]) -> Vec { /// no thinkingConfig is sent; when disabled, it sends the official /// `thinkingBudget = 0` that expresses "no thinking". A model that rejects /// it (Gemini 3+ models only take `thinkingLevel`, some can't stop thinking) -/// is retried once without it — see `body_without_rejected_thinking_config`. +/// fails with `llmThinkingRequired`, which asks the user to turn thinking +/// on — see `GeminiProvider::rejects_thinking_off`. fn disabled_thinking_config() -> Value { json!({ "thinkingBudget": 0 }) } -/// Retry body for a 400 that blames the thinking config: the same request -/// with `thinkingConfig` dropped, so the model falls back to its own default -/// thinking instead of failing the call. `None` when the failure is anything -/// else or the request carried no thinkingConfig. -fn body_without_rejected_thinking_config( +/// The same request with `thinkingConfig` dropped, to probe a 400. `None` +/// when the failure is anything else or the request carried no +/// thinkingConfig. +fn body_without_thinking_config( status: reqwest::StatusCode, - response_body: &str, request_body: &Value, ) -> Option { - if status != reqwest::StatusCode::BAD_REQUEST - || !response_body.to_ascii_lowercase().contains("thinking") - { + if status != reqwest::StatusCode::BAD_REQUEST { return None; } request_body["generationConfig"].get("thinkingConfig")?; - let mut retry_body = request_body.clone(); - retry_body["generationConfig"] + let mut probe_body = request_body.clone(); + probe_body["generationConfig"] .as_object_mut()? .remove("thinkingConfig"); - Some(retry_body) + Some(probe_body) +} + +/// Shown by the provider settings as "turn thinking on" +/// (`settings.providers.llmThinkingRequired`). +fn thinking_required_error() -> LLMError { + LLMError::ParseError("llmThinkingRequired".to_string()) } /// Base path with the API version defaulted like model listing and Gemini @@ -785,55 +787,151 @@ mod tests { ); } + const GENERIC_INVALID_ARGUMENT: &str = r#"{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}"#; + + /// Answers each request on its own connection: Gemini's generic 400 when + /// `reject(body)` holds, else a one-word reply. Returns the base URL and + /// the request bodies seen so far. + async fn gemini_server( + reject: fn(&str) -> bool, + ) -> (String, std::sync::Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}/v1beta", listener.local_addr().unwrap()); + let bodies = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = std::sync::Arc::clone(&bodies); + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + let body = loop { + let read = stream.read(&mut buffer).await.unwrap(); + assert_ne!(read, 0, "request ended before body"); + request.extend_from_slice(&buffer[..read]); + let Some(index) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + else { + continue; + }; + let header = String::from_utf8_lossy(&request[..index]).to_ascii_lowercase(); + let length: usize = header + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + if request.len() >= index + 4 + length { + break String::from_utf8_lossy(&request[index + 4..index + 4 + length]) + .into_owned(); + } + }; + let (status, reply) = if reject(&body) { + ("400 Bad Request", GENERIC_INVALID_ARGUMENT) + } else { + ( + "200 OK", + r#"{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}"#, + ) + }; + seen.lock().unwrap().push(body); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + (base_url, bodies) + } + + fn provider_for(base_url: &str, thinking_enabled: bool) -> GeminiProvider { + // Do not inherit machine-specific proxy settings or the global cache. + GeminiProvider { + config: GeminiConfig::new("k", "gemini-3.5-flash-lite", base_url) + .with_thinking_enabled(thinking_enabled), + client: reqwest::Client::builder().no_proxy().build().unwrap(), + } + } + + fn is_thinking_required(result: &Result) -> bool { + matches!(result, Err(LLMError::ParseError(code)) if code == "llmThinkingRequired") + } + + #[tokio::test] + async fn rejected_thinking_off_asks_to_turn_thinking_on() { + // gemini-3.5-flash-lite answers thinkingBudget 0 with the generic + // INVALID_ARGUMENT and accepts the request without thinkingConfig. + let (base_url, bodies) = gemini_server(|body| body.contains("thinkingConfig")).await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + + let unary = provider + .send_unary(&generate_content_url(&base_url, "m"), &body) + .await; + assert!(is_thinking_required(&unary)); + let streaming = provider + .send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| {}, + || false, + ) + .await; + assert!(is_thinking_required(&streaming)); + + // Each call: the rejected request, then one probe without thinkingConfig. + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 4); + assert!(!bodies[1].contains("thinkingConfig")); + assert!(!bodies[3].contains("thinkingConfig")); + } + + #[tokio::test] + async fn other_invalid_arguments_keep_the_http_error() { + let (base_url, bodies) = gemini_server(|_| true).await; + let url = generate_content_url(&base_url, "m"); + + // The probe fails too, so thinking is not the cause. + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider.send_unary(&url, &body).await; + assert!(matches!( + result, + Err(LLMError::InvalidResponse { status: 400, .. }) + )); + assert_eq!(bodies.lock().unwrap().len(), 2); + + // Thinking on sends no thinkingConfig, so there is nothing to probe. + let provider = provider_for(&base_url, true); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider.send_unary(&url, &body).await; + assert!(matches!( + result, + Err(LLMError::InvalidResponse { status: 400, .. }) + )); + assert_eq!(bodies.lock().unwrap().len(), 3); + } + #[test] - fn rejected_thinking_config_is_retried_without_it() { + fn only_a_400_with_thinking_config_is_probed() { let cfg = GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta"); let body = GeminiProvider::new(cfg).build_generate_body("SYS", vec![user_content("hi")]); - let rejection = r#"{"error":{"code":400,"message":"Budget 0 is invalid. This model only works in thinking mode.","status":"INVALID_ARGUMENT"}}"#; - - let retry = body_without_rejected_thinking_config( - reqwest::StatusCode::BAD_REQUEST, - rejection, - &body, - ) - .expect("thinking rejection should be retried"); - assert!(retry["generationConfig"].get("thinkingConfig").is_none()); + + let probe = body_without_thinking_config(reqwest::StatusCode::BAD_REQUEST, &body) + .expect("a 400 on the thinking-off request should be probed"); + assert!(probe["generationConfig"].get("thinkingConfig").is_none()); assert_eq!( - retry["generationConfig"]["temperature"], + probe["generationConfig"]["temperature"], body["generationConfig"]["temperature"] ); - assert_eq!(retry["contents"], body["contents"]); - assert_eq!(retry["systemInstruction"], body["systemInstruction"]); - } + assert_eq!(probe["contents"], body["contents"]); + assert_eq!(probe["systemInstruction"], body["systemInstruction"]); - #[test] - fn unrelated_failures_are_not_retried() { - let cfg = GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta"); - let body = GeminiProvider::new(cfg).build_generate_body("SYS", vec![user_content("hi")]); - assert!(body_without_rejected_thinking_config( - reqwest::StatusCode::BAD_REQUEST, - r#"{"error":{"code":400,"message":"API key not valid."}}"#, - &body, - ) - .is_none()); - assert!(body_without_rejected_thinking_config( - reqwest::StatusCode::NOT_FOUND, - r#"{"error":{"message":"thinking model not found"}}"#, - &body, - ) - .is_none()); - - let thinking_on = GeminiProvider::new( - GeminiConfig::new("k", "gemini-3.8-flash", "https://x/v1beta") - .with_thinking_enabled(true), - ) - .build_generate_body("SYS", vec![user_content("hi")]); - assert!(body_without_rejected_thinking_config( - reqwest::StatusCode::BAD_REQUEST, - r#"{"error":{"message":"thinking is not supported"}}"#, - &thinking_on, - ) - .is_none()); + assert!(body_without_thinking_config(reqwest::StatusCode::NOT_FOUND, &body).is_none()); + assert!( + body_without_thinking_config(reqwest::StatusCode::TOO_MANY_REQUESTS, &body).is_none() + ); } #[test] diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index e5f135a0d..27557b7ae 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -688,8 +688,10 @@ fn sanitize_validation_error(error: BackendError) -> BackendError { "llmTokenLimitInvalid", "llmThinkingBudgetInvalid", "llmProtocolHeaderConflict", + "llmThinkingRequired", ] { - if message == code || message == format!("parse error: {code}") { + // Polish failures arrive as "LLM provider failed: parse error: {code}". + if message == code || message.ends_with(&format!("parse error: {code}")) { return provider_error(code); } } @@ -2378,4 +2380,19 @@ mod tests { assert_eq!(unique.len(), actual.len()); } } + + #[test] + fn validation_keeps_llm_codes_from_polish_failures() { + for message in [ + "llmThinkingRequired", + "LLM provider failed: parse error: llmThinkingRequired", + ] { + let error = sanitize_validation_error(provider_error(message)); + assert_eq!(error.message, "llmThinkingRequired"); + } + let error = sanitize_validation_error(provider_error( + "LLM provider failed: invalid response: status 400, body: secret", + )); + assert_eq!(error.message, "providerHttpStatus:400"); + } } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ac3e6e9ac..e184da178 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1714,6 +1714,7 @@ export const de: typeof zhCN = { nameHint: 'Dieser Name unterscheidet Kanäle desselben Anbieters. Er beeinflusst weder Modell noch Verbindung.', errModel: 'Modell', + errThinking: 'Denkmodus einschalten', verify: 'Prüfen', verifyHint: 'Prüft diesen Kanal mit einem echten API-Aufruf auf aktuelle Funktionsfähigkeit', errTimeout: 'Zeitüberschreitung', @@ -1939,6 +1940,8 @@ export const de: typeof zhCN = { 'Messages setzt die Authentifizierungs- und Versionsheader automatisch. Entferne x-api-key und anthropic-version aus den zusätzlichen Headern.', llmStreamError: 'Der Server hat einen Streamfehler gemeldet. Prüfe das Modell und die Anfrageparameter.', + llmThinkingRequired: + 'Dieses Modell lässt sich nicht mit ausgeschaltetem Denkmodus nutzen. Schalte „Denkmodus“ neben dem Modell ein und prüfe erneut.', saveProtocol: 'Protokolleinstellungen speichern', thinkingModeHint: 'Aktiviere, deaktiviere oder reduziere das Denken mit den vom Anfrageformat und Modell unterstützten Parametern. Dem Prompt werden keine Steueranweisungen hinzugefügt.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 304d39dec..c98919eb7 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1678,6 +1678,7 @@ export const en: typeof zhCN = { nameHint: 'This name distinguishes channels from the same provider. It does not affect the model or connection.', errModel: 'Model', + errThinking: 'turn on Thinking', verify: 'Verify', verifyHint: 'Makes one real API call to check this channel works right now', errTimeout: 'timeout', @@ -1902,6 +1903,8 @@ export const en: typeof zhCN = { llmProtocolHeaderConflict: 'Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.', llmStreamError: 'The server returned a stream error. Check the model and request parameters.', + llmThinkingRequired: + 'This model does not accept thinking turned off. Turn on Thinking next to the model, then verify again.', saveProtocol: 'Save protocol settings', thinkingModeHint: 'Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 831db88b6..175fda7d2 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1704,6 +1704,7 @@ export const es: typeof zhCN = { nameHint: 'El nombre permite distinguir canales del mismo proveedor. No afecta al modelo ni a la conexión.', errModel: 'Modelo', + errThinking: 'activa Razonamiento', verify: 'Verificar', verifyHint: 'Hace una llamada real a la API para comprobar si este canal funciona ahora', errTimeout: 'tiempo agotado', @@ -1926,6 +1927,8 @@ export const es: typeof zhCN = { 'Messages establece automáticamente las cabeceras de autenticación y versión. Elimina x-api-key y anthropic-version de las cabeceras adicionales.', llmStreamError: 'El servidor devolvió un error de transmisión. Comprueba el modelo y los parámetros de la solicitud.', + llmThinkingRequired: + 'Este modelo no admite el razonamiento desactivado. Activa «Razonamiento» junto al modelo y vuelve a verificar.', saveProtocol: 'Guardar ajustes del protocolo', thinkingModeHint: 'Activa, desactiva o reduce el razonamiento mediante los parámetros admitidos por el formato y el modelo. No se añaden instrucciones de control al prompt.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 5a49334dc..902745d89 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1726,6 +1726,7 @@ export const fr: typeof zhCN = { nameHint: 'Ce nom distingue les canaux d’un même fournisseur. Il n’affecte ni le modèle ni la connexion.', errModel: 'Modèle', + errThinking: 'activez Raisonnement', verify: 'Vérifier', verifyHint: 'Effectue un appel réel à l’API pour vérifier que ce canal fonctionne actuellement', @@ -1954,6 +1955,8 @@ export const fr: typeof zhCN = { 'Messages définit automatiquement les en-têtes d’authentification et de version. Retirez x-api-key et anthropic-version des en-têtes supplémentaires.', llmStreamError: 'Le serveur a renvoyé une erreur de flux. Vérifiez le modèle et les paramètres de la requête.', + llmThinkingRequired: + 'Ce modèle n’accepte pas le raisonnement désactivé. Activez « Raisonnement » à côté du modèle, puis vérifiez à nouveau.', saveProtocol: 'Enregistrer les réglages du protocole', thinkingModeHint: 'Activez, désactivez ou réduisez le raisonnement avec les paramètres pris en charge par le format et le modèle. Aucune instruction de contrôle n’est ajoutée au prompt.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c894554ed..d112ed811 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1665,6 +1665,7 @@ export const ja: typeof zhCN = { nameHint: '同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。', errModel: 'モデル', + errThinking: '思考をオンに', verify: '検証', verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します', errTimeout: 'タイムアウト', @@ -1888,6 +1889,8 @@ export const ja: typeof zhCN = { 'Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。', llmStreamError: 'サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。', + llmThinkingRequired: + 'このモデルは思考をオフにできません。モデル欄の横の「思考」をオンにしてから、もう一度検証してください。', saveProtocol: 'プロトコル設定を保存', thinkingModeHint: '選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 59897bc5a..d2dba6ebf 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1654,6 +1654,7 @@ export const ko: typeof zhCN = { nameHint: '같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.', errModel: '모델', + errThinking: '사고 켜기 필요', verify: '검증', verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다', errTimeout: '시간 초과', @@ -1876,6 +1877,8 @@ export const ko: typeof zhCN = { llmProtocolHeaderConflict: 'Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.', llmStreamError: '서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.', + llmThinkingRequired: + '이 모델은 사고를 끌 수 없습니다. 모델 옆의 "사고"를 켠 뒤 다시 검증하세요.', saveProtocol: '프로토콜 설정 저장', thinkingModeHint: '선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 021c8516d..49704724b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1593,6 +1593,7 @@ export const zhCN = { autoSaveHint: '字段修改后自动保存;完成配置后,可手动验证连接。', nameHint: '名称仅用于区分同一供应商的多个渠道,不影响模型或连接。', errModel: '模型', + errThinking: '需开启思考', verify: '验证', verifyHint: '点一下真实调用一次接口,确认这张卡现在能用', errTimeout: '超时', @@ -1802,6 +1803,7 @@ export const zhCN = { llmProtocolHeaderConflict: 'Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。', llmStreamError: '服务端返回流式错误,请检查模型和请求参数。', + llmThinkingRequired: '该模型不接受关闭思考。请打开模型旁的「思考」开关后重新验证。', saveProtocol: '保存协议设置', thinkingModeHint: '按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9498ddf6c..41bd52cfc 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1593,6 +1593,7 @@ export const zhTW: typeof zhCN = { autoSaveHint: '修改欄位後會自動儲存;完成設定後,可手動驗證連線。', nameHint: '名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。', errModel: '模型', + errThinking: '需開啟思考', verify: '驗證', verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用', errTimeout: '逾時', @@ -1802,6 +1803,7 @@ export const zhTW: typeof zhCN = { llmProtocolHeaderConflict: 'Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。', llmStreamError: '伺服器回傳串流錯誤,請檢查模型和請求引數。', + llmThinkingRequired: '此模型不接受關閉思考。請開啟模型旁的「思考」開關後重新驗證。', saveProtocol: '儲存協定設定', thinkingModeHint: '依所選請求格式與模型支援的引數啟用、關閉或降低思考,不在提示詞注入控制指令。', diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 1bea6af17..50abed5ae 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -182,6 +182,7 @@ function shortErrorLabel(raw: string | null, t: ReturnType