mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(provider): demote lm_studio 'No models loaded' 400 in chat (#4189)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user