fix(provider): demote lm_studio 'No models loaded' 400 in chat (#4189)

This commit is contained in:
Mega Mind
2026-06-28 21:24:58 -07:00
committed by GitHub
parent 723a326460
commit d4fb491e0f
3 changed files with 146 additions and 11 deletions
@@ -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,
@@ -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.
+13 -11
View File
@@ -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::{