diff --git a/src/openhuman/inference/provider/compatible_provider_impl.rs b/src/openhuman/inference/provider/compatible_provider_impl.rs index 2bf632dae..b2d639529 100644 --- a/src/openhuman/inference/provider/compatible_provider_impl.rs +++ b/src/openhuman/inference/provider/compatible_provider_impl.rs @@ -163,6 +163,15 @@ impl Provider for OpenAiCompatibleProvider { Some(model), status, ); + } else if super::super::is_local_provider_no_model_loaded(status, &error) { + // Local inference server up but no model loaded — pure local + // user-state, demote instead of paging (TAURI-RUST-DMQ). + super::super::log_local_provider_no_model_loaded( + "chat_completions", + self.name.as_str(), + Some(model), + status, + ); } else if super::super::is_custom_openai_upstream_bad_request_http_400( self.name.as_str(), status, @@ -749,6 +758,19 @@ impl Provider for OpenAiCompatibleProvider { Some(model), status, ); + } else if super::super::is_local_provider_no_model_loaded(status, &error) { + // Local inference server (LM Studio etc.) is up but has no model + // loaded — pure local user-state, nothing we sent is malformed. + // Demote instead of paging on every retry, and replace the body + // with actionable "load a model" guidance (TAURI-RUST-DMQ, + // mirrors the embeddings #3688 special-case). + super::super::log_local_provider_no_model_loaded( + "native_chat", + self.name.as_str(), + Some(model), + status, + ); + message = super::super::local_provider_no_model_loaded_user_message(); } else if super::super::is_custom_openai_upstream_bad_request_http_400( self.name.as_str(), status, @@ -988,6 +1010,17 @@ impl Provider for OpenAiCompatibleProvider { Some(model_owned.as_str()), status, ); + } else if crate::openhuman::inference::provider::is_local_provider_no_model_loaded( + status, &raw_error, + ) { + // Local inference server up but no model loaded — pure local + // user-state, demote instead of paging (TAURI-RUST-DMQ). + crate::openhuman::inference::provider::log_local_provider_no_model_loaded( + "stream_chat", + provider_name.as_str(), + Some(model_owned.as_str()), + status, + ); } else if crate::openhuman::inference::provider::is_custom_openai_upstream_bad_request_http_400( provider_name.as_str(), status, @@ -1222,6 +1255,17 @@ impl Provider for OpenAiCompatibleProvider { Some(model_owned.as_str()), status, ); + } else if crate::openhuman::inference::provider::is_local_provider_no_model_loaded( + status, &raw_error, + ) { + // Local inference server up but no model loaded — pure local + // user-state, demote instead of paging (TAURI-RUST-DMQ). + crate::openhuman::inference::provider::log_local_provider_no_model_loaded( + "stream_chat_history", + provider_name.as_str(), + Some(model_owned.as_str()), + status, + ); } else if crate::openhuman::inference::provider::is_custom_openai_upstream_bad_request_http_400( provider_name.as_str(), status, diff --git a/src/openhuman/inference/provider/ops/http_error.rs b/src/openhuman/inference/provider/ops/http_error.rs index bdeea9077..a4e8fa0ff 100644 --- a/src/openhuman/inference/provider/ops/http_error.rs +++ b/src/openhuman/inference/provider/ops/http_error.rs @@ -24,6 +24,52 @@ pub fn is_budget_exhausted_http_400(status: reqwest::StatusCode, body: &str) -> && crate::openhuman::inference::provider::is_budget_exhausted_message(body) } +/// Whether a provider non-2xx response is a local inference server that is +/// running but has **no model loaded** (e.g. LM Studio idle): a 400 carrying +/// `No models loaded. Please load a model …`. +/// +/// This is pure local user-state — nothing OpenHuman sent is malformed, there +/// is no product bug and no local lever beyond the user loading a model — so it +/// should be demoted from Sentry to an info log rather than paging on every +/// retry (TAURI-RUST-DMQ: 5,469 events from a single idle LM Studio server). +/// The embeddings path already special-cases this exact string +/// (`embeddings/rpc.rs`, PR #3688 / TAURI-RUST-4P4); this is the chat sibling. +pub fn is_local_provider_no_model_loaded(status: reqwest::StatusCode, body: &str) -> bool { + status == reqwest::StatusCode::BAD_REQUEST + && body.to_ascii_lowercase().contains("no models loaded") +} + +/// Actionable user-facing guidance for a local inference server with no model +/// loaded, mirroring the embeddings verification message +/// (`embeddings/rpc.rs`). Returned in place of the raw provider body so the +/// surfaced error tells the user how to fix it. +pub fn local_provider_no_model_loaded_user_message() -> String { + "Your local inference server (e.g. LM Studio) is running but has no model loaded. \ + Load a model — in LM Studio use the developer page or the `lms load` command — \ + then try again." + .to_string() +} + +pub fn log_local_provider_no_model_loaded( + operation: &str, + provider: &str, + model: Option<&str>, + status: reqwest::StatusCode, +) { + tracing::info!( + domain = "llm_provider", + operation = operation, + provider = provider, + model = model.unwrap_or(""), + status = status.as_u16(), + failure = "non_2xx", + kind = "provider_user_state", + reason = "local_provider_no_model_loaded", + "[llm_provider] {operation} local inference server has no model loaded — \ + user must load a model, not reporting to Sentry" + ); +} + /// Whether a custom OpenAI-compatible proxy returned the known generic /// upstream 400 envelope: /// `{"error":{"message":"Bad request to upstream provider","type":"upstream_error","status":400}}`. @@ -901,6 +947,11 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E let is_auth_failure = matches!(status.as_u16(), 401 | 403); let is_backend = provider == openhuman_backend::PROVIDER_LABEL; let is_budget_exhausted_user_state = is_budget_exhausted_http_400(status, &body); + // Local inference server (LM Studio etc.) running with no model loaded — + // pure local user-state, nothing we sent is malformed. Demote and replace + // the body with actionable "load a model" guidance (TAURI-RUST-DMQ, mirrors + // the embeddings #3688 special-case). + let is_local_provider_no_model_loaded = is_local_provider_no_model_loaded(status, &body); let is_custom_openai_upstream_bad_request = is_custom_openai_upstream_bad_request_http_400(provider, status, &body); let is_provider_access_policy_denied = is_provider_access_policy_denied_http_403(status, &body); @@ -948,6 +999,8 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E publish_backend_session_expired("api_error", provider, status, &message); } else if is_budget_exhausted_user_state { log_budget_exhausted_http_400("api_error", provider, None, status); + } else if is_local_provider_no_model_loaded { + log_local_provider_no_model_loaded("api_error", provider, None, status); } else if is_custom_openai_upstream_bad_request { log_custom_openai_upstream_bad_request_http_400("api_error", provider, None, status); } else if is_provider_access_policy_denied { @@ -986,6 +1039,11 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E if is_ollama_cloud_internal_500 { return anyhow::anyhow!(ollama_cloud_internal_500_user_message(None, status)); } + // Replace the raw `No models loaded` body with actionable guidance so the + // surfaced chat error tells the user how to recover (TAURI-RUST-DMQ). + if is_local_provider_no_model_loaded { + return anyhow::anyhow!(local_provider_no_model_loaded_user_message()); + } anyhow::anyhow!(message) } @@ -994,6 +1052,37 @@ mod tests { use super::*; use reqwest::StatusCode; + /// Verbatim TAURI-RUST-DMQ LM Studio body — the local server is running but + /// has no model loaded. The matcher keys on this prose, so coupling the test + /// to the exact string makes a wording drift fail CI rather than silently + /// leak events back to Sentry. + const DMQ_BODY: &str = "lm_studio API error (400 Bad Request): {\"error\":\ + {\"message\":\"No models loaded. Please load a model in the developer page \ + or via `lms load`.\",\"type\":\"invalid_request_error\",\"param\":\"model\"}}"; + + #[test] + fn local_provider_no_model_loaded_matches_verbatim_dmq_body() { + assert!(is_local_provider_no_model_loaded( + StatusCode::BAD_REQUEST, + DMQ_BODY + )); + // Status-gated: the same prose on a non-400 status must not match (the + // 400 is the local-idle signal; other statuses are different failures). + assert!(!is_local_provider_no_model_loaded( + StatusCode::INTERNAL_SERVER_ERROR, + DMQ_BODY + )); + // A generic 400 (malformed request) must stay reportable. + assert!(!is_local_provider_no_model_loaded( + StatusCode::BAD_REQUEST, + "{\"error\":{\"message\":\"invalid 'temperature': must be <= 2\"}}" + )); + // The surfaced guidance is actionable (tells the user to load a model). + assert!(local_provider_no_model_loaded_user_message() + .to_ascii_lowercase() + .contains("load a model")); + } + /// Verbatim TAURI-RUST-C62 provider body. The matcher keys on this prose, /// so coupling the test to the exact string makes a provider wording drift /// fail CI rather than silently leak events back to Sentry. diff --git a/src/openhuman/inference/provider/ops/mod.rs b/src/openhuman/inference/provider/ops/mod.rs index 5f87c4b2a..aafd8dbd9 100644 --- a/src/openhuman/inference/provider/ops/mod.rs +++ b/src/openhuman/inference/provider/ops/mod.rs @@ -22,17 +22,19 @@ pub use http_error::{ api_error, body_indicates_insufficient_credits, body_indicates_quota_exhausted, is_backend_auth_failure, is_backend_error_code_owned, is_budget_exhausted_http_400, is_byo_provider_auth_failure_http, is_context_window_exceeded_message, - is_custom_openai_upstream_bad_request_http_400, is_ollama_cloud_internal_500, - is_ollama_cloud_internal_500_message, is_openai_oauth_session_expired_http, - is_openai_oauth_session_expired_message, is_provider_access_policy_denied_http_403, - is_provider_config_rejection_http, is_provider_insufficient_credits_402, - is_provider_quota_exhausted, log_backend_error_code_owned, log_budget_exhausted_http_400, - log_byo_provider_auth_failure, log_context_window_exceeded, - log_custom_openai_upstream_bad_request_http_400, log_ollama_cloud_internal_500, - log_openai_oauth_session_expired, log_provider_access_policy_denied_http_403, - log_provider_config_rejection, log_provider_insufficient_credits_402, - log_provider_quota_exhausted, ollama_cloud_internal_500_user_message, - publish_backend_session_expired, should_report_provider_http_failure, + is_custom_openai_upstream_bad_request_http_400, is_local_provider_no_model_loaded, + is_ollama_cloud_internal_500, is_ollama_cloud_internal_500_message, + is_openai_oauth_session_expired_http, is_openai_oauth_session_expired_message, + is_provider_access_policy_denied_http_403, is_provider_config_rejection_http, + is_provider_insufficient_credits_402, is_provider_quota_exhausted, + local_provider_no_model_loaded_user_message, log_backend_error_code_owned, + log_budget_exhausted_http_400, log_byo_provider_auth_failure, log_context_window_exceeded, + log_custom_openai_upstream_bad_request_http_400, log_local_provider_no_model_loaded, + log_ollama_cloud_internal_500, log_openai_oauth_session_expired, + log_provider_access_policy_denied_http_403, log_provider_config_rejection, + log_provider_insufficient_credits_402, log_provider_quota_exhausted, + ollama_cloud_internal_500_user_message, publish_backend_session_expired, + should_report_provider_http_failure, }; pub use models::{