From 1979eb754e30518884d8f51698c0c212024ce398 Mon Sep 17 00:00:00 2001 From: aqilaziz Date: Thu, 21 May 2026 12:18:39 +0700 Subject: [PATCH] fix(chat): reset web session after rate limits --- app/src/lib/i18n/chunks/de-3.ts | 2 + app/src/lib/i18n/chunks/de-5.ts | 22 +++ src/openhuman/channels/providers/web.rs | 62 +++++-- src/openhuman/channels/providers/web_tests.rs | 168 ++++++++++++++++-- 4 files changed, 228 insertions(+), 26 deletions(-) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 8cbb4e8ae7..e1b209a9b5 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -104,6 +104,8 @@ const de3: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.providerUnavailableTitle': 'Subconscious ist pausiert', + 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c698c292fd..8ec284678d 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -501,6 +501,28 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', + 'settings.developerMenu.mcpServer.title': 'MCP-Server', + 'settings.developerMenu.mcpServer.desc': + 'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman', + 'settings.mcpServer.title': 'MCP-Server', + 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools', + 'settings.mcpServer.toolsSectionDesc': + 'Tools, die über den MCP-stdio-Server verfügbar sind, wenn openhuman-core mcp ausgeführt wird', + 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', + 'settings.mcpServer.configSectionDesc': + 'Wähle deinen MCP-Client aus, um den passenden Konfigurationsausschnitt zu erzeugen', + 'settings.mcpServer.copySnippet': 'In Zwischenablage kopieren', + 'settings.mcpServer.copied': 'Kopiert!', + 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', + 'settings.mcpServer.binaryPathNotFound': + 'OpenHuman-Binärdatei nicht gefunden. Wenn du aus dem Quellcode arbeitest, baue sie mit: cargo build --bin openhuman-core', + 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', + 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', + 'settings.mcpServer.clientCursor': 'Cursor', + 'settings.mcpServer.clientCodex': 'Codex', + 'settings.mcpServer.clientZed': 'Zed', + 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', + 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', }; export default de5; diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index 7ce4509649..4a3586100f 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -326,6 +326,14 @@ fn classify_inference_error(err: &str) -> (&'static str, String) { err, ), ) + } else if lower.contains("402") + || lower.contains("payment required") + || lower.contains("insufficient balance") + { + ( + "budget_exhausted", + with_provider_detail("Insufficient credits. Please top up to continue.", err), + ) } else if crate::openhuman::agent::error::is_max_iterations_error(err) { ( "max_iterations", @@ -353,6 +361,22 @@ fn classify_inference_error(err: &str) -> (&'static str, String) { err, ), ) + } else if lower.contains("error sending request") + || lower.contains("failed to connect") + || lower.contains("connection refused") + || lower.contains("connection reset") + || lower.contains("connection aborted") + || lower.contains("dns") + || lower.contains("network error") + || lower.contains("transport error") + { + ( + "network", + with_provider_detail( + "Could not reach the AI provider. Check your connection and try again.", + err, + ), + ) } else if lower.contains("401") || lower.contains("unauthorized") || lower.contains("api key") { ( "auth_error", @@ -361,14 +385,6 @@ fn classify_inference_error(err: &str) -> (&'static str, String) { err, ), ) - } else if lower.contains("402") - || lower.contains("payment required") - || lower.contains("insufficient balance") - { - ( - "budget_exhausted", - with_provider_detail("Insufficient credits. Please top up to continue.", err), - ) } else if lower.contains("500") || lower.contains("internal server") || lower.contains("service unavailable") @@ -432,6 +448,27 @@ fn classify_inference_error(err: &str) -> (&'static str, String) { } } +/// Transient provider failures can leave the in-memory agent/session in a +/// provider-specific retry state, so retries should rebuild from transcript. +fn should_clear_cached_session_after_error(error_type: &str) -> bool { + matches!( + error_type, + "rate_limited" | "timeout" | "network" | "provider_error" + ) +} + +/// Returns the transient error type that should prevent the current `Agent` +/// from being cached for the next turn. +fn session_reset_error_type(result: &Result) -> Option<&'static str> { + match result { + Ok(_) => None, + Err(err) => { + let (error_type, _) = classify_inference_error(err); + should_clear_cached_session_after_error(error_type).then_some(error_type) + } + } +} + fn prompt_guard_user_message(action: PromptEnforcementAction) -> &'static str { match action { PromptEnforcementAction::Allow => "Message accepted.", @@ -544,7 +581,6 @@ pub async fn start_chat( let thread_id_task = thread_id.clone(); let request_id_task = request_id.clone(); let map_key_task = map_key.clone(); - let user_message = message.clone(); let handle = tokio::spawn(async move { let result = run_chat_task( @@ -970,7 +1006,13 @@ async fn run_chat_task( // Clear the sender so it doesn't hold the channel open across sessions. agent.set_on_progress(None); - { + if let Some(error_type) = session_reset_error_type(&result) { + log::debug!( + "[web-channel] discarded cached session after transient error map_key={} error_type={}", + map_key, + error_type + ); + } else { let mut sessions = THREAD_SESSIONS.lock().await; sessions.insert( map_key, diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index b18ad63556..0e089e9e6b 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -4,23 +4,18 @@ use super::{ extract_provider_error_detail, generic_inference_error_user_message, inference_budget_exceeded_user_message, is_inference_budget_exceeded_error, json_output, key_for, locale_reply_directive, normalize_model_override, optional_f64, optional_string, - provider_role_for_model_override, required_string, schemas, - set_test_forced_run_chat_task_error, start_chat, subscribe_web_channel_events, + provider_role_for_model_override, required_string, schemas, session_reset_error_type, + set_test_forced_run_chat_task_error, should_clear_cached_session_after_error, start_chat, + subscribe_web_channel_events, WebChatTaskResult, }; use crate::core::TypeSchema; +use once_cell::sync::Lazy; +use tokio::sync::Mutex as AsyncMutex; use tokio::time::{timeout, Duration}; -/// Ensures the test-only forced run_chat_task failure toggle is always reset, -/// even if the test panics before reaching explicit cleanup code. -struct TestForcedRunChatTaskErrorGuard; - -impl Drop for TestForcedRunChatTaskErrorGuard { - fn drop(&mut self) { - tokio::spawn(async { - set_test_forced_run_chat_task_error(None).await; - }); - } -} +/// Serializes tests that install a one-shot forced run_chat_task failure. +/// The toggle is process-global and consumed by the next spawned chat task. +static FORCED_RUN_CHAT_TASK_ERROR_LOCK: Lazy> = Lazy::new(|| AsyncMutex::new(())); #[tokio::test] async fn start_chat_validates_required_fields() { @@ -76,12 +71,13 @@ async fn cancel_chat_validates_required_fields() { } #[tokio::test] -async fn start_chat_emits_sanitized_chat_error_on_inference_failure() { +async fn start_chat_emits_sanitized_chat_error_on_transport_failure() { + let _forced_error_lock = FORCED_RUN_CHAT_TASK_ERROR_LOCK.lock().await; + set_test_forced_run_chat_task_error(None).await; set_test_forced_run_chat_task_error(Some( "error sending request for url (https://internal-api.example.invalid/openai/v1/chat/completions)", )) .await; - let _forced_error_guard = TestForcedRunChatTaskErrorGuard; let mut rx = subscribe_web_channel_events(); let request_id = start_chat( @@ -96,7 +92,7 @@ async fn start_chat_emits_sanitized_chat_error_on_inference_failure() { .await .expect("start_chat should accept valid request"); - let expected = generic_inference_error_user_message().to_string(); + let expected = "Could not reach the AI provider. Check your connection and try again."; let recv = timeout(Duration::from_secs(20), async move { loop { let event = rx.recv().await.expect("event stream should stay open"); @@ -113,11 +109,61 @@ async fn start_chat_emits_sanitized_chat_error_on_inference_failure() { .expect("expected chat_error event for started chat request"); let message = recv.message.unwrap_or_default(); + assert_eq!(recv.error_type.as_deref(), Some("network")); assert_eq!(message, expected); assert!( !message.contains("error sending request for url"), "chat error payload must not expose raw transport details" ); + set_test_forced_run_chat_task_error(None).await; +} + +#[tokio::test] +async fn start_chat_emits_rate_limit_chat_error_event() { + let _forced_error_lock = FORCED_RUN_CHAT_TASK_ERROR_LOCK.lock().await; + set_test_forced_run_chat_task_error(None).await; + set_test_forced_run_chat_task_error(Some("HTTP 429 Too Many Requests: rate limit exceeded")) + .await; + + let mut rx = subscribe_web_channel_events(); + let request_id = start_chat( + "rate-limit-client", + "rate-limit-thread", + "Please check the current score.", + None, + None, + None, + None, + ) + .await + .expect("start_chat should accept valid request"); + + let recv = timeout(Duration::from_secs(20), async move { + loop { + let event = rx.recv().await.expect("event stream should stay open"); + if event.event != "chat_error" { + continue; + } + if event.request_id != request_id { + continue; + } + return event; + } + }) + .await + .expect("expected chat_error event for started chat request"); + + assert_eq!(recv.error_type.as_deref(), Some("rate_limited")); + let message = recv.message.unwrap_or_default(); + assert!( + message.contains("transient upstream limit"), + "rate-limit copy should explain the provider-side throttle: {message}" + ); + assert!( + message.contains("retry in this thread"), + "rate-limit copy should tell users the thread is still usable: {message}" + ); + set_test_forced_run_chat_task_error(None).await; } #[test] @@ -140,6 +186,96 @@ fn budget_exceeded_copy_mentions_top_up() { assert!(message.contains("credits")); } +#[test] +fn classify_inference_error_treats_429_balance_as_budget_not_rate_limit() { + let raw = r#"custom_openai API error (429 Too Many Requests): {"error":{"message":"insufficient balance","code":723}}"#; + let (category, message) = classify_inference_error(raw); + assert_eq!(category, "budget_exhausted"); + assert!(message.contains("top up")); + assert!( + !message.contains("being rate-limited"), + "balance errors must not look like transient throttles: {message}" + ); +} + +#[test] +fn classify_inference_error_maps_transport_failures_to_network() { + let raw = + "error sending request for url (https://api.example.com/v1/chat): failed to connect: dns error"; + let (category, message) = classify_inference_error(raw); + assert_eq!(category, "network"); + assert!(message.contains("Could not reach the AI provider")); +} + +#[test] +fn transient_errors_clear_cached_web_session() { + for error_type in ["rate_limited", "timeout", "network", "provider_error"] { + assert!( + should_clear_cached_session_after_error(error_type), + "{error_type} should force a fresh session on retry" + ); + } + + for error_type in [ + "budget_exhausted", + "auth_error", + "model_unavailable", + "context_overflow", + "inference", + ] { + assert!( + !should_clear_cached_session_after_error(error_type), + "{error_type} should not be treated as transient session poison" + ); + } +} + +#[test] +fn transient_chat_errors_skip_cached_web_session_storage() { + for (raw, expected_type) in [ + ( + "HTTP 429 Too Many Requests: rate limit exceeded", + "rate_limited", + ), + ("request timed out while waiting for provider", "timeout"), + ( + "error sending request for url (https://api.example.com): failed to connect", + "network", + ), + ( + "provider returned HTTP 503 Service Unavailable", + "provider_error", + ), + ] { + let result = Err(raw.to_string()); + assert_eq!( + session_reset_error_type(&result), + Some(expected_type), + "{raw} should skip cache storage" + ); + } + + for raw in [ + "OpenHuman API error (402 Payment Required): Budget exceeded", + "OpenAI API error (401 Unauthorized): invalid api key", + "custom_openai API error (404 Not Found): model does not exist", + "unexpected inference parser error", + ] { + let result = Err(raw.to_string()); + assert_eq!( + session_reset_error_type(&result), + None, + "{raw} should remain cacheable" + ); + } + + let result = Ok(WebChatTaskResult { + full_response: "ok".to_string(), + citations: Vec::new(), + }); + assert_eq!(session_reset_error_type(&result), None); +} + #[test] fn extract_provider_error_detail_pulls_openai_message() { let raw = r#"custom_openai API error (404 Not Found): {"error":{"message":"Project `proj_X` does not have access to model `gpt-5.5`","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#;