diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda2bb711..4d59a2fce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,6 +276,9 @@ jobs: -x :app:rustBuildX86_64Debug --no-daemon + - name: Check Android emulator cleanup + run: bash scripts/android-emulator-cleanup.test.sh + - name: Enable KVM for Android instrumentation tests run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ @@ -391,8 +394,17 @@ jobs: trap - EXIT if [ -n "$emulator_pid" ]; then if kill -0 "$emulator_pid" 2>/dev/null; then - timeout 10s adb -s emulator-5554 emu kill >/dev/null 2>&1 || true + timeout --kill-after=5s 10s adb -s emulator-5554 emu kill >/dev/null 2>&1 || true kill "$emulator_pid" 2>/dev/null || true + # The emulator can ignore SIGTERM while shutting down. + for attempt in {1..10}; do + kill -0 "$emulator_pid" 2>/dev/null || break + sleep 1 + done + if kill -0 "$emulator_pid" 2>/dev/null; then + echo 'Android emulator did not exit; sending SIGKILL' + kill -KILL "$emulator_pid" 2>/dev/null || true + fi fi wait "$emulator_pid" 2>/dev/null || true fi 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..0528ba4dc 100644 --- a/openless-all/app/crates/openless-core/src/llm_gemini.rs +++ b/openless-all/app/crates/openless-core/src/llm_gemini.rs @@ -319,21 +319,58 @@ 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); + request + .json(body) + .send() + .await + .map_err(llm_error_from_reqwest) + } - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), + /// 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, + 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 response = self.post(url, body, false).await?; let status = response.status(); let body_text = response.text().await.map_err(llm_error_from_reqwest)?; @@ -342,6 +379,9 @@ impl GeminiProvider { 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(), @@ -362,34 +402,31 @@ 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 request = request.json(body); - - let response = match request.send().await { - Ok(r) => r, - Err(e) => return Err(llm_error_from_reqwest(e)), - }; - + let mut response = self.post(url, body, true).await?; 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(), - }); + let error = http_failure(status, &body_text); + if should_cancel() { + return Err(LLMError::Network("cancelled".into())); + } + // Match the shared streaming client's cancellation while waiting on network data. + let cancellation = async { + while !should_cancel() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + let thinking_required = tokio::select! { + biased; + _ = cancellation => return Err(LLMError::Network("cancelled".into())), + rejected = self.rejects_thinking_off(url, status, body, true) => rejected, + }; + if thinking_required { + return Err(thinking_required_error()); + } + return Err(error); } - 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 +502,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 +632,58 @@ 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) +/// fails with `llmThinkingRequired`, which asks the user to turn thinking +/// on — see `GeminiProvider::rejects_thinking_off`. fn disabled_thinking_config() -> Value { json!({ "thinkingBudget": 0 }) } +/// 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, + request_body: &Value, +) -> Option { + if status != reqwest::StatusCode::BAD_REQUEST { + return None; + } + request_body["generationConfig"].get("thinkingConfig")?; + let mut probe_body = request_body.clone(); + probe_body["generationConfig"] + .as_object_mut()? + .remove("thinkingConfig"); + 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 +/// 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 +694,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 +778,236 @@ 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" + ); + } + + 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, + probe_delay: Duration, + ) -> (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); + if status == "200 OK" { + tokio::time::sleep(probe_delay).await; + } + 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"), Duration::ZERO).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, Duration::ZERO).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); + } + + #[tokio::test] + async fn cancelled_stream_does_not_probe_thinking_config() { + let (base_url, bodies) = gemini_server(|_| true, Duration::ZERO).await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let result = provider + .send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| panic!("cancelled request emitted text"), + || !bodies.lock().unwrap().is_empty(), + ) + .await; + assert!( + matches!(result, Err(LLMError::Network(ref message)) if message == "cancelled") + ); + assert_eq!(bodies.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn cancelled_stream_interrupts_a_pending_thinking_probe() { + use std::sync::atomic::{AtomicBool, Ordering}; + let (base_url, bodies) = gemini_server( + |body| body.contains("thinkingConfig"), + Duration::from_secs(10), + ) + .await; + let provider = provider_for(&base_url, false); + let body = provider.build_generate_body("SYS", vec![user_content("hi")]); + let cancelled = std::sync::Arc::new(AtomicBool::new(false)); + let cancel = std::sync::Arc::clone(&cancelled); + let seen = std::sync::Arc::clone(&bodies); + tokio::spawn(async move { + while seen.lock().unwrap().len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + cancel.store(true, Ordering::Release); + }); + let result = tokio::time::timeout( + Duration::from_secs(2), + provider.send_streaming( + &stream_generate_content_url(&base_url, "m"), + &body, + |_| panic!("cancelled request emitted text"), + || cancelled.load(Ordering::Acquire), + ), + ) + .await + .expect("cancellation must not wait for the probe response"); + assert!( + matches!(result, Err(LLMError::Network(ref message)) if message == "cancelled") + ); + assert_eq!(bodies.lock().unwrap().len(), 2); + } + + #[test] + 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 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!( + probe["generationConfig"]["temperature"], + body["generationConfig"]["temperature"] + ); + assert_eq!(probe["contents"], body["contents"]); + assert_eq!(probe["systemInstruction"], body["systemInstruction"]); + + 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] 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 968113506..5c6f3b424 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -652,7 +652,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"), @@ -746,7 +746,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, } @@ -1357,7 +1357,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/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index abf797bcc..fed2432e7 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -701,8 +701,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); } } @@ -2410,4 +2412,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/e2e/settings-feedback.spec.ts b/openless-all/app/e2e/settings-feedback.spec.ts index 517a216aa..a462aca68 100644 --- a/openless-all/app/e2e/settings-feedback.spec.ts +++ b/openless-all/app/e2e/settings-feedback.spec.ts @@ -189,3 +189,38 @@ test('style card lists mount and switch without the React 18 PopChild ref warnin await tabs.nth(0).click(); await expect(page.locator('[data-ol-style-pack]').first()).toBeVisible(); }); + +test('Gemini Omni exposes the shared thinking setting and hides it for transcription models', async ({ + page, +}, testInfo) => { + await settings(page, 'AI 服务与模型'); + await page.getByRole('button', { name: '多模态模式', exact: true }).click(); + const card = page.locator('.ol-omni-settings'); + await card.getByRole('combobox', { name: '供应商', exact: true }).click(); + await page.getByRole('option', { name: 'Google Gemini', exact: true }).click(); + const thinking = card.getByRole('switch', { name: '思考', exact: true }); + await expect(thinking).toHaveAttribute('aria-checked', 'false'); + await thinking.click(); + await expect(thinking).toHaveAttribute('aria-checked', 'true'); + await expect + .poll(() => + page.evaluate(async () => { + const { mockSettings } = await import('/src/lib/ipc/mock-data.ts'); + return mockSettings.llmThinkingEnabled; + }), + ) + .toBe(true); + await page.screenshot({ path: testInfo.outputPath('gemini-omni-desktop.png') }); + + await page.setViewportSize({ width: 375, height: 812 }); + await expect(thinking).toBeInViewport({ ratio: 1 }); + const model = card.getByLabel('模型', { exact: true }); + await model.fill('gemini-3.5-transcribe'); + await model.blur(); + await expect(thinking).toHaveCount(0); + await model.fill('gemini-3.5-flash-lite'); + await model.blur(); + await expect(thinking).toHaveAttribute('aria-checked', 'true'); + await thinking.click(); + await expect(thinking).toHaveAttribute('aria-checked', 'false'); +}); diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 82dcb4219..9f23e88e8 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -468,6 +468,16 @@ pub const CATALOG: &[Msg] = &[ "사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.", ), }, + Msg { + key: "settings.providers.llmThinkingRequired", + text: row( + "该模型不接受关闭思考。请打开模型旁的「思考」开关后重新验证。", + "此模型不接受關閉思考。請開啟模型旁的「思考」開關後重新驗證。", + "This model does not accept thinking turned off. Turn on Thinking next to the model, then verify again.", + "このモデルは思考をオフにできません。モデル欄の横の「思考」をオンにしてから、もう一度検証してください。", + "이 모델은 사고를 끌 수 없습니다. 모델 옆의 \"사고\"를 켠 뒤 다시 검증하세요.", + ), + }, // ---- Shell / navigation ------------------------------------------------ Msg { @@ -7997,6 +8007,15 @@ pub fn tr_catalog(lang: L, key: &'static str) -> &'static str { tr(CATALOG, lang, key) } +/// Localize provider guidance at display time; stored validation codes stay unchanged. +pub fn provider_error_text(lang: Lang, message: &str) -> &str { + if message == "llmThinkingRequired" || message.ends_with(": llmThinkingRequired") { + tr_catalog(lang, "settings.providers.llmThinkingRequired") + } else { + message + } +} + /// Global formatted lookup against [`CATALOG`]. pub fn fmt_catalog(lang: L, key: &str, args: &[&dyn Display]) -> String { fmt(CATALOG, lang, key, args) @@ -8050,6 +8069,24 @@ mod tests { assert_eq!(tr(TEST_CATALOG, Lang::En, "k.full"), "Full"); } + #[test] + fn thinking_required_provider_errors_are_localized_without_changing_other_errors() { + for lang in LANGS { + let expected = tr_catalog(lang, "settings.providers.llmThinkingRequired"); + assert_ne!(expected, "settings.providers.llmThinkingRequired"); + for message in [ + "llmThinkingRequired", + "Provider: llmThinkingRequired", + "LLM provider failed: parse error: llmThinkingRequired", + ] { + assert_eq!(provider_error_text(lang, message), expected); + } + for message in ["providerHttpStatus:401", "notllmThinkingRequired"] { + assert_eq!(provider_error_text(lang, message), message); + } + } + } + #[test] fn unknown_key_returns_the_key_itself() { assert_eq!(tr(TEST_CATALOG, Lang::Ja, "k.unknown"), "k.unknown"); diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 4a9e3aa89..a1814718f 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -90,7 +90,9 @@ pub fn apply_selection_voice_target( fcitx5::apply_selection_target(session_id, source, replacement) } pub use hotkeys::{Fcitx5HotkeyListener, LinuxHotkeyEvent}; -pub use i18n::{fmt_catalog as fmt_l10n, tr_catalog as tr_l10n, Lang, LocalePref, LANGS}; +pub use i18n::{ + fmt_catalog as fmt_l10n, provider_error_text, tr_catalog as tr_l10n, Lang, LocalePref, LANGS, +}; pub use local_hotkeys::{ next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event, HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 479b8ebb3..6bc777858 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -1,5 +1,5 @@ use eframe::egui; -use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; +use openless_linux_egui::{fmt_l10n, provider_error_text, tr_l10n, Lang}; use super::icons::{self, IconName}; use super::layout; @@ -445,7 +445,11 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec "$work/adb" << 'ADB' +#!/usr/bin/env bash +if [[ "${STUCK_ADB:-}" == 1 ]]; then + trap '' TERM + exec sleep 10 +fi +exit 0 +ADB +chmod +x "$work/adb" + +for scenario in normal stuck stuck-adb failure no-process; do + expected=0 + if [[ "$scenario" == failure || "$scenario" == no-process ]]; then expected=7; fi + rm -f "$work/ready" + actual=0 + # The outer timeout also kills the fake emulator if the old cleanup hangs. + timeout --kill-after=1s 3s bash -c ' + set -euo pipefail + export PATH="$3:$PATH" + if [[ "$4" == stuck-adb ]]; then export STUCK_ADB=1; fi + timeout() { + local args=("${@/10s/0.1s}") + command timeout "${args[@]/5s/0.1s}" + } + log="$3/emulator.log" + echo emulator-failure > "$log" + emulator_pid="" + if [[ "$4" != no-process ]]; then + ( + if [[ "$4" != normal ]]; then trap "" TERM; fi + echo ready > "$3/ready" + while :; do command sleep 0.1; done + ) & + emulator_pid=$! + until [[ -s "$3/ready" ]]; do command sleep 0.01; done + fi + # Exercise the real loop without waiting ten wall-clock seconds per case. + sleep() { command sleep 0.01; } + eval "$1" + trap cleanup EXIT + (exit "$2") + ' bash "$cleanup" "$expected" "$work" "$scenario" || actual=$? + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $scenario cleanup returned $actual, expected $expected" >&2 + exit 1 + fi + echo "ok: $scenario cleanup preserves exit status $expected" +done diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index f9c1b8959..a87f03a11 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -118,7 +118,7 @@ case "$name" in exec "$TEST_SED" "$@" ;; python3) exec "$TEST_PYTHON" "$@" ;; - sleep) ;; + sleep) "$TEST_SLEEP" 0.02 ;; avdmanager) [ -n "$ANDROID_AVD_HOME" ] || { echo 'SDK tools and emulator require an explicit shared AVD registry' >&2; exit 1; } [ -s "$ANDROID_HOME/emulator/package.xml" ] || { echo 'Error: "emulator" package must be installed!' >&2; exit 1; } @@ -127,6 +127,7 @@ case "$name" in printf 'path=%s\n' "$2" > "$ANDROID_AVD_HOME/openless-ci.ini" ;; timeout) + if [[ "$1" == --kill-after=* ]]; then shift; fi if [ "$1" = 10s ]; then shift; exec "$@"; fi [ "$1" = 600s ] || exit 99 until [ -s "$RUNNER_TEMP/emulator.pid" ]; do "$TEST_SLEEP" 0.01; done diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 4fcaf1f6e..ae3411246 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1801,6 +1801,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', @@ -2029,6 +2030,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 a2057bb11..20d04ad12 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1765,6 +1765,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', @@ -1991,6 +1992,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 a6b5cce3d..6fe87ebd2 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1791,6 +1791,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', @@ -2015,6 +2016,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 8c128a739..a3e8d6ad4 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1813,6 +1813,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', @@ -2043,6 +2044,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 27cbb5ff5..9e3935d69 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1752,6 +1752,7 @@ export const ja: typeof zhCN = { nameHint: '同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。', errModel: 'モデル', + errThinking: '思考をオンに', verify: '検証', verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します', errTimeout: 'タイムアウト', @@ -1977,6 +1978,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 ce3a52c83..126a76a5f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1740,6 +1740,7 @@ export const ko: typeof zhCN = { nameHint: '같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.', errModel: '모델', + errThinking: '사고 켜기 필요', verify: '검증', verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다', errTimeout: '시간 초과', @@ -1964,6 +1965,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 00c418bc5..05a8ea038 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1676,6 +1676,7 @@ export const zhCN = { autoSaveHint: '字段修改后自动保存;完成配置后,可手动验证连接。', nameHint: '名称仅用于区分同一供应商的多个渠道,不影响模型或连接。', errModel: '模型', + errThinking: '需开启思考', verify: '验证', verifyHint: '点一下真实调用一次接口,确认这张卡现在能用', errTimeout: '超时', @@ -1887,6 +1888,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 93285a3d1..bfbf7d59c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1676,6 +1676,7 @@ export const zhTW: typeof zhCN = { autoSaveHint: '修改欄位後會自動儲存;完成設定後,可手動驗證連線。', nameHint: '名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。', errModel: '模型', + errThinking: '需開啟思考', verify: '驗證', verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用', errTimeout: '逾時', @@ -1887,6 +1888,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/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index d1c5f3d19..009f69219 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": [], @@ -776,7 +776,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", diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 45420fd2a..f309dedbb 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -183,6 +183,7 @@ function shortErrorLabel(raw: string | null, t: ReturnType { + void updatePrefs((current) => ({ + ...current, + llmThinkingEnabled: enabled, + })).catch((error) => + console.error('[settings] failed to update Omni thinking mode', error), + ); + }} + /> + ) : undefined + } /> {committedOmniProvider === 'gemini' && (