From 5b2a738662cc09b47930d1cd520072e908aa87fb Mon Sep 17 00:00:00 2001 From: Raphael Malikian Date: Wed, 17 Jun 2026 16:08:55 -0700 Subject: [PATCH] fix: propagate API error messages for auto-embeddings with empty/too-long input When a remote embedding API (OpenAI, Voyage, Jina) returns an error for empty input or input exceeding the model's token limit, the actual error message from the API was being discarded. Users would see a generic 'HTTP error from remote model: status code 400' instead of the descriptive message like 'maximum context length is 8192 tokens'. Changes: - Add 'message' field to RemoteHttpError variant to carry API error details - Extract error message from API JSON response (error.message for OpenAI/Voyage, error.message or detail for Jina) - Include API error message in displayed error output - Add pre-validation for empty inputs before making API calls across all three remote model implementations (OpenAI, Voyage, Jina) - Add descriptive messages for all RemoteHttpError usages in the success response parsing path Fixes #143 --- embeddings/src/error.rs | 9 +++++++-- embeddings/src/model/jina.rs | 37 ++++++++++++++++++++++++++++++++++ embeddings/src/model/openai.rs | 24 ++++++++++++++++++++++ embeddings/src/model/voyage.rs | 26 ++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/embeddings/src/error.rs b/embeddings/src/error.rs index 6a74939f..459f0079 100644 --- a/embeddings/src/error.rs +++ b/embeddings/src/error.rs @@ -28,6 +28,7 @@ pub enum LibError { HuggingFaceTokenInvalid, RemoteHttpError { status: u16, + message: Option, }, OnnxModelEvalFailed, } @@ -91,8 +92,12 @@ impl std::fmt::Display for LibError { LibError::HuggingFaceTokenInvalid => { write!(f, "Invalid or expired HuggingFace token") } - LibError::RemoteHttpError { status } => { - write!(f, "HTTP error from remote model: status code {}", status) + LibError::RemoteHttpError { status, message } => { + if let Some(msg) = message { + write!(f, "HTTP error from remote model: status code {}: {}", status, msg) + } else { + write!(f, "HTTP error from remote model: status code {}", status) + } } LibError::OnnxModelEvalFailed => write!(f, "Failed to evaluate ONNX model"), } diff --git a/embeddings/src/model/jina.rs b/embeddings/src/model/jina.rs index d09067d5..de503ff5 100644 --- a/embeddings/src/model/jina.rs +++ b/embeddings/src/model/jina.rs @@ -112,6 +112,15 @@ impl JinaModel { impl TextModel for JinaModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -136,10 +145,30 @@ impl TextModel for JinaModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| { + // Try standard error.message format first + if let Some(msg) = body.get("error")?.get("message")?.as_str() { + return Some(msg.to_string()); + } + // Try Jina's detail format + if let Some(detail) = body.get("detail")?.as_str() { + return Some(detail.to_string()); + } + None + }) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -166,6 +195,7 @@ impl TextModel for JinaModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -199,6 +229,7 @@ impl TextModel for JinaModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, } }; @@ -217,6 +248,7 @@ impl TextModel for JinaModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -225,6 +257,7 @@ impl TextModel for JinaModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; // Check if there's an error in the response (shouldn't happen if status was success, but check anyway) @@ -247,6 +280,7 @@ impl TextModel for JinaModel { "rate_limit_exceeded" | "quota_exceeded" => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()), }, }; @@ -271,6 +305,7 @@ impl TextModel for JinaModel { } else { LibError::RemoteHttpError { status: status_code, + message: Some(detail_str.to_string()), } }; return Err(Box::new(lib_error)); @@ -295,6 +330,7 @@ impl TextModel for JinaModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } @@ -306,6 +342,7 @@ impl TextModel for JinaModel { if embedding.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned an empty embedding vector".to_string()), })); } if embedding.len() != inferred_dim { diff --git a/embeddings/src/model/openai.rs b/embeddings/src/model/openai.rs index 3a7922f3..22722114 100644 --- a/embeddings/src/model/openai.rs +++ b/embeddings/src/model/openai.rs @@ -94,6 +94,15 @@ impl OpenAIModel { impl TextModel for OpenAIModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -118,10 +127,20 @@ impl TextModel for OpenAIModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| body.get("error")?.get("message")?.as_str().map(|s| s.to_string())) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -143,6 +162,7 @@ impl TextModel for OpenAIModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -160,6 +180,7 @@ impl TextModel for OpenAIModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -169,12 +190,14 @@ impl TextModel for OpenAIModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; let data_array = response_body["data"].as_array(); if data_array.is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response missing 'data' array".to_string()), })); } @@ -196,6 +219,7 @@ impl TextModel for OpenAIModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } diff --git a/embeddings/src/model/voyage.rs b/embeddings/src/model/voyage.rs index b56fd57e..cbf69462 100644 --- a/embeddings/src/model/voyage.rs +++ b/embeddings/src/model/voyage.rs @@ -99,6 +99,15 @@ impl VoyageModel { impl TextModel for VoyageModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -123,10 +132,20 @@ impl TextModel for VoyageModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| body.get("error")?.get("message")?.as_str().map(|s| s.to_string())) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -152,6 +171,7 @@ impl TextModel for VoyageModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -169,6 +189,7 @@ impl TextModel for VoyageModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -178,11 +199,13 @@ impl TextModel for VoyageModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; if response_body.get("data").is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response missing 'data' field".to_string()), })); } @@ -191,6 +214,7 @@ impl TextModel for VoyageModel { if data_array.is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response 'data' is not an array".to_string()), })); } @@ -212,6 +236,7 @@ impl TextModel for VoyageModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } @@ -223,6 +248,7 @@ impl TextModel for VoyageModel { if embedding.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned an empty embedding vector".to_string()), })); } if embedding.len() != inferred_dim {